Skip to content

VOL 06 / CH 13 / LESSON 01

13.1 Modular Monoliths: First Establish Boundaries, Then Add Deployment Units ​

Changing a registration field breaks scoring tests because both modules keep adding fields to the same mutable entity. Splitting them into services at this point merely gives that entity a network commute.

Here, a monolith is delivered as one deployment unit. It can run multiple replicas; “monolith” does not mean exactly one running process or internal chaos. A modular monolith divides business capabilities into bounded, constrained modules while preserving the simplicity of in-process calls, single deployment, and local transactions.

If a team cannot maintain module boundaries within a single process, a network typically won’t save them. Interfaces become remotely coupled, shared databases remain, and fault handling becomes significantly more complex.

First, Distinguish Among Three Structural Patterns ​

StructureDeploymentInternal BoundaryPrimary Risks
Big Ball of Mud1Vague, arbitrary callsChanges have unpredictable impact
Modular Monolith1Business modules own APIs and dataRequires ongoing governance of boundaries
MicroservicesMultipleIndependent deployment and data contracts allow separate evolutionDistributed failures and high operational costs

Jumping directly from a big ball of mud to microservices often merely relocates poorly understood coupling into the network. A more reliable path is to first identify, fix, and validate boundaries within the monolith.

Module Around Business Capabilities ​

text
tournament/
├── registration/
│   ├── api/
│   └── internal/
├── scheduling/
│   ├── api/
│   └── internal/
├── scoring/
│   ├── api/
│   └── internal/
└── bootstrap/

A module should have:

  • Publicly exposed commands, queries, and events;
  • The application and domain code that implements these capabilities;
  • Its own rights to write data;
  • Clear ownership and maintenance responsibility.

Modules should not expose internal entities for arbitrary modification by other modules. Shared across modules first are identifiers and contracts, not mutable object graphs.

Synchronous Calls and Module Events ​

These Java 21 fragments assume project-defined IDs and score summaries. Use synchronous public API calls when an immediate result is needed. Sharing a local transaction additionally requires compatible resources, propagation settings, and thread context; synchronous calls alone do not ensure it:

java
public interface ScoringApi {
    ScoreSnapshot scoreFor(PlayerId playerId);
}

After a fact occurs, multiple modules can respond independently, this pattern is well-suited for publishing module events:

java
public record MatchSettled(MatchId matchId, PlayerId winnerId, java.time.Instant occurredAt) {}

In-process events are not inherently reliable queues. You must explicitly define:

  • Whether the handler runs before or after the transaction commit;
  • Whether a handler failure triggers a rollback of the initiating transaction;
  • Whether asynchronous processing is allowed;
  • Whether events need to be persisted and replayed.

Do not use "event-driven" as a veneer to hide commands that should be expressed directly and with strong consistency.

Data Ownership Matters More Than Package Names ​

A database can serve modules through separate schemas or table sets. This design assigns each business table one module responsible for writes. That module’s replicas, migration tools, and operational jobs must follow its contract; it does not mean only one thread may write. Schema names alone do not isolate access, so combine database permissions with code rules. Other modules access data through APIs, read-only projections, or published events.

Banning cross-module JOIN is not an absolute rule. For read-only reports, projections can be established within clearly defined analytical boundaries. For online business writes, directly modifying tables across modules bypasses the invariants of the owner and makes future service extraction difficult.

Constrain Boundaries with Tools ​

Build Modules ​

Gradle/Maven submodules reject references only when implementation dependencies are kept off consumer classpaths. Putting every implementation in shared dependencies defeats that boundary:

text
registration-api
registration-impl  -> registration-api
scoring-api
scoring-impl       -> scoring-api
application        -> all modules impl, responsible for assembly

JPMS ​

Put this Java 21 declaration in a named module’s root module-info.java and build and run on the module path. The module name need not equal a Maven artifactId:

java
module tournament.scoring {
    exports example.tournament.scoring.api;
}

A tournament.registration module with requires tournament.scoring can reference public ScoringApi in the exported package, but cannot import example.tournament.scoring.internal.ScoreCalculator. Exporting a package does not make its package-private types public.

requires declares readability, exports controls ordinary access, and opens enables deep reflection. Do not open an entire module just to enable framework scanning. Ordinary classpath execution does not provide named-module boundaries, and overrides such as --add-exports can relax them. JPMS checks visibility; the team must still identify business boundaries.

Architecture Testing ​

Add this method to the complete ArchitectureTest in 12.2, reusing its JUnit/ArchUnit setup and classes import scope. The bootstrap composition root may assemble internal implementations; other business modules must use the API:

java
@Test
void otherModulesUseOnlyScoringApi() {
    noClasses().that().resideOutsideOfPackages("..scoring..", "..bootstrap..")
            .should().dependOnClassesThat()
            .resideInAPackage("..scoring.internal..")
            .check(classes);
}

This protects only scoring. Apply corresponding rules to other modules and check dependency cycles separately. A registration → scoring.internal reference should fail; the bootstrap exception is not permission to turn every package into an assembly layer.

Advantages of Modular Monoliths in Operation ​

  • Cross-module calls avoid network timeouts and serialization overhead;
  • A single process enables easier local debugging and end-to-end tracing;
  • Single deployment reduces the combinatorial complexity of environment, deployment, and rollback scenarios;
  • Local transactions can still be used for small, genuinely atomic cross-module operations.

These advantages are not "temporary compromises" but real complexity budgets. They should only be sacrificed when the benefits of independent deployment, scaling, or isolation become sufficiently large.

When Boundary Integrity Is Failing ​

  • A feature requires changes to the internal code of multiple modules.
  • Multiple modules simultaneously write to the same core table.
  • A shared module is overloaded with business objects, so any change affects the entire system.
  • Modules form call cycles or dependencies that loop back on themselves.
  • Published events lack ownership, clear semantics, or version compatibility rules.
  • What is called an API merely exposes internal entities without abstraction or encapsulation.

Fix these issues before considering service decomposition. The next lesson provides a complete step-by-step process from identifying problems to executing migration.

References ​

Built with VitePress | Software Systems Atlas