Modular Monolith
Modular monolith keeps a single deployable artifact while enforcing hard module boundaries inside the codebase — each module owns its schema slice, exposes a narrow public API,…
Introduction
Modular monolith keeps a single deployable artifact while enforcing hard module boundaries inside the codebase — each module owns its schema slice, exposes a narrow public API, and cannot import sibling internals. At Amazon scale, teams use it to defer microservice operational cost until domain boundaries and traffic patterns are proven.
Real production story
During Prime Day 2022, an internal ordering team at Amazon ran a single Spring Boot monolith where checkout, inventory, and pricing lived in packages with "soft" boundaries — any class could import any other. A pricing refactor renamed a DTO used by checkout; the deploy passed CI but broke tax calculation for 47 minutes because integration tests mocked cross-module calls.
The post-incident ADR introduced a modular monolith: Gradle modules with enforced dependency rules (ArchUnit), each module owning its tables via schema-per-module inside one PostgreSQL instance, and inter-module communication only through published interfaces or domain events on an in-process event bus. Six months later the same team extracted inventory to a service in two weeks because the boundary already matched production ownership.
Business problem
Business pressure: Amazon's retail platform must ship weekly while sustaining 99.99% checkout availability. Microservices too early fragment ownership and inflate incident surface; a ball-of-mud monolith blocks parallel team velocity and makes every deploy a company-wide risk.
- Revenue at risk: Undetected cross-module coupling caused a $12M estimated GMV impact during a single Prime Day deploy window.
- Engineering velocity: 14 teams touching one repo without module fences averaged 3-day merge queues and correlated rollbacks.
- Compliance / trust: PCI scope could not shrink until payment code was physically isolated in a module with separate audit trail.
Architecture overview
A modular monolith is one deployable unit composed of replaceable modules. Each module has: (1) a public API package, (2) private implementation, (3) owned persistence, (4) forbidden inward dependencies from siblings.
- Definition: Single process, multiple bounded modules — not "folders by layer" but folders by subdomain.
- When to adopt: 3+ teams, clear subdomain seams, need independent evolution without Kubernetes tax.
- When to defer: Solo founder MVP — a plain monolith is faster; add modules when the second team arrives.
- Operability: One dashboard, but per-module metrics (latency, error rate) tagged by module name in logs and traces.
Architecture motivation
Why architects care: Modular monolith delivers logical service boundaries with physical operational simplicity — one artifact, one pipeline, one runtime — while preserving the option to extract hot modules to services later.
- Force: Domain boundaries are fuzzy; premature network splits create distributed monoliths worse than a well-fenced monolith.
- Constraint: Cannot afford 40 microservice on-call rotations for a product still finding market fit.
- Outcome: Enforced module APIs, schema ownership, and event seams that survive extraction without rewrite.
Internal architecture
Amazon retail modular monolith — modules as deployment-ready slices:
- ArchUnit or Gradle dependency rules fail CI on illegal cross-module imports.
- Each module publishes only
com.amazon.order.checkout.api— never.internal.
┌─────────────────────────────────┐│ Single deployable JAR/WAR │┌──────────┐ │ ┌─────────┐ ┌─────────┐ ┌──────┐ ││ ALB │───────▶│ │Checkout │ │Inventory│ │Pricing│ │└──────────┘ │ │ module │ │ module │ │module │ ││ │ API ▲ │ │ API ▲ │ │ API ▲ │ ││ └───┬─────┘ └───┬─────┘ └──┬───┘ ││ │ events │ events │ ││ └────────────┴───────────┘ ││ In-process bus │└──────────────────┬──────────────────┘▼PostgreSQL (schema per module)checkout.* | inventory.* | pricing.*
Data flow
Primary path: HTTP hits checkout module facade; checkout validates cart via inventory module's public API (sync call or cached read model); pricing module computes totals; checkout persists order in checkout.orders and emits OrderPlaced via transactional outbox.
- Write path: Single DB transaction within module; cross-module effects via outbox → message relay → in-process or Kafka handler.
- Read path: Module serves its own read models; cross-module reads go through published query APIs, never direct SQL.
- Async path: Domain events carry idempotency keys; inventory module decrements stock idempotently on
OrderPlaced.
System design diagram
Two diagrams show the Modular Monolith topology and the primary request/event path used in production at scale.
Production code example
Gradle module structure with ArchUnit enforcement — pattern Amazon platform teams wire in CI:
- Run ArchUnit on every PR — boundary violations are merge blockers, not suggestions.
- Publish
:module-apiartifacts separately so extraction is a dependency change, not a rewrite.
// build.gradle.kts — module graphdependencies {constraints {api(project(":checkout-api"))implementation(project(":checkout-internal"))// inventory module visible ONLY via checkout-api}}// ArchUnit test — fails CI on boundary violation@AnalyzeClasses(packages = "com.amazon.order")class ModuleBoundaryTest {@ArchTeststatic final ArchRule checkoutMustNotImportInventoryInternal =noClasses().that().resideInAPackage("..checkout..").should().dependOnClassesThat().resideInAPackage("..inventory.internal..");@ArchTeststatic final ArchRule modulesTalkViaApiOnly =classes().that().resideInAPackage("..checkout..").should().onlyDependOnClassesThat().resideInAnyPackage("..checkout..", "..inventory.api..", "java..");}// Transactional outbox per module@Entity @Table(schema = "checkout", name = "outbox")class OutboxEvent {@Id UUID id;String aggregateType;String eventType;String payload;Instant createdAt;}
Enterprise case study
Amazon internal ordering platform (2022–2024): Migrated from layered monolith to modular monolith, then extracted inventory service when QPS exceeded 80k/min on stock checks.
- Before: 23-minute mean rollback time; any team's bug could break checkout.
- Decision: Gradle multi-module + ArchUnit + schema-per-module + outbox events.
- After: Deploy frequency 3×; inventory extraction completed in 11 days with zero schema rewrite.
Trade-offs
- Simplicity vs isolation: One deploy means correlated failure — module bulkheads (thread pools, circuit breakers) mitigate but do not eliminate.
- Schema-per-module vs one schema: Separate schemas enforce ownership; shared DB instance keeps ops simple and enables cross-module transactions when truly needed.
- Extraction readiness vs YAGNI: Module seams cost upfront discipline; payback when traffic or team count forces service split.
Security considerations
Security is architectural: PCI module isolation reduces audit scope; payment card data never crosses into catalog module memory.
- Identity: Internal module calls still authenticate via service identity tokens even in-process — prepares extraction.
- Data: Row-level security per module schema; encryption keys scoped per module for at-rest data.
- Supply chain: Single artifact means one SBOM scan path — simpler than 20 service images but blast radius is wider.
Scalability analysis
Scale dimensions: Amazon engineers scale the modular monolith vertically first, then extract the hottest module (usually checkout or inventory) horizontally as an independent service.
- Horizontal scale: Stateless replicas behind ALB; session stickiness avoided via externalized cart state in ElastiCache.
- Hot spots: Pricing module CPU spikes during promotions — isolate thread pool and consider async repricing pipeline.
- Cost: One cluster vs 12 microservice clusters — modular monolith saves 60–70% infra until QPS per module exceeds single-fleet capacity.
Failure scenarios
What breaks: A memory leak in pricing module takes down the entire JVM — module boundaries are logical, not physical.
- Runaway thread pool: Checkout calls inventory synchronously; inventory slowdown blocks all checkout threads — enforce timeouts and bulkheads per module.
- Schema migration collision: Two modules migrate same DB instance concurrently — use module-scoped migration tools (Flyway schemas).
- False extraction: Team extracts module but keeps synchronous HTTP between services — recreates distributed monolith with network latency.
Staff engineer insights
- If you cannot draw module boundaries on a whiteboard without mentioning HTTP or Kafka, they are not real boundaries yet.
- Modular monolith is a stepping stone, not a destination — invest in extraction triggers (QPS, team count, deploy conflict rate) upfront.
- Amazon's lesson: enforce boundaries in CI, not in code review comments — humans forget, ArchUnit does not.
Interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1AdvancedQuestionHow is a modular monolith different from a well-structured monolith?+
Answer
Follow-up
2AdvancedQuestionWhen would you extract a module to a microservice from a modular monolith?+
Answer
Follow-up
3AdvancedQuestionCan modules share a database transaction in a modular monolith?+
Answer
Follow-up
Architecture review questions
- Does each module have a published API package and private implementation hidden from siblings?
- Are cross-module dependencies enforced in CI (ArchUnit, dependency-cruiser, Gradle constraints)?
- Does each module own its schema or table prefix with independent migrations?
- Are cross-module side effects async via outbox/events, not direct table writes?
- Are per-module metrics (latency, errors, saturation) tagged in logs and dashboards?
- Is there a documented extraction trigger (QPS, team count, failure correlation) per module?
Summary
Modular monolith at Amazon scale means treating internal modules like microservices that happen to share a process — owned schemas, public APIs, event seams, and CI-enforced dependency rules. Master the pattern when you need team parallelism without Kubernetes operational tax, and know when extraction metrics say the shared JVM is exhausted.