One Jar, Twelve Modules: Making a Monolith Behave Like a Federation
A logistics platform runs as a single Spring Boot jar. It deploys fine, but OrderService imports FleetRepository directly, the pricing package reaches into billing tables, and every release notes read "deployed all services." Twelve engineers coordinate through one CI pipeline, and a change to driver shifts touches four packages owned by three teams. Nobody wants microservices — the operational cost is real — but the monolith has become a distributed system's coupling without any of its deployment independence.
A modular monolith keeps one deployment unit while enforcing module-level boundaries. The federation is logical, not physical: one process, one build, one database if you like — but code arranged so that each module is a small application with a public face and a private body.
The mechanics rest on language features you already have. In Java or Kotlin, a module is a package tree where only a handful of types are public:
com.acme.orders/
api/ public: OrderModule, PlaceOrderCmd, OrderView
internal/ package-private: OrderEntity, PricingRules, ShiftPlanner
spi/ public interfaces this module needs FROM others
The rule is blunt: nothing outside com.acme.orders.internal may name a type from that package. Callers get OrderModule.placeOrder(cmd) and a DTO back. They never see the entity, never construct it, and — critically — never call orderRepository.save() because the repository class itself is package-private. That last point is what separates a real modular monolith from a naming convention: if you cannot reach the persistence layer of another module without going through its API, the module owns its data in practice, not just on a diagram.
Cross-module needs are declared, not scavenged. When orders needs to know whether a driver is on shift, it declares a dependency on fleet.spi.DriverAvailability, an interface owned by the consumer, and the fleet module provides an implementation at the composition root — the hexagonal port pattern applied between modules. The direction matters: consumer-owned interfaces mean fleet can be swapped or faked in tests, and orders never imports a fleet entity.
// owned by orders (consumer), implemented by fleet
public interface DriverAvailability {
boolean isOnShift(DriverId driver, Instant at);
}
public final class PlaceOrderHandler {
private final DriverAvailability availability; // no FleetRepository in sight
}
Wiring happens in exactly one file — the composition root — which is the only place in the application allowed to see every module's public face:
@ModuleConfig
public class Wiring {
@Bean DriverAvailability driverAvailability(FleetModule fleet) {
return fleet::isOnShift; // in-process today, proxy tomorrow
}
}
Because every cross-module collaboration is routed through that single file, you can see the entire system's coupling on one screen, and tests replace any neighbor with a lambda. A cross-module call remains a method invocation: no serialization, no retry policy, no tail latency — the call graph is the same shape microservices will later have, executed at memory speed.
The payoff is autonomy inside one deployable. Team A merges a pricing change without reading fleet's code; the build fails if someone sneaks a cross-module import; onboarding means learning four module APIs instead of the entire codebase. And because there is still one process, you keep the properties microservices tax heavily — a cross-module call is a method invocation, transactions can span modules when you explicitly allow it, and there is no network in any test. Teams get service-like ownership without paying network and operational overhead yet — that is the pitch, and it is honest as long as the enforcement is real.
Boundaries the Build Enforces: ArchUnit, Module Descriptors, and Schema Ownership
Six months after declaring "every module owns its packages," a developer adds import com.acme.fleet.internal.ShiftPlanner to an order handler because the API was missing a method and the deadline was not. The pattern repeats until internal is imported from everywhere and the boundary exists only in the README. The lesson: a boundary without a failing build is a suggestion. Three enforcement layers close the gap.
First, dependency rules as tests. ArchUnit reads your bytecode and asserts architecture in one file:
@Test void modules_do_not_reach_into_each_other() {
ArchRule rules = classes().that().resideInAPackage("..orders.internal..")
.should().onlyBeAccessed().byAnyPackage("..orders..");
rules.check(producedClasses());
noClasses().that().resideInAPackage("..billing..")
.should().dependOnClassesThat().resideInAPackage("..orders..") // declared cycle check
.andShould().accessClass().where(true); // fail CI on violation
}
The same idea exists in other toolchains: import-linter for Python (contracts: orders.internal -> bb) , dependency-cruiser for TypeScript with noCrossModuleInternal rules, and modular Java's module system (module-info.java with requires and exports) where the compiler itself rejects illegal reads. Whichever you use, the rule must run on every pull request; a nightly report is ignored within a month.
Second, build-level modules. Splitting the monolith into separately compiled units — Gradle subprojects, Maven modules, npm workspaces, .NET assemblies — makes the boundary visible to the compiler instead of a test. orders can only reference fleet if its build file declares the dependency, and it can only see fleet's api source set because internal is not on the export path. This is the step that turns "we behave like services" into "we compile like services."
Third, database ownership inside the one database. The classic erosion is a shared schema where every service can JOIN orders o ON ... and a "quick fix" becomes a permanent coupling. The discipline:
| Practice | What it prevents |
|---|---|
One schema (or table prefix) per module: orders.*, fleet.* |
Ad-hoc joins across module data |
| Only the owning module's datasource role may write its tables | Compensating writes from foreign code |
| Cross-module reads via API or an explicit projection table | Hidden temporal coupling on row shapes |
| Migration files owned with the module's code | Schema changes negotiated by three teams |
Reporting is the honest complement to prevention. A monthly dependency report — module graph, cycles, fan-in/fan-out — gives each team the one metric that predicts trouble: who depends on whom. Rising fan-in on a module means its API is becoming a utility sink; any cycle means two modules now change together and cannot be extracted or tested independently. Architects track coupling metrics over time precisely because boundaries erode under deadlines in ways no single review catches — the erosion is invisible until you plot it.
Analytics deserves an explicit exception because it is the pressure that breaks schemas first. Reporting teams need cross-module reads, and granting them ad-hoc SELECT on every schema reopens the coupling door. The working pattern is a dedicated read-only role that can only read, fed instead by a nightly (or near-real-time) projection into a reporting.* schema — a copy, so the analytics joins never constrain how orders and fleet evolve their tables. The trade-off is staleness, and it is usually the right one: reports tolerate minutes of lag; production modules should not tolerate foreign rows tied to their schema shape.
In interviews, expect to be asked "how do you enforce module boundaries in a monolith?" Answer with the three layers — bytecode rules, compile-time modules, schema ownership — and add the tell that separates candidates who shipped: enforcement is only as good as the failure mode. A rule that warns passes; a rule that fails the build with the offending import in the message gets fixed in the same PR.
Services in Embryo: What You Gain Now and the One Trigger That Justifies Splitting
A modular monolith is not a consolation prize for teams too timid for microservices. It is a specific bet: pay for boundaries now, pay for distribution later only where evidence demands it. The bet has three legs.
What you gain immediately. Independent team ownership without coordination overhead: the pricing team merges to its module, reviews happen inside the owning team, and the shared pipeline stays green because modules do not import each other's internals. Testing gets cheaper — module tests run against in-process fakes of neighbors' SPI interfaces, so the default suite has no network and no containers. And you keep monolith economics: one deploy artifact, ACID transactions across the two or three modules that genuinely need them (used sparingly, with the participating modules named explicitly), and no on-call burden for message brokers, service meshes, or distributed tracing on day one.
What you deliberately delay. Per-service deploy pipelines, network failure modes, eventual consistency, schema duplication, versioned contracts — all of it. The team that would spend its first quarter building a platform instead ships product, and the boundary debt they accumulate is measured and visible rather than feared.
The extraction trigger. Split a module out only when one of three conditions is proven:
| Trigger | Example | Why the network is now justified |
|---|---|---|
| Independent scaling | The document-generation module needs 8× the CPU of everything else | Vertical scaling or a worker pool inside the jar has been tried and hit its limit |
| Fault isolation | A runaway export must not take down checkout | The module's failure modes have actually caused incidents |
| Team/technology divergence | The ML scoring module needs Python and its own release cadence | One pipeline now actively blocks two teams |
Without one of those, extraction is costume change: you get the cost of distribution and keep the coordination anyway. With one, extraction is mechanical — and this is where the design pays its dividend. The module already has a narrow API (its api package), consumer-owned SPI interfaces, private tables, and no inbound hacks, because CI has been proving it for two years. The move becomes: replace in-process calls to OrderModule with an RPC or event adapter implementing the same interface, move orders.* tables to their own Postgres, and delete nothing. Teams that skipped the modular stage face a rewrite; teams that kept it face a migration with a checklist.
The failure modes are worth naming so you can spot them in a review. Shared-internal drift — modules importing each other's internal via friend-module exemptions granted "temporarily." God module — one module that everyone requires (usually common or customers), where fan-in grows until it is a distributed monolith in embryo; the fix is to shrink its API to stable value types. Anemic cross-module calls — chatty method calls between two modules that mimic a network hop and argue, quietly, for real extraction.
Interview framing: "modular monolith vs microservices" is asked constantly. The strong answer gives the mechanism (enforced APIs, private data, CI rules), states the gain (autonomy without distribution), and names the trigger rather than the fashion — then links the boundary language to Bounded Contexts in Domain-Driven Design, which supplies the map for where the module cuts should go in the first place.
Quiz
1. Which change most reliably turns a package-naming convention into a real module boundary in a Java monolith?
2. The `orders` module needs driver shift data owned by `fleet`. Where should the interface live?
3. What is the strongest argument for one database schema per module inside a shared database?
4. A team wants to extract its scoring module into a separate service today. Which justification is sufficient?
Flashcards
Question
What three elements make up a module in a modular monolith?
Click to reveal answer
Answer
A public api package (the only legal entry point: command/query types and the module interface), a package-private internal package (entities, repositories, rules — unreachable from outside), and an spi set of interfaces the module needs from others, owned by the consumer and implemented by neighbors at the composition root.
Question
Name the three enforcement layers for module boundaries.
Click to reveal answer
Answer
1) Bytecode/dependency tests (ArchUnit, import-linter, dependency-cruiser) failing CI on violations; 2) compile-level modules (Gradle subprojects, module-info.java, npm workspaces) so illegal imports do not compile; 3) database schema ownership — one schema per module, foreign roles cannot write it, migrations owned with the module.
Question
Which coupling metric do architects watch on a modular monolith, and what does each signal mean?
Click to reveal answer
Answer
The module dependency graph: cycles mean two modules now change, test, and deploy together (independent extraction impossible); rising fan-in marks a module becoming a universal dependency (a god/common module heading toward distributed-monolith shape). Track it over time because deadlines erode boundaries invisibly in single reviews.
Question
What are the three legitimate triggers for extracting a module into a service?
Click to reveal answer
Answer
Proven independent scaling (module needs far more resources than the rest and in-process scaling hit its limit), fault isolation (module failures have caused real incidents that must stop taking down everything), and team/technology divergence (different language or release cadence that one pipeline actively blocks). Fashion and résumé are not triggers.
Question
Why does a well-run modular monolith make later extraction mechanical rather than a rewrite?
Click to reveal answer
Answer
The module already has a narrow API, consumer-owned SPI interfaces, private tables, and CI proving no inbound hacks — so extraction is: implement the same interface over RPC/events, move the module's schema to its own database, and delete nothing. Skipping the modular stage means discovering the actual dependency graph during a rewrite.
Revision Notes
Key Takeaways
- 1. A modular monolith is one deployment unit with compile- and CI-enforced module APIs; visibility plus a failing rule is what separates it from a naming convention
- 2. Internals are package-private including repositories — if you can touch another module's tables or entities, the module owns nothing in practice
- 3. Cross-module interfaces are consumer-owned (spi pattern) so dependencies point the right way and neighbors can be faked in tests
- 4. Schema-per-module inside the shared database converts silent JOIN coupling into visible access errors; track the module graph for cycles and fan-in
- 5. Extraction is justified only by proven scaling, fault-isolation, or cadence triggers — and the module's existing API makes the split a migration, not a rewrite
Interview Tips
- • 'Modular monolith vs microservices?' — one process, enforced APIs, private data, CI rules; you gain autonomy without distribution and split only when a trigger (scaling, fault isolation, cadence) is proven
- • 'How do you enforce boundaries?' — three layers: ArchUnit/dependency rules failing CI, compile-level modules, schema ownership — then say warnings are ignored and only red builds hold
- • 'Where does the interface between two modules live?' — with the consumer, in its spi package; the provider implements it and the composition root wires it, so neither side imports the other's entities
- • 'What metrics tell you boundaries are eroding?' — module dependency graph: any cycle means two modules change together; rising fan-in on common/customers predicts a god module; chatty cross-module calls predict extraction
Cheat Sheet
Modular Monolith Cheat Sheet
Shape (one deployable, enforced federation):
com.acme.<module>/
api/ public — module interface + DTOs (only legal entry)
internal/ package-private — entities, repos, rules (CI-blocked from outside)
spi/ interfaces this module needs FROM neighbors (consumer-owned)
Rule: nothing outside the module names an internal type — including its repository.
Enforcement (all three, or it is a convention):
| Layer | Tooling |
|---|---|
| Fails CI | ArchUnit / import-linter / dependency-cruiser |
| Fails compile | Gradle subprojects, module-info.java, npm workspaces |
| Fails DB access | one schema per module; owning role writes; migrations live with module |
Cross-module call pattern: consumer declares the interface, provider implements it, composition root wires it — fakes make neighbor tests in-process and instant.
Metrics: module graph → cycles = change-together (block extraction); fan-in spike = god module; chatty in-process calls = extraction candidate.
Gain now: team autonomy, no network in tests, ACID where explicitly allowed, one artifact. Delay: pipelines, brokers, eventual consistency, tracing.
Extract only on: proven scaling limit · fault-isolation incidents · team/tech cadence divergence.
// extraction = same interface, transport behind it, tables moved — delete nothing