Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 7

    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

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    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:

    1. Separate a technical layer (presentation, application, domain, infrastructure) from a business module (catalog, orders, payment, fulfillment).
    2. Explain why a package-by-layer layout lets OrderService depend on CatalogRepository even when the team’s wiki says it must not.
    3. Structure a Spring Boot application so each capability is a Spring Modulith application module with a public API and internal packages.
    4. Restrict module dependencies with @ApplicationModule(allowedDependencies = …) and fail CI when ApplicationModules.verify() reports a violation.
    5. 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.
    6. Choose a synchronous in-process call for a shopper-facing reservation and an application event for fulfillment after authorization.
    7. List the extraction seams — API, schema, transaction, team — that must already hold before a module becomes its own deployable.
    8. 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 GRANT at 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?”

    LayerResponsibility in commerceTypical Spring types
    Presentation / driving adaptersHTTP and message entry@RestController, @KafkaListener inside the module
    ApplicationUse-case sequencing, transactions at the module edge@Service implementing the module API
    DomainInvariants for that capabilityOrder, Money, catalog Product rules
    Infrastructure / driven adaptersSQL, HTTP clients, mailJdbcClient, 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:

    text
    com.techlearningpro.commerce
    controller OrderController, CatalogController, PaymentController
    service OrderService, CatalogService, PaymentService
    repository OrderRepository, CatalogRepository
    domain 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:

    text
    com.techlearningpro.commerce
    CommerceApplication
    shared Money, ShopperId, Sku (tiny shared kernel)
    catalog public API in this package
    internal JDBC, pricing rules
    orders
    internal
    payment
    internal
    fulfillment
    internal

    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
    @Test
    void 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:

    InteractionMechanismTransaction
    Orders needs a priceSynchronous call to CatalogPrices in-processCatalog read is its own short read. Orders stores the returned Money on the line.
    Orders needs an authorizationSynchronous call to payment’s PaymentGateway APISeparate transactions, as in lesson 6. Payment’s schema commits the authorization; orders’ schema commits AUTHORIZED.
    Fulfillment should ship after authorization@ApplicationModuleListener on an OrderAuthorized eventListener runs after commit, in a new transaction (REQUIRES_NEW is part of that stereotype).
    Shopper must know stock is heldSynchronous call to fulfillment’s ReservationApiFulfillment’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

    text
    catalog.product
    catalog.price
    orders.checkout_order
    orders.checkout_line
    orders.checkout_idempotency
    payment.authorization
    fulfillment.reservation
    fulfillment.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:

    1. Another squad needs to release it on a different cadence, or a security boundary (PCI for payment) requires a smaller blast radius.
    2. The public API is already the only dependency, and verify() is green.
    3. No foreign keys and no write-path joins cross the schema boundary.
    4. The cross-module transaction list is empty, or each former transaction has a documented saga or reconciliation.
    5. 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

    mermaid
    flowchart TB
    subgraph boot [One Spring Boot process]
    subgraph catalog [catalog module]
    CAPI[CatalogPrices API]
    CINT[catalog.internal JDBC]
    CAPI --> CINT
    end
    subgraph orders [orders module]
    OAPI[PlaceOrder API]
    OINT[orders.internal]
    OAPI --> OINT
    end
    subgraph payment [payment module]
    PAPI[PaymentGateway API]
    PINT[payment.internal]
    PAPI --> PINT
    end
    subgraph fulfillment [fulfillment module]
    FAPI[ReservationApi]
    FL[OrderAuthorized listener]
    end
    end
    OINT --> CAPI
    OINT --> PAPI
    OINT --> FAPI
    OINT -. event after commit .-> FL
    CINT --> CS[(schema catalog)]
    OINT --> OS[(schema orders)]
    PINT --> PS[(schema payment)]
    FL --> FS[(schema fulfillment)]
    FAPI --> FS
    mermaid
    flowchart LR
    subgraph forbidden [Package-by-layer leak]
    OS2[OrderService] --> CR[CatalogRepository]
    end
    subgraph allowed [Modular dependency]
    OI[orders.internal] --> API[catalog.CatalogPrices]
    API --> CI[catalog.internal]
    end
    mermaid
    sequenceDiagram
    participant UI as Shopper
    participant O as orders.PlaceOrder
    participant C as catalog.CatalogPrices
    participant F as fulfillment.ReservationApi
    participant P as payment.PaymentGateway
    participant L as fulfillment listener
    UI->>O: place order
    O->>C: priceOf(sku)
    C-->>O: Money snapshot
    O->>F: reserve(sku, qty)
    F-->>O: reservationId or rejected
    O->>P: authorize
    P-->>O: APPROVED
    Note over O: commit orders row AUTHORIZED
    O--)L: OrderAuthorized
    L->>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
    @ApplicationModuleListener
    void 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

    FailureShopper-visible resultModule state
    Catalog price missing404-style problem from orders, nothing reservedNo rows
    Reservation rejectedItem unavailableNo payment call, no order in AUTHORIZED
    Payment declinedDeclined, reservation released by ReservationApi.releasePayment stores decline; fulfillment deletes or expires the hold
    Payment UNKNOWNRetry with the same keyHold remains until expiry or resolution
    Process dies after payment approval, before confirmReconciler in orders confirms holds for AUTHORIZED ordersPayment has the authorization
    Shipment listener failsShopper still sees an authorized orderRegistry 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

    java
    package com.techlearningpro.commerce.catalog;
    public interface CatalogPrices {
    Money priceOf(Sku sku);
    }
    java
    package com.techlearningpro.commerce.catalog.internal;
    @Service
    class CatalogPricesImpl implements CatalogPrices {
    private final JdbcClient jdbc;
    CatalogPricesImpl(JdbcClient jdbc) { this.jdbc = jdbc; }
    @Override
    public Money priceOf(Sku sku) {
    return jdbc.sql("""
    select amount, currency from catalog.price
    where sku = :sku and effective_from <= now()
    order by effective_from desc
    limit 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

    java
    Money 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

    java
    package 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

    java
    package com.techlearningpro.commerce.fulfillment.internal;
    @Component
    class ShipmentOnAuthorized {
    private final ShipmentCreator shipments;
    ShipmentOnAuthorized(ShipmentCreator shipments) { this.shipments = shipments; }
    @ApplicationModuleListener
    void 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

    java
    class ModularityTest {
    @Test
    void 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 @ManyToOne across modules. They are foreign keys in disguise.
    • No shared EntityManager scanning all packages and lazy-loading a Product from an Order.
    • No Kafka topic for OrderAuthorized while 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 allowedDependencies on 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 shared free of I/O and free of use-case logic. If a class in shared grows a JdbcClient, move it.
    • Map CODEOWNERS to module roots and require the providing squad on allowedDependencies changes.
    • 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

    SymptomLikely causeWhat to checkCorrection
    verify() fails on a new DTODTO placed in internal and returned from the API, or a sibling imported the implViolation message names both typesMove the type to the base package or stop returning it
    Cycle: orders ↔ paymentPayment loads order lines “to be sure”allowedDependencies on bothPass the needed fields on the request; break the back-edge
    Place path latency tracks catalog admin queriesShared connection pool and a lock on catalog.price during a migrationPool metrics, lock view in PostgreSQLShort transactions; do not run migrations in the request pool; cache prices inside catalog
    Duplicate shipmentsListener retried without a unique order_idShipment row count versus registry republish logsPrimary key on order_id; treat conflict as success
    Shopper charged, no holdconfirm failed and payment was not a separate, recorded factOrder status versus fulfillment.reservationReconciler; 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 appTest depends on concrete beans in other internalsTest classpath and constructorFake the three APIs
    Report SQL copied into checkoutConveniencerg "join catalog" ordersSnapshot or API method
    PCI scope still covers catalogPayment “service” shares the jar and the admin routesDeploy artifact contentsExtract 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 allowedDependencies grow 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.

    1. 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.
    2. Draw the module diagram and the allowed dependency arrows, including one event.
    3. List the public types of each module (interfaces and event payload) and three types that must stay internal.
    4. Assign tables to schemas. Mark which columns are snapshots.
    5. Specify transaction boundaries for reserve, authorize, and create-shipment.
    6. Write package-info.java allowedDependencies for all four modules, including any empty list.
    7. Describe one failure (payment unknown, listener crash) and the idempotency key involved.
    8. 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.

    AreaWeight
    Requirements and shopper-visible consistency15%
    Module APIs and dependency direction25%
    Schema ownership and snapshots20%
    Failure handling and idempotency20%
    Extraction trade-off, including what stays one deployable10%
    Clarity of the package and CI plan10%

    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

    Ready to mark this lesson complete?Track your journey across the entire course.