Layered and Modular Monolith Architecture
TechLearningPro’s commerce system started as one Spring Boot application with packages named 'controller', 'service', 'repository', and 'domain'. That layout matched every tutorial
Layered and Modular Monolith Architecture
Advanced System Design & Software Architecture · Lesson 07
Lesson Overview
TechLearningPro’s commerce system started as one Spring Boot application with packages named controller, service, repository, and domain. That layout matched every tutorial the team had used, and for a catalog of a few dozen courses it was enough. The same deployable now sells courses, physical books, and exam vouchers. Four squads commit to it. A change to voucher expiry in CatalogService regularly breaks checkout because OrderService injects CatalogRepository and reads columns the catalog squad thought were private. Deployments are still one artifact, which the team likes: one pipeline, one database, one on-call rotation. The architecture they dislike is the one the packages failed to create.
A layered architecture separates technical responsibilities: presentation, application, domain, infrastructure. A modular monolith separates business capabilities inside one deployable: catalog, orders, payment, fulfillment. Each module has a public API and an internal implementation. The compiler, Spring Modulith’s ApplicationModules.verify(), and PostgreSQL schema grants make a cross-module reach a failed build or a failed query, not a surprise in production. Package-by-feature is the folder shape. Modularity is the rule the folders enforce.
This lesson uses those four commerce capabilities as the worked example. Inside a module, the hexagonal layout from Hexagonal Architecture and Ports & Adapters still applies. Between modules, you choose an in-process API call or an application event, and you notice when a single database transaction crosses a boundary you hope to extract later. Microservices Architecture Principles is the later decision. This lesson builds the seams that make that decision cheap. The enterprise track covers the same styles as Layered Architecture and Modular Monolith.
Learning Objectives
By the end of this lesson, you should be able to:
- Separate a technical layer (presentation, application, domain, infrastructure) from a business module (catalog, orders, payment, fulfillment).
- Explain why a package-by-layer layout lets
OrderServicedepend onCatalogRepositoryeven when the team’s wiki says it must not. - Structure a Spring Boot application so each capability is a Spring Modulith application module with a public API and internal packages.
- Restrict module dependencies with
@ApplicationModule(allowedDependencies = …)and fail CI whenApplicationModules.verify()reports a violation. - Give each module its own PostgreSQL schema and database grants, and snapshot foreign data onto the owning module’s rows instead of joining across schemas on the write path.
- Choose a synchronous in-process call for a shopper-facing reservation and an application event for fulfillment after authorization.
- List the extraction seams — API, schema, transaction, team — that must already hold before a module becomes its own deployable.
- Write an ADR-ready argument for keeping one deployable while payment’s schema and API are already isolated for a future PCI boundary.
Prerequisites
- Lesson 6, hexagonal architecture: ports and adapters describe the inside of a module. This lesson describes how modules sit side by side in one JVM.
- SOLID Principles in Practice: the dependency inversion you applied to classes now applies to packages.
- Requirements and Architecture Drivers: independent deployability is a driver you either have or do not. A modular monolith is the honest structure when you do not.
- Java 21 packages,
package-info.java, and constructor injection. - PostgreSQL schemas and
GRANTat a conceptual level. One database, several schemas. - Spring Boot 3.4+ or Boot 4, and Spring Modulith 1.2+ (the reference in this lesson matches the 2.x fundamentals:
@ApplicationModule,@NamedInterface,ApplicationModules.verify()).
Detailed Core Concepts
Layers classify technology; modules classify business change
A layer answers “what kind of code is this?” A module answers “whose rule is this, and who is allowed to change it?”
| Layer | Responsibility in commerce | Typical Spring types |
|---|---|---|
| Presentation / driving adapters | HTTP and message entry | @RestController, @KafkaListener inside the module |
| Application | Use-case sequencing, transactions at the module edge | @Service implementing the module API |
| Domain | Invariants for that capability | Order, Money, catalog Product rules |
| Infrastructure / driven adapters | SQL, HTTP clients, mail | JdbcClient, RestClient |
Those four layers are a useful checklist inside catalog and again inside orders. They are a poor top-level package structure for a system four squads share. If the top-level packages are controller and service, every squad edits the same packages, and Java’s visibility rules cannot express “orders may call catalog’s API and must not call catalog’s SQL.”
A business module in this lesson is:
- A package tree with one owner squad.
- A small public API: interfaces and types in the module’s base package, or a named interface package.
- An internal tree the other modules fail verification for touching.
- Its own schema in PostgreSQL.
- Its own tests, which fake other modules’ APIs.
The deployable is still one Spring Boot jar, one process, one CommerceApplication. That is the “monolith” in modular monolith. “Modular” means the boundaries are enforced in code and in the database, so a later split copies a module out instead of untangling it.
Package-by-layer versus package-by-feature
Package-by-layer groups code by technical role:
textcom.techlearningpro.commercecontroller OrderController, CatalogController, PaymentControllerservice OrderService, CatalogService, PaymentServicerepository OrderRepository, CatalogRepositorydomain Order, Product, Authorization
OrderService and CatalogRepository are both public classes in packages everyone already depends on. The IDE offers CatalogRepository as a constructor parameter. Nothing fails. The wiki page “do not bypass catalog” is a social rule. Social rules decay under a release deadline.
Package-by-feature (here, package-by-module) groups code by capability:
textcom.techlearningpro.commerceCommerceApplicationshared Money, ShopperId, Sku (tiny shared kernel)catalog public API in this packageinternal JDBC, pricing rulesordersinternalpaymentinternalfulfillmentinternal
Spring Modulith treats each direct child of the application package as an application module. Types in catalog (the base package) are catalog’s API. Types in catalog.internal are invisible to other modules by default. A dependency from orders.internal to catalog.internal fails ApplicationModules.verify().
What verify() enforces
org.springframework.modulith.core.ApplicationModules reads the package structure and, when you call verify(), checks the rules documented in Spring Modulith’s fundamentals:
- A module’s base package is the default API. Subpackages are internal unless exposed.
- Other modules may depend on that API. They may not depend on internal types.
- If a module declares
allowedDependencies, only those modules (and any application-wide shared modules) are legal. The reference documentation is explicit: an empty array allows no dependencies on other modules. Leaving the attribute at its default leaves dependencies unrestricted apart from the API-versus-internal rule. - Cycles between modules fail verification.
java@org.springframework.modulith.ApplicationModule(allowedDependencies = "catalog")package com.techlearningpro.commerce.orders;
Orders may use types in com.techlearningpro.commerce.catalog (the base package). Orders may not use catalog.internal. Orders may not use payment unless you add "payment" to the list. Payment’s allowedDependencies stays empty (allowedDependencies = {}) if payment must not call orders, catalog, or fulfillment. An empty array is a real restriction. Do not write it thinking you are accepting the default.
When the public types should live in a subpackage, mark a named interface:
java@org.springframework.modulith.NamedInterface("api")package com.techlearningpro.commerce.catalog.api;
Dependents that opt into an explicit list then name it as in the Spring Modulith reference: allowedDependencies = "catalog :: api". The double colon separates the module name from the named interface. "catalog :: *" allows every named interface of catalog and still excludes the unnamed base package. Prefer the base package as the API until you have a second, genuinely different face (for example an SPI that fulfillment implements). Extra named interfaces are a tool, not a default.
Verification belongs in a test:
java@Testvoid modularStructure() {ApplicationModules.of(CommerceApplication.class).verify();}
A violation throws and the build fails. That is the difference between a modular monolith and a folder convention.
Shared kernel stays small
Money, Sku, and ShopperId sit in shared because every module speaks them and none of them has a squad-specific rule. Spring Modulith allows dependencies on code that is not itself a module. The moment shared grows an OrderHelper or a CatalogJdbc class, it has become a fifth module with no owner. Review rule: a type moves into shared only when at least two modules need it and it contains no I/O. If catalog and orders both need a price calculation, the calculation has one owner — catalog — and orders calls the API.
Transactions across modules are a design decision
Inside one PostgreSQL instance a single Spring transaction can update orders.checkout_order and payment.authorization. The database will commit them together. The modularity cost is hidden: you have coupled their availability and their release. Extracting payment into its own service later replaces that atomic commit with a reservation, an outbox, and a reconciliation job (Distributed Transactions and the Saga Pattern).
This lesson’s rule for TechLearningPro:
| Interaction | Mechanism | Transaction |
|---|---|---|
| Orders needs a price | Synchronous call to CatalogPrices in-process | Catalog read is its own short read. Orders stores the returned Money on the line. |
| Orders needs an authorization | Synchronous call to payment’s PaymentGateway API | Separate transactions, as in lesson 6. Payment’s schema commits the authorization; orders’ schema commits AUTHORIZED. |
| Fulfillment should ship after authorization | @ApplicationModuleListener on an OrderAuthorized event | Listener runs after commit, in a new transaction (REQUIRES_NEW is part of that stereotype). |
| Shopper must know stock is held | Synchronous call to fulfillment’s ReservationApi | Fulfillment’s transaction commits the hold. Orders stores the reservation id. A crash between them is reconciled by the reservation’s expiry, not by a two-schema global transaction. |
@ApplicationModuleListener (org.springframework.modulith.events.ApplicationModuleListener) is Spring Modulith’s stereotype for a module listening to another module’s application events. It is asynchronous relative to the publisher, transactional, and tied to Modulith’s event publication registry so a listener failure can be republished. It is an in-process integration, not Kafka. Kafka appears when a module is extracted or when a listener’s work must survive independently of this JVM. Lesson 9 covers that transport. Using Kafka inside one deployable “so we are ready” adds a broker to a problem packages already solve.
Schema-per-module is the extraction seam you can feel
textcatalog.productcatalog.priceorders.checkout_orderorders.checkout_lineorders.checkout_idempotencypayment.authorizationfulfillment.reservationfulfillment.shipment
The orders role receives SELECT on catalog.price only if you still allow a cross-schema read. This design does not. Orders calls the catalog API in-process, and the API’s adapter is the only code whose datasource user can read catalog. At the SQL level you can enforce that with two roles even inside one application: it is awkward in a single Spring DataSource, so many teams enforce the schema boundary in review and ArchUnit first, and add roles when a second service is imminent. The table layout still matters today: an order line stores sku, title_snapshot, unit_price, and currency copied at place time. It does not store a foreign key that implies SELECT … FROM catalog.product on the checkout write path. When catalog becomes another service, the snapshot remains valid and the in-process call becomes an HTTP adapter implementing the same CatalogPrices interface. That adapter swap is the seam.
Joins across schemas for reporting can live in a separate reporting role with read-only grants. They must not be the write model.
What a layered architecture is still good for
A three-screen admin tool, one squad, a handful of CRUD tables, and a six-month life does not need four modules and a publication registry. Classic layers plus a single package are honest. The failure mode is keeping that layout after a second squad and a second business capability arrive, because “we will split packages later” is the work you are now doing under incident pressure.
Inside a module, layers remain. orders.internal.adapter.inbound, orders.internal.application, orders.internal.domain, orders.internal.adapter.outbound are the lesson 6 packages, nested so the module API (orders.PlaceOrder) is the only type a sibling module sees. Hexagonal inside, modular between.
Extraction is a consequence, not a phase
A module is a candidate for its own deployable when all of the following are true:
- Another squad needs to release it on a different cadence, or a security boundary (PCI for payment) requires a smaller blast radius.
- The public API is already the only dependency, and
verify()is green. - No foreign keys and no write-path joins cross the schema boundary.
- The cross-module transaction list is empty, or each former transaction has a documented saga or reconciliation.
- The operational cost of a second pipeline, a second on-call surface, and network failure is accepted in an ADR (lesson 10).
Until then the modular monolith is the production architecture, not a waiting room. Architecture Migration and Modernization covers the move. This lesson’s job is to keep that move from requiring a rewrite.
Architecture and Design Diagrams
mermaidflowchart TBsubgraph boot [One Spring Boot process]subgraph catalog [catalog module]CAPI[CatalogPrices API]CINT[catalog.internal JDBC]CAPI --> CINTendsubgraph orders [orders module]OAPI[PlaceOrder API]OINT[orders.internal]OAPI --> OINTendsubgraph payment [payment module]PAPI[PaymentGateway API]PINT[payment.internal]PAPI --> PINTendsubgraph fulfillment [fulfillment module]FAPI[ReservationApi]FL[OrderAuthorized listener]endendOINT --> CAPIOINT --> PAPIOINT --> FAPIOINT -. event after commit .-> FLCINT --> CS[(schema catalog)]OINT --> OS[(schema orders)]PINT --> PS[(schema payment)]FL --> FS[(schema fulfillment)]FAPI --> FS
mermaidflowchart LRsubgraph forbidden [Package-by-layer leak]OS2[OrderService] --> CR[CatalogRepository]endsubgraph allowed [Modular dependency]OI[orders.internal] --> API[catalog.CatalogPrices]API --> CI[catalog.internal]end
mermaidsequenceDiagramparticipant UI as Shopperparticipant O as orders.PlaceOrderparticipant C as catalog.CatalogPricesparticipant F as fulfillment.ReservationApiparticipant P as payment.PaymentGatewayparticipant L as fulfillment listenerUI->>O: place orderO->>C: priceOf(sku)C-->>O: Money snapshotO->>F: reserve(sku, qty)F-->>O: reservationId or rejectedO->>P: authorizeP-->>O: APPROVEDNote over O: commit orders row AUTHORIZEDO--)L: OrderAuthorizedL->>L: create shipment in a new transaction
The shopper waits for price, reservation, and authorization. The shopper does not wait for shipment creation. That split is the modular design, visible in the sequence, and it is the same split you will keep if fulfillment moves to another process.
Technical Explanation
How a place-order call crosses modules without crossing internals
OrderController lives in orders.internal.adapter.inbound. It is not part of the orders API. It calls PlaceOrderService in the same module. The service’s constructor takes CatalogPrices, ReservationApi, and PaymentGateway, all interfaces from other modules’ base packages. Spring injects the implementations because they are @Service beans in those modules. Injection across the API is legal. Injection of catalog.internal.JdbcCatalogRepository into the orders service is a verification failure and, if someone bypasses the container with a manual new, still a boundary violation you can catch with ArchUnit.
Price lookup returns Money. The order line persists that value. A later catalog price change does not rewrite open orders. That is a business rule owned by orders, using data catalog produced. Storing catalog_price_id and joining at read time for the checkout total couples the modules at the worst moment, the shopper’s request, and it couples their schemas.
Reservation is synchronous because the product promise is “we will not sell the same seat twice.” ReservationApi.reserve either inserts a hold with an expiry or returns a typed rejection (NONE_AVAILABLE). Orders maps rejection to a place result of REJECTED and does not call payment. The hold expiry is fulfillment’s policy (for example 15 minutes). A shopper who authorizes successfully causes orders to call ReservationApi.confirm(reservationId). A shopper who abandons the flow lets the hold expire. Confirm and reserve are explicit API methods so orders does not update fulfillment.reservation with SQL.
Payment is the lesson 6 port, now owned by the payment module. Orders depends on payment. Payment’s allowedDependencies is empty: an authorization must not reach back into orders to “just load the lines.” Everything payment needs is on AuthorizationRequest. This one-way dependency is what keeps the cycle check green. A callback from payment into orders is an event, AuthorizationRecorded, if you ever need it, not a dependency from payment’s service onto PlaceOrder.
Listener semantics you can operate
The publisher uses Spring’s ApplicationEventPublisher.publishEvent(new OrderAuthorized(...)) after the orders transaction has committed, or from a transactional listener inside orders that fires on AFTER_COMMIT. The fulfillment method:
java@ApplicationModuleListenervoid onAuthorized(OrderAuthorized event) {shipments.createFor(event.orderId(), event.lines());}
Because the stereotype includes a new transaction, a shipment insert does not enlist in the already-finished orders transaction. If createFor throws, Modulith’s publication registry retains the incomplete publication and can invoke the listener again. The listener must tolerate a replay: createFor checks a unique order_id on fulfillment.shipment and returns the existing row on conflict. That is the same idempotency idea as lesson 6, applied to an in-process event. Assuming the registry gives you exactly-once delivery will double-ship when you retry.
OrderAuthorized is a type in the orders base package so other modules may name it. It carries order id, shopper id, and line snapshots (sku, quantity). It does not carry a JPA Order entity and it does not carry the payment token.
Failure and consistency the shopper can understand
| Failure | Shopper-visible result | Module state |
|---|---|---|
| Catalog price missing | 404-style problem from orders, nothing reserved | No rows |
| Reservation rejected | Item unavailable | No payment call, no order in AUTHORIZED |
| Payment declined | Declined, reservation released by ReservationApi.release | Payment stores decline; fulfillment deletes or expires the hold |
Payment UNKNOWN | Retry with the same key | Hold remains until expiry or resolution |
| Process dies after payment approval, before confirm | Reconciler in orders confirms holds for AUTHORIZED orders | Payment has the authorization |
| Shipment listener fails | Shopper still sees an authorized order | Registry retries; unique key prevents two shipments |
This is modular consistency: each module’s schema is internally atomic, and cross-module work has a named recovery. A staff review that asks “is it ACID?” deserves the table, not a yes.
Security and ownership
Each module trusts the shopper id that the edge authenticated and that orders copied into the event. Fulfillment does not re-parse the browser cookie. Payment checks token ownership through the tokenizer, as in lesson 6, and that check lives in the payment module because it is payment’s rule. Catalog’s internal price tables are not granted to the fulfillment role. A report that needs all four schemas uses a read-only role and a replica, and it is not imported by the order path.
Team ownership matches packages. CODEOWNERS maps catalog/** to the catalog squad. A pull request that changes allowedDependencies requires both squads. That social rule works because the technical rule is in CI; CODEOWNERS without verify() is the wiki page again.
Modifiability and the cost you refuse
Four modules in one process share a heap, a deploy, and a failure domain. A bug in catalog that leaks memory takes down checkout. The modular monolith does not give you fault isolation. It gives you a boundary you can see and a path to isolation when the driver appears. Adding a fifth capability, “tax,” is a new package, a new schema, an API TaxCalculator, and an allowedDependencies update on orders. It is not a new Kubernetes service, a new on-call, and a new latency term on the place path unless tax is operated by a different organization that already ships its own service.
Practical Implementation or Design Walkthrough
Problem. One Spring Boot application, four squads, PostgreSQL, about 40 order placements per second at peak, catalog reads far higher. Payment will likely move behind a PCI boundary within a year. Fulfillment must not add its latency to the shopper’s authorization response except for the stock hold, which the shopper does wait for.
Assumptions. One repository, one CommerceApplication, Spring Modulith on the classpath, one PostgreSQL database with four schemas, JDBC rather than cross-module JPA relationships. No foreign keys between schemas.
1. Module metadata
java@org.springframework.modulith.ApplicationModule(allowedDependencies = { "catalog", "payment", "fulfillment" })package com.techlearningpro.commerce.orders;
java@org.springframework.modulith.ApplicationModule(allowedDependencies = {})package com.techlearningpro.commerce.payment;
java@org.springframework.modulith.ApplicationModule(allowedDependencies = "orders")package com.techlearningpro.commerce.fulfillment;
Fulfillment depends on orders only so it may mention OrderAuthorized, which lives in the orders API. If you would rather not have that dependency, move the event type to a named interface orders :: events and allow only that. Do not put the event class in orders.internal. Catalog’s allowedDependencies is empty: pricing does not call orders.
2. Catalog API and the only SQL that reads prices
javapackage com.techlearningpro.commerce.catalog;public interface CatalogPrices {Money priceOf(Sku sku);}
javapackage com.techlearningpro.commerce.catalog.internal;@Serviceclass CatalogPricesImpl implements CatalogPrices {private final JdbcClient jdbc;CatalogPricesImpl(JdbcClient jdbc) { this.jdbc = jdbc; }@Overridepublic Money priceOf(Sku sku) {return jdbc.sql("""select amount, currency from catalog.pricewhere sku = :sku and effective_from <= now()order by effective_from desclimit 1""").param("sku", sku.value()).query((rs, n) -> new Money(rs.getBigDecimal("amount"),Currency.getInstance(rs.getString("currency")))).optional().orElseThrow(() -> new UnknownSku(sku));}}
The class is package-visible. The interface is public. Orders cannot compile against CatalogPricesImpl without adding a dependency on an internal type, and verify() rejects that even if someone widens the class to public.
3. Orders snapshots the price
javaMoney unit = catalogPrices.priceOf(line.sku());order.addLine(line.sku(), line.quantity(), unit, catalogPrices.titleOf(line.sku()));
checkout_line columns: sku, title_snapshot, unit_amount, currency, quantity. No price_id. The insert uses orders.checkout_line. A reviewer who sees catalog.price in a statement under orders/ rejects the pull request. A grep in CI for from catalog. under src/main/java/**/orders/** is a blunt version of the same rule and is worth having because Modulith does not parse SQL.
4. Reservation API
javapackage com.techlearningpro.commerce.fulfillment;public interface ReservationApi {Reservation reserve(ReserveCommand command);void confirm(ReservationId id);void release(ReservationId id);}
ReserveCommand carries sku, quantity, and an idempotency key owned by the shopper attempt. reserve inserts into fulfillment.reservation with status = HELD and expires_at. A unique (idempotency_key) makes the shopper’s double-click safe. confirm updates HELD to CONFIRMED only if expires_at is still in the future. Orders calls confirm after payment approval, in its own step, not inside payment’s transaction.
5. The listener and the unique shipment
javapackage com.techlearningpro.commerce.fulfillment.internal;@Componentclass ShipmentOnAuthorized {private final ShipmentCreator shipments;ShipmentOnAuthorized(ShipmentCreator shipments) { this.shipments = shipments; }@ApplicationModuleListenervoid onAuthorized(OrderAuthorized event) {shipments.createFor(event);}}
createFor inserts fulfillment.shipment with primary key order_id. A duplicate insert is treated as success. The publication registry may redeliver; the table is the deduplication.
6. The test that makes the boundary real
javaclass ModularityTest {@Testvoid verifyModules() {ApplicationModules.of(CommerceApplication.class).verify();}}
Add a deliberate failing test in a branch once: a class in orders.internal that imports catalog.internal.CatalogPricesImpl. Confirm CI goes red, then delete it. Teams that never see a red verify() do not trust a green one.
Documenter in org.springframework.modulith.docs can write module canvases from this structure. Generate them in the same job as verify() so the picture in the architecture repo cannot drift from the packages. Lesson 10 explains where those generated pages sit among C4 views and ADRs. The generated canvas is a component-level view of one deployable, not a system context diagram.
7. What you deliberately do not build
- No Spring Data relationships with
@ManyToOneacross modules. They are foreign keys in disguise. - No shared
EntityManagerscanning all packages and lazy-loading aProductfrom anOrder. - No Kafka topic for
OrderAuthorizedwhile there is a single process, unless a second, non-Java consumer already exists. - No “common” service module that all four depend on for orchestration. Orchestration of place-order belongs to orders, the capability the shopper invoked.
Expected result. verify() passes. A place call writes only to orders and, through APIs, to fulfillment and payment. Shipment appears after commit even if you slow the listener in a test. A unit test of orders uses in-memory CatalogPrices, ReservationApi, and PaymentGateway. Catalog’s JDBC test never starts the orders web slice.
Enterprise Real-World Scenarios
Scenario 1: Four squads, one Friday deploy
Catalog, orders, payment, and fulfillment are four squads and one pipeline. Peak checkout is 40 placements per second. Catalog browse is two orders of magnitude higher and is served from the same process today, with a cache inside catalog.internal. The pain last quarter was a fulfillment schema migration bundled into a catalog price-format change, rolled back together, and then re-released three days later. With schema-per-module and separate Flyway locations (db/migration/catalog, db/migration/orders, …), each squad’s migration is a separate versioned history. They still ship in one artifact, so a bad catalog migration can still roll back the binary that contains a good orders fix. The team accepts that, and they split the migration job so a catalog migration failure does not run the orders scripts. Independent schema history inside one deployable is the operational halfway step. Independent binaries come later, for payment first, because that is the boundary with a real driver.
The Friday review checklist is short: verify() green, no new cross-schema SQL, allowedDependencies diff reviewed by the providing squad, and any new synchronous call on the place path has a timeout. In-process calls cannot hang on a network, and they can still hang on a lock in another module’s transaction. Timeouts on in-process calls mean a deadline around reservation and payment, enforced with a java.time budget in the orders use case, not with HTTP client settings.
Scenario 2: PCI scope forces payment out
The security team will only certify a payment deployable that does not contain the catalog admin UI. The commerce codebase is one jar, so the entire monolith is in PCI scope. The extraction plan does not start by rewriting checkout. It starts from seams that already exist: payment’s API is PaymentGateway, its tables are the only ones under payment, orders never joins them, and payment does not call orders. The new payment service implements the same interface over HTTPS. Orders gains a driven adapter, HttpPaymentGateway, selected by configuration. The in-process implementation remains for the test suite. Timeouts, idempotency keys, and UNKNOWN move from “method call that shares a heap” to “network call,” which lesson 6 already modeled. What changes operationally is a second pipeline and a network failure mode. What must not change in the same quarter is fulfillment’s listener protocol. Extracting payment and extracting fulfillment at once is two migrations. The ADR in lesson 10 records payment first, fulfillment stays in-process, and the revisit trigger is a second PCI finding or a sustained need for fulfillment to scale on a different profile than checkout.
Scenario 3: A reporting join that tried to become the write path
Finance built a nightly report as SQL across all four schemas, under a read-only role, on a replica. An engineer then copied that join into PlaceOrderService to “show the live warehouse name” on the confirmation page. The confirmation page became a hidden dependency on fulfillment’s internal columns. The fix was a field on OrderAuthorized’s read model inside orders: fulfillment’s listener does not serve the page; orders stores warehouseCode only if product still wants it, supplied by ReservationApi’s result at confirm time. The report SQL stayed on the replica. Write-path code under orders returned to addressing other capabilities only through Java APIs. The incident was small. The rule it restored is the modular monolith’s entire point.
Best Practices
- Top-level packages are business modules. Technical layers live inside
internal. - One public API per module: a few interfaces and immutable types in the base package. Implementation classes stay package-visible in
internal. - Declare
allowedDependencieson every module, including empty arrays for modules that must stay sinks. - Run
ApplicationModules.verify()in CI on every pull request. - One PostgreSQL schema per module. No foreign keys between schemas. Snapshot values that the calling module must keep if the provider changes later.
- Put synchronous calls on the shopper path only when the shopper must see the outcome before the response. Everything else is an event listener with an idempotent handler.
- Give cross-module application events a unique natural key on the consumer’s table.
- Keep
sharedfree of I/O and free of use-case logic. If a class insharedgrows aJdbcClient, move it. - Map CODEOWNERS to module roots and require the providing squad on
allowedDependencieschanges. - Version database migrations per schema so one squad’s script is reviewable without reading the others.
- When you add a module, write the one-paragraph extraction note: what would become HTTP, what transaction would break, who would be on call. If you cannot write it, the boundary is still fuzzy.
- Prefer Spring Modulith’s in-process events over a broker until a second process needs the message.
Common Mistakes and Anti-Patterns
Layers as the only modularity. Packages named service and repository feel organized. Any service can inject any repository. The result is a distributed domain implemented as one compilation unit with no seams. Move to package-by-module and make the illegal dependency fail the build.
A common module that becomes the system. common starts with Money and ends with OrderFacade, EmailUtil, and base entities for every table. Every module depends on it, so every change risks every squad. Keep a shared kernel of values, and let each capability own its behavior.
Public internal classes “for a test.” Widening CatalogPricesImpl to public so an orders test can mock it bypasses the API. The orders test should use a fake CatalogPrices. If the test needs SQL, it belongs in catalog’s test suite.
Cross-schema foreign keys. They make deletes and migrations a multi-squad lock, and they document a join the write path will eventually use. Use identifiers and snapshots. Accept that the database will not cascade for you; the API method release is the cascade.
Shared @Transactional service that updates every schema. One application service in a checkout package above the modules re-creates the god service and usually sits outside verify() if you are careless about where it lives. Place-order orchestration belongs in the orders module and calls APIs. It does not import other modules’ repositories.
Kafka as a folder substitute. Publishing OrderAuthorized to a broker both modules run in the same process to consume adds operational failure modes (broker down, consumer lag) without a second deployable. Use @ApplicationModuleListener until extraction. The event type can stay the same when you later publish it through an outbox.
Empty allowedDependencies misunderstood. Copying @ApplicationModule with allowedDependencies = {} onto orders because “the sample had an empty array” isolates orders from catalog and breaks pricing in a way that looks like a mysterious bean failure or a verification error the team then deletes. Read the attribute: empty means no module dependencies. Default means unrestricted API access. Set the list you mean.
Extracting a module that still joins. A new service that opens a second connection to the old database and queries catalog.price has moved the package, not the boundary. You gained a network hop and kept the coupling. Close the grants before you celebrate the deploy.
Troubleshooting and Design Review
| Symptom | Likely cause | What to check | Correction |
|---|---|---|---|
verify() fails on a new DTO | DTO placed in internal and returned from the API, or a sibling imported the impl | Violation message names both types | Move the type to the base package or stop returning it |
| Cycle: orders ↔ payment | Payment loads order lines “to be sure” | allowedDependencies on both | Pass the needed fields on the request; break the back-edge |
| Place path latency tracks catalog admin queries | Shared connection pool and a lock on catalog.price during a migration | Pool metrics, lock view in PostgreSQL | Short transactions; do not run migrations in the request pool; cache prices inside catalog |
| Duplicate shipments | Listener retried without a unique order_id | Shipment row count versus registry republish logs | Primary key on order_id; treat conflict as success |
| Shopper charged, no hold | confirm failed and payment was not a separate, recorded fact | Order status versus fulfillment.reservation | Reconciler; do not wrap payment and reserve in one cross-module transaction as the “fix” unless you accept the extraction cost in writing |
| Module test starts the entire Boot app | Test depends on concrete beans in other internals | Test classpath and constructor | Fake the three APIs |
| Report SQL copied into checkout | Convenience | rg "join catalog" orders | Snapshot or API method |
| PCI scope still covers catalog | Payment “service” shares the jar and the admin routes | Deploy artifact contents | Extract only after the API and schema seams exist; a second package in the same jar does not shrink scope |
Design review for a change that adds a dependency:
- Which module owns the rule the new call implements?
- Is the new edge synchronous, and does the shopper need it to be?
- What is persisted on the caller so the caller can survive a later change to the callee’s internals?
- Does
allowedDependenciesgrow by one name, and does the other module avoid a back-edge? - Which unique key makes a replay safe?
- If this module were extracted next quarter, which method would become HTTP, and which transaction would stop being atomic?
Interview Questions and Answers
Q1. What is the difference between a layered architecture and a modular monolith?
A layered architecture groups code by technical role: how a request is presented, how a use case is sequenced, how domain rules run, how rows are stored. A modular monolith groups code by business capability inside a single deployable. TechLearningPro can use layers inside the orders module and still be one modular monolith with catalog, orders, payment, and fulfillment. The layered top-level layout (controller, service, repository) does not create module boundaries, because every squad shares those packages and Java will compile a dependency from any service to any repository. The modular layout makes each capability a package tree with a public API, and verification fails when one capability reaches into another’s internals. One deployable is common to both; the grouping principle is not.
Q2. Why doesn’t package-by-layer enforce the boundaries teams write in a wiki?
Java access control works on packages and modules of the JPMS kind, not on naming conventions. If OrderService and CatalogRepository are both public, the dependency compiles. Code review can catch it until a deadline. A package-by-module layout plus ApplicationModules.verify() turns “orders must not touch catalog internals” into a test. PostgreSQL grants turn “orders must not write catalog tables” into a database error if you take that step. The wiki can explain why. It cannot be the only control.
Q3. What does Spring Modulith’s verify() check?
It checks the application module structure derived from packages under the Spring Boot application class. The base package of each module is the API other modules may use. Subpackages are internal unless annotated as a named interface with @NamedInterface. Dependencies on internal types fail. If allowedDependencies is set, edges to modules outside that list fail. An empty array allows no dependencies on other modules; the default attribute value does not apply that restriction. Cycles fail. verify() does not read SQL, so a JDBC string that joins another schema can still pass. Pair it with a schema convention and a review or a lint on SQL under each module path.
Q4. How should orders get a product price?
Through catalog’s API, CatalogPrices.priceOf, implemented inside catalog.internal with SQL against catalog.price. Orders persists the returned amount, currency, and title on the order line. It does not keep a foreign key to catalog.price and it does not join that table while placing the order. The snapshot is what the shopper was charged. A later price change applies to later placements. The same interface is what an HTTP adapter will implement if catalog is extracted. A shared repository bean injected into both modules removes that option without looking like a distributed-systems problem, which is why it is tempting and why verify() should make the internal repository unreferenceable.
Q5. When is a synchronous call between modules the right choice?
When the caller cannot answer the shopper, or complete its own invariant, without the result. Stock reservation is that case if the promise is that an authorized order has a hold. Price lookup is that case if the order must store a server-side price before payment. Shipment creation is not that case: the shopper can receive “authorized” before a warehouse task exists, and the listener can retry. Synchronous in-process calls are still coupling: they share a thread pool and they can share a lock. Put a deadline on them. Do not make every interaction synchronous to avoid learning the listener model, and do not make every interaction an event to look event-driven. The shopper’s need decides.
Q6. What is wrong with one transaction that updates orders and payment together?
PostgreSQL can commit both schemas atomically, and the happy path is easy to explain. The cost appears when payment must move to another service or another database for PCI. That atomic update becomes a distributed transaction or, more realistically, a saga with UNKNOWN and reconciliation. If the business already accepts lesson 6’s split — pending order, authorize, then mark authorized — keep that split at the module boundary now. If you truly need atomicity, write it down as an extraction blocker in the ADR. The mistake is getting atomicity by accident because both adapters share a TransactionTemplate, then discovering the blocker during the PCI project.
Q7. What has to be true before you extract a module into a microservice?
The API is the only dependency and CI enforces it. The schema has no foreign keys from the rest of the system and the write path does not join outward. Cross-module transactions are already replaced with explicit calls, events, and recovery. A real driver exists: compliance boundary, release cadence, or scale profile that hurts the other modules. The team accepts a second operational surface. Extracting because the repository feels large, while OrderService still injects three other repositories, moves the tangle into HTTP. Microservices Architecture Principles starts from that point. This lesson’s modular monolith is a valid end state if the driver never arrives.
Q8. How do you keep a shared kernel from eating the system?
Allow values with no I/O and no single-squad policy: money, SKU, shopper id. Require two consumers before a type is eligible. Forbid Spring stereotypes and JDBC in that package with ArchUnit. If catalog and orders disagree about a rule, the rule is not shared; one module owns it and the other calls the API. Review the kernel’s file count in the same staff review that looks at allowedDependencies. A growing shared package is a modularity bug even when verify() is green, because everything may depend on code that is “not a module.”
Q9. How does fulfillment react to an authorized order inside the monolith?
Orders publishes OrderAuthorized after its transaction commits. The type lives in the orders API and carries ids and line snapshots, not entities and not payment tokens. Fulfillment exposes a listener annotated with @ApplicationModuleListener, which runs in its own transaction and is backed by Modulith’s publication registry for retry. fulfillment.shipment uses the order id as a unique key so a retry does not create a second shipment. Fulfillment’s allowedDependencies includes orders so the listener may reference the event type. Fulfillment does not call back into PlaceOrder. Payment is not on fulfillment’s dependency list for this flow; the event means authorization already succeeded.
Q10. What does a staff engineer flag in a “modular” pull request?
A new dependency on another module’s internal package, or a widened public modifier on an implementation class. SQL under orders that names catalog. or payment.. A new synchronous call on the place path with no deadline and no statement of why the shopper waits. An event payload that includes a persistent entity or a secret. A @Transactional method that writes two schemas without an ADR sentence. A common helper that opens a repository. They also flag the absence of an idempotency key on any new mutating API. They ask who owns the module in CODEOWNERS. A clean diagram in a slide with none of these checks is not a modular monolith.
Q11. How do layers and ports fit together once modules exist?
Each module is hexagonal inside. Driving adapters and driven adapters stay in internal. The module’s base-package interface is the inbound port other modules may use; it is coarser than the HTTP controller, which remains internal. Outbound ports that talk to other modules are the other modules’ APIs, injected by Spring. Outbound ports that talk to PostgreSQL stay inside the module and use that module’s schema. You do not create a fifth, technical module called infrastructure for all JDBC. Infrastructure that serves catalog lives in catalog.internal. A shared connection factory bean is acceptable at the Boot application level; shared SQL is not.
Hands-On Exercises
Exercise 1: Boundaries for catalog, orders, payment, and fulfillment
Problem. TechLearningPro commerce is one Spring Boot application. Define module boundaries for catalog, orders, payment, and fulfillment.
Tasks.
- State the shopper promises: priced from the server, no double sell of a limited seat, charge once, ship after authorization without blocking the HTTP response on the warehouse.
- Draw the module diagram and the allowed dependency arrows, including one event.
- List the public types of each module (interfaces and event payload) and three types that must stay internal.
- Assign tables to schemas. Mark which columns are snapshots.
- Specify transaction boundaries for reserve, authorize, and create-shipment.
- Write
package-info.javaallowedDependenciesfor all four modules, including any empty list. - Describe one failure (payment unknown, listener crash) and the idempotency key involved.
- State what would have to change to extract payment only, and what you refuse to extract in the same step.
Expected deliverables. Dependency sketch, API list, schema list, transaction notes, and a short extraction paragraph suitable for an ADR context section.
Acceptance. Another engineer can implement verify()-friendly packages without choosing a broker. No write-path join remains. Payment does not depend on orders.
Exercise 2: Repair a layered checkout
You inherit com.techlearningpro.commerce.{controller,service,repository,domain} where OrderService injects CatalogRepository and PaymentRepository and is annotated @Transactional across a RestClient call. Propose the package moves for one quarter without a microservice split: what moves first, which temporary adapter you leave, and which CI check you add in week one.
Rubric.
| Area | Weight |
|---|---|
| Requirements and shopper-visible consistency | 15% |
| Module APIs and dependency direction | 25% |
| Schema ownership and snapshots | 20% |
| Failure handling and idempotency | 20% |
| Extraction trade-off, including what stays one deployable | 10% |
| Clarity of the package and CI plan | 10% |
Lesson Summary
A layered architecture organizes technology inside a capability. A modular monolith organizes the capabilities themselves — catalog, orders, payment, fulfillment — inside one Spring Boot deployable. Package-by-layer cannot enforce those capabilities in Java; package-by-module plus ApplicationModules.verify() and allowedDependencies can. Each module owns a schema, exposes a small API, and snapshots data it must remember. The shopper waits for price, stock hold, and payment. Shipment runs after commit on an application-module listener with an idempotent write. Cross-module database transactions are a conscious extraction cost, not a convenience. The result is a system four squads can change and a payment boundary you can later move behind PCI scope by replacing an in-process port with an HTTP adapter. Until a driver requires that move, one well-bounded deployable is the architecture, not a compromise you apologize for.
Further Reading
- Spring Modulith, Fundamentals — application modules,
allowedDependencies,@NamedInterface, and the empty-array rule. - Spring Modulith, Application Module Listener and event publication —
@ApplicationModuleListenerand the publication registry. - Martin Fowler, MonolithFirst — why a modular deployable often precedes a service split.
- Microsoft, Design a microservices architecture — the operational costs you defer by staying in one process, and the boundaries you still want.
- PostgreSQL, Schemas — the database object this lesson uses as a module seam.
- ArchUnit, Slices — additional cycle checks if you need a rule Modulith does not express.
- Sibling lessons: Hexagonal Architecture and Ports & Adapters, Domain-Driven Design Fundamentals, Event-Driven Architecture Fundamentals, Architecture Documentation and ADRs, Microservices Architecture Principles.
- Enterprise track: Modular Monolith, Layered Architecture, Service Decomposition.