Skip to content

    System Design

    System Design: Monolith, Modular Monolith and Microservices

    Compare monoliths, modular monoliths and microservices honestly, avoid the distributed-monolith trap, and learn when and how to split a system using the strangler pattern.

    By Published 14 min read

    Share

    This lesson is part of System Design Building Blocks: Data, Messaging and Scale. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to describe monoliths, modular monoliths and microservices and what each costs, recognise a distributed monolith, decide whether and when to split a system based on team and deployment needs rather than fashion, and plan a gradual extraction with the strangler pattern while respecting data ownership.

    Think of three ways to run a food business. A single big restaurant has one kitchen, one building and one team. Everything is close, orders flow quickly, and a menu change is one conversation. A food court has separate stalls with their own cooks, menus and suppliers, but they share a building, so they can be coordinated. A chain of independent food trucks is fully autonomous, each truck can change its menu daily and park anywhere, but now you must keep them supplied, find them on a map and work out what happens when two of them need to share a customer's order.

    None of these is "the best". They are different trade-offs between simplicity, autonomy and operating cost. The mistake teams make most often is choosing the food-truck model for a business that is still a single restaurant. This lesson gives you the tools to choose deliberately.

    The core idea

    An architecture style is a decision about where to draw boundaries and how strongly to enforce them. Boundaries have a logical part (who owns what code and data, what each module may depend on) and a physical part (what runs as a separate deployable process and communicates over a network). A monolith has weak logical boundaries and no physical ones. A modular monolith has strong logical boundaries and no physical ones. Microservices have both.

    Physical boundaries buy independence (deploy, scale and fail separately) but cost you the network, which is slower, less reliable and harder to debug than a function call. So add physical boundaries only where the independence is worth that cost, and draw the logical boundaries well in every case.

    Three styles, one product

    flowchart TD subgraph Mono["Monolith"] M1["One deployable: users, orders, catalog, notifications, search in one codebase and one database"] end subgraph Mod["Modular monolith"] MM["One deployable"] --> U1["Users module"] MM --> P1["Orders module"] MM --> C1["Catalog module"] MM --> N1["Notifications module"] end subgraph Micro["Microservices"] GW["Gateway"] --> US["Users service and own DB"] GW --> PS["Orders service and own DB"] GW --> CS["Catalog service and own DB"] PS -->|"event"| NS["Notifications service"] end

    The same product three times: the left box has no internal walls, the middle has walls but one front door and one database, and the right has separate buildings connected by the network.

    Monolith. All code is built and deployed as one unit, usually with one database. Calls are function calls and one transaction can span everything. It is the simplest to build, test, debug and deploy, and cheapest for small teams. Without discipline it becomes a "big ball of mud", the whole thing redeploys for any change, one runaway component can take the process down, and scaling means scaling everything. It is a perfectly good starting point.

    Modular monolith. Still one deployable and usually one database, but divided into modules with explicit boundaries. Each module owns its tables, exposes a small public interface, and other modules call only that interface, never reaching into internals or another module's tables. Build tooling, package visibility or architecture tests enforce this. You get most of the clarity of microservices (ownership, replaceable parts, cleaner dependencies) with one process and local transactions, and a well-defined module can later be lifted out with far less pain. See modular monolith architecture in Java.

    Microservices. Small services, each independently deployable, owning its data and communicating over the network (HTTP, gRPC, messages), typically each owned by one team. Teams deploy at their own pace, hot services scale alone, failures can be isolated, and parts can use different stores or languages. But every call is now a network call with latency and failure; cross-service queries and transactions become hard (see distributed transactions); each service needs discovery, a gateway, tracing, monitoring, versioned contracts and deployment automation; and system-level testing gets harder. The practical lessons are collected in microservices best practices.

    Comparing them honestly

    Factor Monolith Modular monolith Microservices
    Deployment unit One One Many
    Team independence Low Medium: shared release, separate ownership High: independent release
    Transactions Simple ACID Simple, one database Hard: sagas or eventual consistency
    Operational burden Low Low High: many pipelines and monitors
    Latency between parts Function call Function call Network hop
    Scaling granularity Whole app Whole app Per service
    Fault isolation Weak Weak Strong if designed well
    Cross-boundary refactoring Easy but unconstrained Easy in one repo Hard: contracts across teams
    Best fit Small team, early product Growing product, one to a few teams Many teams, clear domains, scale needs

    Microservices are mainly a tool for organisational scaling (many teams not blocking each other) and for scaling parts independently. They are not primarily a performance feature: a well-tuned single process handles a surprising load, and splitting adds network overhead rather than removing it.

    Team and deployment factors

    Ask about people and delivery, not only technology.

    • How many teams and engineers? A team of eight rarely benefits from ten services. A service should be owned by one team, and a team should be able to understand and operate what it owns.
    • How often does each area change, and by whom? If one area changes daily and others monthly, isolating it pays off. If everything changes together, separate deployables just mean coordinated releases.
    • Do parts have different scaling or reliability needs? Video transcoding (CPU heavy, bursty) and profile reads (light, constant) argue for separation; uniform load argues against.
    • Is there platform capability? Independent deployment, automated tests, centralised logging, metrics, tracing, discovery and secure service-to-service calls must exist first. See observability and SLOs and API gateway and service mesh.
    • Are the domain boundaries known? Early on, boundaries move. Moving one inside a module is a refactor; between services it is a cross-team migration.
    flowchart TD S["Considering a split"] --> Q1{"Is a shared release blocking more than one team regularly?"} Q1 -->|"no"| Stay["Stay modular monolith, enforce module rules"] Q1 -->|"yes"| Q2{"Is the boundary stable and the data separable?"} Q2 -->|"no"| Fix["Fix boundaries inside the monolith first"] Q2 -->|"yes"| Q3{"Does a platform exist for deploys, tracing and contracts?"} Q3 -->|"no"| Plat["Build the platform capability first"] Q3 -->|"yes"| Ext["Extract one service with the strangler pattern"]

    Every "no" leads to a cheaper action than a new service. Only a stable boundary, blocked teams and a ready platform justify extraction.

    TIP

    "Monolith first, but modular." Start with a well-structured modular monolith, watch where pain appears (a module that needs to scale alone, a team blocked by shared releases) and extract only those parts.

    The distributed monolith anti-pattern

    A distributed monolith is a system split into many services that still behave like one: they must be deployed together, share a database, or call each other in long synchronous chains so a failure or change ripples everywhere. You pay the full cost of distribution and get none of the independence. Warning signs:

    • Releasing one service routinely requires releasing others in order.
    • Multiple services read and write the same tables.
    • One request passes through a long chain of synchronous calls, so availability is the product of all of them. Five services at 99.9 percent each give roughly 99.5 percent (0.999 to the fifth power), worse than any one.
    • A large shared "common" library of domain objects forces everyone to upgrade together.
    • A data field change needs coordinated changes in many repositories.
    • You cannot run or test a service without starting most of the others.
    A distributed monolith: a synchronous chain over one shared databaseService A to Service B (sync call); Service B to Service C (sync call); Service C to Service D (sync call); Service A to Shared database; Service B to Shared database; Service C to Shared database; Service D to Shared databaseService AService AService BService BService CService CService DService DShared databaseShareddatabasesync callsync callsync call

    The fixes are the opposite of the symptoms: give each service its own data, communicate through versioned contracts, prefer asynchronous events (see message queues and event streaming) where no immediate answer is needed, redraw boundaries around business capabilities rather than technical layers, and if pieces truly cannot be independent, merge them back into one deployable.

    Data ownership

    The hardest part of splitting is the data. Each piece of data has exactly one owning service, and others access it only through that service's API or through events it publishes. That means no cross-service joins (call the owners, or keep a deliberately duplicated read-only copy updated by events, with a single owner and known staleness), no cross-service ACID transactions (use sagas, the outbox pattern and idempotent steps), schema changes treated as contract changes (add fields, never rename or remove without deprecation), and a plan for reporting (stream data into a warehouse rather than querying live service databases).

    How and when to split: the strangler pattern

    The strangler pattern (named after vines that slowly grow around a tree) replaces a piece of the old system step by step instead of in one risky rewrite.

    1. Pick a candidate with a clear boundary and a real reason: independent scaling, a blocked team, a volatile area or different technology needs.
    2. Establish the seam: a routing layer (gateway, proxy or internal interface) in front of the functionality.
    3. Build the new service behind the seam with its own data store.
    4. Migrate the data and keep both in sync during the transition (for example by publishing change events from the old database), so you can switch back.
    5. Shift traffic gradually, comparing results at each step.
    6. Remove the old code once the new path is stable; skipping this leaves two implementations.
    Strangler pattern: routing traffic to the old and the new searchClients to Routing layer; Routing layer to Monolith (most traffic); Routing layer to New search service (growing share); Monolith to New search service (data sync)ClientsClientsRouting layerRouting layerMonolith: search moduleMonolithsearch moduleNew search serviceNew searchservicemost trafficgrowing sharedata sync

    Good first candidates are the edges of the system: notifications, image processing, search, report generation. They have clear inputs and outputs, are naturally asynchronous and share little state. The tangled core (users, orders, pricing) is usually the worst place to start.

    IMPORTANT

    Avoid a "big bang" rewrite to microservices. It freezes feature work, hides risk until the end and often recreates the same coupling in distributed form. Incremental extraction with a rollback path is slower to describe and far safer to execute.

    A decision guide

    Situation Suggested style Why What to watch
    New product, one small team Monolith with clean modules Speed and simplicity Boundaries rotting
    Growing product, few teams, shared release fine Modular monolith Ownership without network cost Enforce module rules in the build
    One component needs very different scaling Extract that component only Targeted benefit Data sync, new failure modes
    Many teams blocked by a shared release Microservices around business domains Independent delivery Platform, contracts, observability
    Services share a database and deploy together Merge back or separate the data Distributed monolith Do not add more services
    Strict regulatory isolation of one function Separate service or deployment Isolation by design Cost and duplication

    In practice: a 25-engineer e-commerce company

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    The situation

    A direct-to-consumer retailer has 25 engineers in four squads: catalog and search, checkout and payments, fulfilment and inventory, and growth (promotions, email, analytics). The product is a Spring Boot application on Kubernetes with one PostgreSQL database, Redis and a message broker, built and released as one artifact. Two senior engineers have proposed rewriting into microservices "to move faster".

    Illustrative pain: the build and test cycle takes 40 minutes; releases happen once a week through a release train because any squad's bug blocks everyone; squads regularly edit each other's code; and a slow catalog import once degraded checkout. But there is also a counterweight: checkout, inventory and promotions share transactions (reserve stock, apply a coupon, create an order) that work today precisely because they share a database. Budget allows no dedicated platform team.

    Starting architecture

    Starting architecture: one Spring Boot artifact on KubernetesShoppers and staff to Nginx ingress; Nginx ingress to Spring Boot; Spring Boot to PostgreSQL; Spring Boot to Redis; Spring Boot to Message brokerPACKAGES INSIDE THE APPShoppers and staffShoppers andstaffNginx ingressNginx ingressSpring Boot: one artifactSpring Bootone artifactPostgreSQLPostgreSQLRedisRedisMessage brokerMessage brokercatalogcatalogcheckoutcheckoutinventoryinventorygrowthgrowth

    Packages exist, but any package may call any other and any may query any table.

    Options considered

    Option What it fixes What it costs Risk
    A. Do nothing, speed up the build Faster feedback Little Coupling keeps growing; release train persists
    B. Modular monolith: enforced module boundaries, schema per module, per-squad ownership, faster pipeline; extract only edges later Ownership and build isolation, no new network failure modes Discipline, refactoring effort, some duplicated data access code Weak fault isolation remains inside one process
    C. Full microservices rewrite by squad Independent deploys and scaling Platform build-out, sagas for checkout, distributed tracing, on-call per service Distributed monolith around the shared transaction; feature freeze

    The decision

    ADR field Content
    Context The pain is release coupling and code ownership, not scale. The shared checkout transaction is valuable.
    Decision Option B. Four modules (catalog, checkout, inventory, growth) with public interfaces; architecture tests (for example with ArchUnit) fail the build on illegal dependencies; each module owns its own database schema and no module reads another's tables; cross-module reactions use in-process events. Pipelines run module-level tests in parallel. Search indexing and email are marked as first extraction candidates behind the existing event interface.
    Consequences Good: squads own boundaries, builds and tests parallelise, the checkout transaction stays local, and extraction later is a mechanical move. Bad: a runaway module can still starve others, and some squads lose the freedom to pick a different stack.
    Revisit when A single module's resource profile needs different scaling for 2 consecutive quarters, or a squad blocked by the shared release exceeds an agreed share of its time, or a platform team exists to run services.

    Rollout and verification

    Begin by measuring: dependency graph between packages, which tables each package touches, and the change-coupling in version control. Turn on architecture tests in report-only mode, fix violations module by module, then make the build fail. Introduce schema per module with views for the transition. Verify with these metrics: build time, lead time from merge to production, change failure rate, number of cross-module violations (target zero), and the number of squads blocked per release. Then move to daily releases behind feature flags with a canary.

    Target architecture: a modular monolith with a schema per modulecheckout module to catalog module (public interface); checkout module to inventory module (interface, one transaction); checkout module to growth module (in-process events); catalog module to schema catalog; checkout module to schema checkout; inventory module to schema inventory; growth module to schema growth; growth module to Edge services (extracted later)ONE DEPLOYABLE WITH 6 PODScatalog modulecatalog modulecheckout modulecheckoutmoduleinventory moduleinventorymodulegrowth modulegrowth moduleschema catalogschema catalogschema checkoutschemacheckoutschema inventoryschemainventoryschema growthschema growthEdge services: search and emailEdge servicessearch and emailpublic interfaceinterface, one transactionin-process eventsextracted later

    The 2 a.m. failure

    The weakness of this design is shared fate. A growth-squad job that rebuilds promotion eligibility runs a large query on a loop and takes most of the database connection pool, so checkout requests queue and then time out. The page reads: "checkout p99 above 8 seconds, connection pool saturation".

    sequenceDiagram participant J as Promo rebuild job participant P as Shared connection pool participant C as Checkout request participant D as PostgreSQL J->>P: borrow many connections P->>D: long running queries C->>P: borrow connection P-->>C: wait, then timeout Note over C: checkout fails while the job runs

    The first three checks: the pool metrics (active versus max connections) per module if tagged; SELECT pid, state, query_start, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY query_start to find the long queries; and a thread dump of one pod to see who holds the connections. Mitigation: stop the job and cancel the offending queries (pg_cancel_backend(pid)). Do not scale out pods as a first move, because every new pod brings its own pool and adds connections to a database that is already saturated. Follow-up fixes: a separate bounded connection pool per module (a bulkhead), run batch jobs in a second deployment of the same artifact with its own pool and replica reads, and set statement timeouts.

    Architect's notes

    • Name the pain before the style. If the problem is the release train, a modular monolith with better pipelines may fix it; ask for the measured lead time before approving a rewrite.
    • Look at the transactions. List the business operations that need atomicity across modules. Every one becomes a saga if you split them; that list is the real cost of microservices.
    • Count the on-call. Each service needs an owner who gets paged and a runbook. Twelve services for 25 engineers means most people carry several.
    • Enforce, do not hope. A boundary that is not a failing build will erode in a quarter. Make violations visible on a dashboard.
    • Extract edges first, defer the core. Notifications, search and reporting split cleanly; checkout and inventory should stay together until evidence says otherwise.
    • Plan for shared fate inside the monolith: separate pools, separate batch deployments, statement timeouts. Weak fault isolation is a known cost to be engineered around, not ignored.

    Common mistakes

    • Starting with microservices for an unproven product. Boundaries are guesses and you pay distribution costs immediately. Start modular, split on evidence.
    • Splitting by technical layer. A "database service" and a "validation service" change together for every feature. Split by business capability.
    • Sharing a database between services. It couples schemas and deployments. Give each service its own data and expose APIs or events.
    • Long synchronous call chains. Availability multiplies down and latency adds up. Shorten chains, use events, cache and set timeouts.
    • Ignoring the platform cost. Every service needs a pipeline, monitoring, alerts, on-call and security. Count it first.
    • Rewriting everything at once. Use the strangler pattern with traffic shifting and rollback.
    • Treating the modular monolith as a lesser option. It is a deliberate destination for many teams.

    Key takeaways

    • Boundaries have two parts. Logical boundaries matter in every style; physical boundaries are an optional, costly addition.
    • Monolith first is a sound default. A single deployable is simplest to build, test and reason about.
    • The modular monolith is the middle path. Strong module boundaries and local transactions, with an easier path to extraction.
    • Microservices scale organisations. The benefit is independent team delivery and targeted scaling, not raw speed.
    • A distributed monolith is the worst of both. Shared databases, lockstep releases and long synchronous chains bring the costs and none of the independence.
    • One owner per piece of data. Access it via APIs or events; accept no cross-service joins or ACID transactions.
    • Split gradually with the strangler pattern. Introduce a seam, extract one capability, shift traffic, delete the old code.
    • Decide with evidence. Team size, release friction, scaling differences and platform maturity should drive the decision.

    Practice exercises

    Exercise 1 — Starter (30 min): For a ten-person team building a ticket-booking site, list the modules you would create in a modular monolith (for example users, events, bookings, payments, notifications), say what data each owns, and write the public interface (three to five operations) of the bookings module.

    Exercise 2 — Intermediate (2 hours): Given five services that each target 99.9 percent availability and are called synchronously in a chain, compute the overall availability and the expected monthly downtime (approximate). Then redesign the chain so that two of the calls become asynchronous events, and recompute the availability of the user-facing request. State your assumptions.

    Exercise 3 — Advanced (half day): You inherit a monolith where notifications, search and billing are tangled with the core. Write a migration plan using the strangler pattern for extracting notifications first. Cover the seam, the new service's data, the event contract, how you sync during the transition, the traffic-shifting steps with success metrics, the rollback plan, and what you would explicitly leave in the monolith. Draw the before, during and after architecture.

    Enroll to keep reading

    Lesson 8 of 14 · System Design Building Blocks: Data, Messaging and Scale

    Compare monoliths, modular monoliths and microservices honestly, avoid the distributed-monolith trap, and learn when and how to split a system using the strangler pattern.

    In the rest of this lesson

    • Team and deployment factors
    • The distributed monolith anti-pattern
    • Data ownership
    • How and when to split: the strangler pattern
    • A decision guide
    • In practice: a 25-engineer e-commerce company

    Enrolling is free. It unlocks every lesson in System Design Building Blocks: Data, Messaging and Scale, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments