Leer en español · Lire en français · Auf Deutsch lesen
Section 5.3 of 5.8 · Intermediate · 26 min read
The modular monolith
Video lesson
5 min · narrated, with captionsListen only (audio)
Read the transcript
A modular monolith gives you many of the benefits people seek from microservices, without running a distributed system. In this lesson you'll see how it's built, how its boundaries are kept, and where it most often fails.
Think of a large department store. It's one building, with one front door, one set of opening hours and one security team. Inside, it's divided into departments: food, clothing, electronics. Each has its own staff, stockroom and till. If clothing needs something from food, it asks at the food counter rather than wandering into the food stockroom. A modular monolith is software built like that store.
A modular monolith is a single deployment unit whose top level is divided by business domain into modules: ordering, payments, delivery, menus. It combines the domain partitioning of microservices with the simplicity of a monolith: one build, one deployment, calls in memory and local transactions.
Here's the inside of one. The outer boundary is one deployment unit, the food-delivery application, and inside it are modules such as Ordering and Payments. Each module has a small public API: the functions, data and events its owners promise to keep stable. Ordering uses Payments only by calling that API. Everything else is internal: the domain model, the rules and the repositories. Reaching into another module's internals is forbidden. And each module owns its own database schema, with no joins across schemas.
In a monolith, any class can technically call any other, so boundaries hold only if something enforces them. Otherwise it decays into a big ball of mud within a year or two. Start with the strongest hiding your language offers. Add architecture tests in the build, such as ArchUnit for Java or import-linter for Python, which fail when one module imports another's internals. Frameworks can help. Spring Modulith verifies module rules for Spring applications, and Shopify's Packwerk checks dependencies between packages in Ruby. And draw data boundaries: a schema per module, and ideally a database user that can reach only that schema. Code review won't stop a convenient join; database permissions will.
Data ownership is where modular monoliths most often fail. Sharing one database server is fine; sharing tables is not. Every table has exactly one owning module, and only the owner writes it. Other modules get the data through the owner's API, or from events it publishes. A report that joined three modules' tables now needs API calls or its own read model. That's real cost, but it's the cost a later move to services would bring, paid early and cheaply. Modules talk in two ways, both in memory. Make a call when you need an answer, such as whether a payment was authorised. Publish an event when you don't, and when several modules react to the same fact.
Here's a worked example. A food-delivery start-up with four teams, about twenty five engineers, wants independent ownership without running microservices. The architect proposes eight modules in one deployable Python application, with one schema per module. Ordering sits at the centre. It calls three modules through their APIs: Payments to pay, Menus to price items, and Customers for the address. Restaurants updates menus through the Menus API. When an order is placed, Ordering publishes an Order Placed event, and Delivery listens. So do Reporting and Notifications. Ordering doesn't need to know who is listening. Dependencies point one way, so cycles are impossible, and import-linter checks that in every build. It all ships as one container image, run as six replicas, plus a worker for events and background jobs.
A well-known real case is Shopify. Rather than split its huge Ruby on Rails monolith into microservices, from 2017 it reorganised the code into components named after business domains, such as orders, shipping and billing. It wasn't a simple success: years later, almost no component had a complete, strong boundary. A modular monolith needs sustained investment, and retrofitting boundaries is much harder than keeping them from the start. The payoff is options. If a module later needs to scale or be released on its own, extraction is far easier when it already has an explicit API, owns its data and talks to others only through APIs and events.
The full section covers good module APIs, the subtleties of events, and Shopify's story in more detail. Next, in Section five point four, we turn to a very different shape: pipes and filters. See you there.
What you need to know
- A modular monolith is a single deployment unit whose top level is partitioned by business domain into modules, each with a small explicit public API, private internals and its own data.
- It keeps the operational simplicity of a monolith (one build, one deployment, in-process calls, local transactions) while giving many of the modularity benefits people seek from microservices.
- Boundaries erode unless enforced: language visibility, architecture tests (ArchUnit, import-linter), Spring Modulith's verification for Spring applications, Packwerk for Ruby, and separate database schemas per module.
- Each table belongs to exactly one module; other modules use its API or react to its events, never its tables. Joins across modules are replaced by API calls or read models.
- Modules can communicate by in-process calls (simple, immediate, transactional) or in-process events (looser coupling, but the listener's work happens separately and can fail separately).
- Shopify reorganised its large Ruby on Rails monolith into domain components from 2017 and described it in 2019; its later posts show both the value of the approach and how hard full isolation is.
- A well-bounded module can later be extracted into a service with modest effort, because its API, data and dependencies are already explicit.
Definition: one deployment, many domains
A modular monolith is an application that is built and deployed as one unit but whose code is divided, at the top level, into modules that follow business boundaries: ordering, payments, delivery, menus. Each module has a small, deliberate public API that other modules may use, a larger internal part that nobody outside may touch, and its own data. It combines the domain partitioning of microservices with the single deployment unit of a classic monolith.
The idea is not new. Modules with hidden internals go back to David Parnas's information hiding (1972), and large well-structured monoliths have always existed. What is newer is the name and the attention. After a decade in which many organisations split systems into microservices and discovered the cost of distribution, practitioners such as Simon Brown and Sam Newman argued that a modular monolith is an underrated option, and that many teams would be better served by one than by a set of services. Richards and Ford give it a chapter of its own in the second edition of Fundamentals of Software Architecture (2025).
The difference from the layered style is the top-level cut. A layered application is divided into presentation, business and persistence, with every domain spread across all three. A modular monolith is divided into domains, and each module may be layered inside. The difference from microservices is deployment: the modules share one process, one release and usually one database server, and they call each other in memory.
Anatomy of a modular monolith
Module APIs and internal code
The single most important rule is that a module is used only through its API. The API is the set of functions, data transfer objects and events that the module's owners promise to keep stable; everything else, including the domain model, the repositories and the database tables, is internal and may change at any time. In practice the API is small: a payments module might expose authorise_payment, refund, a PaymentResult type and a PaymentCaptured event, while hiding dozens of classes for providers, retries, ledgers and reconciliation.
Good module APIs share several traits. They speak the language of the domain (authorise_payment, not insert_payment_row). They pass simple data, such as identifiers and small immutable records, rather than internal objects, so that callers cannot reach through a returned object into the module's internals. They are coarse enough that a typical use case needs one or two calls, not twenty. And they are designed as if a network might one day sit between caller and callee: no shared mutable objects, no reliance on the caller's database transaction, explicit errors. That last trait is what keeps the option of extracting the module later.
Inside the boundary, anything goes that the module's team finds useful: a layered structure, a hexagonal one, a rich domain model or simple transaction scripts. Different modules can make different choices, because nothing outside depends on them.
# delivery_platform/payments/__init__.py (the module's public API)
from .api import authorise_payment, refund, PaymentResult
from .events import PaymentCaptured
__all__ = ["authorise_payment", "refund", "PaymentResult", "PaymentCaptured"]
# delivery_platform/ordering/internal/checkout.py
from delivery_platform import payments # allowed: public API
# from delivery_platform.payments.internal import ledger # forbidden
def place_order(order, card_token):
result = payments.authorise_payment(order.id, order.total, card_token)
if not result.ok:
raise PaymentDeclined(result.reason)
order.confirm(result.payment_id)
events.publish(OrderPlaced(order.id, order.restaurant_id))Enforcing the boundaries
In a monolith every class can technically call every other class, so boundaries hold only if something enforces them. Without enforcement, a deadline-driven shortcut into another module's internals is invisible in review, and a modular monolith decays into a big ball of mud within a year or two. There are several layers of defence, best used together.
Language visibility. Use the strongest hiding the language offers: package-private classes in Java, internal in Kotlin and C#, the Java Platform Module System's exports, separate build modules (Maven or Gradle subprojects, .NET projects) whose dependencies are declared explicitly. Python and Ruby offer only conventions (a leading underscore, an internal sub-package), so tools must do the work.
Architecture tests. Architecture tests check dependencies in the build. ArchUnit (Java) can assert that no class outside a module touches its internal packages and that module dependencies are free of cycles. import-linter (Python) offers a forbidden contract, which can stop other modules importing a module's internal package while still letting them use its public API, a layers contract whose layers can hold several mutually independent siblings separated by |, which makes a one-way dependency order between modules checkable, and an independence contract for packages that must not depend on each other at all, even indirectly.
Framework support. Spring Modulith, for Spring Boot applications, treats each direct sub-package of the application's main package as a module, treats the module's base package as its API and its sub-packages as internal, and offers a one-line verification (ApplicationModules.of(Application.class).verify()) that fails when a module references another module's internals, when module dependencies form a cycle, or, where a module lists its allowed dependencies, when it uses one that is not listed. It can also generate C4-style component diagrams of the modules. In the Ruby world, Shopify's open-source Packwerk statically analyses references between packages, each declared by a package.yml file, and reports undeclared dependencies.
Data boundaries. Give each module its own database schema (or at least its own clearly prefixed tables) and, ideally, its own database user that can access only that schema. Code review alone will not stop a convenient JOIN across modules; database permissions will.
[importlinter:contract:payments-internals]
name = Only payments may use payments internals
type = forbidden
source_modules =
delivery_platform.ordering
delivery_platform.delivery
delivery_platform.reporting
forbidden_modules =
delivery_platform.payments.internal
# going through the public API (which itself uses internals) is fine
allow_indirect_imports = True
[importlinter:contract:module-order]
name = Module dependencies point one way
type = layers
layers =
delivery_platform.reporting | delivery_platform.notifications
delivery_platform.delivery
delivery_platform.ordering | delivery_platform.restaurants
delivery_platform.payments | delivery_platform.menus | delivery_platform.customersData ownership inside a monolith
Data ownership is where modular monoliths most often fail. Sharing one database server is fine; sharing tables is not. If the delivery module reads the orders table directly, the ordering team can no longer change that table without coordinating with delivery, and the two modules are coupled as tightly as if they were one. The rule is simple to state: every table has exactly one owning module, only the owner writes it, and other modules obtain the data through the owner's API or from events the owner publishes.
The rule has consequences that teams must accept deliberately. A report that used to be one SQL query joining orders, payments and couriers now needs either calls to three module APIs or a read model: a reporting module that subscribes to events from the others and keeps its own denormalised copy, optimised for queries. Foreign keys across module schemas are usually dropped, because they would make one module's schema depend on another's; integrity across modules is maintained by the application instead. These costs are real, but they are the same costs a later move to services would impose, paid early and in a cheaper setting.
One advantage remains that distributed systems lose: modules in one process can, when they truly must, share a single local database transaction. Use it sparingly. Each use couples the modules' data operationally and must be undone if a module is ever extracted.
Communication between modules: calls or events
Modules talk in two ways, both in memory. An in-process call to another module's API is simple, fast and immediate: ordering calls payments.authorise_payment and gets the result at once, and an exception propagates normally. It creates a direct dependency, from caller to callee, and the caller cannot finish until the callee does. Use calls when the caller needs an answer to continue, such as whether the payment was authorised.
An in-process event inverts the dependency. Ordering publishes OrderPlaced and does not know or care who listens; delivery, notifications and reporting subscribe. Adding a new reaction means adding a listener, without touching ordering. Use events when the publisher does not need an answer, and when several modules react to the same fact.
Events introduce subtleties that teams new to them often miss. If a listener runs inside the publisher's transaction, a failure in the listener can roll back the order; if it runs after the commit, the order is saved but the listener's work can be lost if the process crashes in between. Spring Modulith addresses this with an event publication registry that records each event-listener pair in the same transaction as the business change and marks it complete only when the listener succeeds, so that incomplete ones can be retried; a hand-built equivalent is the transactional outbox described in Section 6.7. Listeners should also be idempotent, so that a retried event does no harm. Neither style is better; most modular monoliths use calls for queries and commands that need an answer and events for notifications of things that have happened.
Shopify's modular monolith
Shopify's core application is one of the largest Ruby on Rails code bases in existence, worked on by more than a thousand developers over more than a decade. In Deconstructing the Monolith
(Shopify Engineering, February 2019), Kirsten Westeinde described why the company did not split it into microservices and instead, from 2017, turned it into a modular monolith. The pain was familiar: code for unrelated business areas was entangled, changes had unexpected effects elsewhere, and onboarding was slow. Microservices were considered and judged to bring their own costs (separate pipelines and infrastructure, network latency and failures, harder refactoring across services), so the team chose to get modularity without multiple deployment units.
The first step was to reorganise the code from the standard Rails layout (models, views and controllers) into components named after business domains, such as orders, shipping, inventory and billing, in one large automated change. The next was isolation: each component was to have a public API and exclusive ownership of its data, with an internal tool, Wedge, tracking each component's progress by recording calls across boundaries during the test suite. A September 2020 update reported 37 components in a core monolith of about 2.8 million lines of Ruby, and that same month Shopify open-sourced Packwerk, a static analysis tool for declaring and checking dependencies between packages.
The story is instructive because it is not a simple success. The 2020 update said the original ideas still stood but almost every detail had changed, and that almost no component yet had a complete, strong boundary. A 2024 retrospective on Packwerk reported that its privacy checks had been removed, that some packages organised by name rather than by runtime behaviour misled developers, and that fully isolating even a base package took many months; Packwerk had become a guard on key boundaries rather than the centre of the strategy. The lesson for architects is that a modular monolith is a direction of travel that needs sustained investment, and that retrofitting boundaries into a large existing code base is much harder than keeping them from the start.
Worked example
Worked example: the food-delivery platform as a modular monolith
A food-delivery start-up with four teams (about 25 engineers) wants independent ownership without the cost of operating microservices. The architect proposes eight modules in one deployable Python application, with one PostgreSQL server and one schema per module.
| Customers | Accounts, addresses, preferences. API: get_customer, get_delivery_address. Team A. |
|---|---|
| Restaurants; Menus (two modules) | Restaurants holds restaurant accounts and opening hours and updates menus through the Menus API; Menus serves prices and availability to everyone else (price_items, is_open). Team B. |
| Ordering | Basket, checkout, order lifecycle. Calls Customers, Menus and Payments; publishes OrderPlaced and OrderCancelled. Team C. |
| Payments | Authorisation, capture and refunds through an external provider. API: authorise_payment, refund; publishes PaymentCaptured. Team C. |
| Delivery | Courier matching and tracking. Listens for OrderPlaced; publishes CourierAssigned and Delivered. Team D. |
| Notifications and Reporting | Pure listeners: send messages and maintain read models. They call no other module. Team A. |
| Dependency rule | Payments, Menus and Customers depend on nobody; Ordering and Restaurants call them; Delivery reacts to Ordering's events; Notifications and Reporting only listen. Checked by import-linter in the build. |
| Deployment | One container image, run as six replicas behind a load balancer, plus a worker process (same code) that handles event listeners and background jobs. |
Each team owns whole modules, so most changes land in one module and one team.
The dependency rule makes cycles impossible: if Payments ever needs to know about orders, it receives an order identifier, not an import of Ordering.
Module map of the food-delivery platform
Characteristics of the modular monolith
| Characteristic | Rating | Reason |
|---|---|---|
| Simplicity | Medium to high | One deployment and in-process calls, but finding good module boundaries takes thought. |
| Overall cost | High (cheap) | Infrastructure and operations of a monolith; some extra effort for boundary tooling. |
| Modifiability for business change | High | Domain partitioning keeps most changes inside one module owned by one team. |
| Testability | Medium to high | Modules can be tested through their APIs with neighbours replaced; whole-system tests run in one process. |
| Deployability | Low to medium | Still one deployment unit: every release ships all modules, though smaller blast radius per change helps. |
| Scalability and elasticity | Low to medium | Replicas scale the whole application; one module cannot be scaled alone, though worker processes can separate background load. |
| Fault tolerance | Low | One process: a crash or memory leak in any module affects all of them. |
| Performance | High | Calls between modules are in memory, with no serialisation or network latency. |
| Evolvability | High | Explicit APIs and data ownership make modules easy to restructure or extract later. |
When to use it, when not to, and anti-patterns
A modular monolith is a strong default for new products whose domain is reasonably clear, for organisations with up to a handful of teams, for systems that need strong consistency across business areas, and for teams with limited operations capacity. It is also the natural target when modernising a tangled monolith: introducing boundaries inside it is usually cheaper and less risky than breaking it into services in one go. It is less suitable when parts of the system need genuinely different quality attributes (a feed of courier positions that must absorb huge write volumes and a back-office module with modest load), when dozens of teams must release independently many times a day, or when parts must be written in different languages or run in different places for data-residency reasons. Those needs call for separate quanta.
Common anti-patterns: modules in name only, where folders follow domains but anything imports anything; the shared database free-for-all, where modules keep their own code but join each other's tables; the chatty boundary, an API so fine-grained that a use case needs dozens of calls, which will be painful if the module is ever extracted; the shared kernel that grows, a common module that slowly absorbs business logic and couples every module to it; and entity modules, cutting by data entity (a Customer module, an Order module, a Product module that every use case needs at once) instead of by business capability, which produces modules that cannot do anything alone.
Keeping the option to extract services
A modular monolith is sometimes presented as a stepping stone to microservices, sometimes as a destination. It can be either, and the point is that the decision can be deferred to the last responsible moment. If one module later needs to scale separately or be released independently, extraction is far easier when the module already has an explicit API, owns its data and depends on others only through APIs and events. The work becomes largely mechanical: put a network interface in front of the API, move the schema to its own database, replace in-process events with a message broker, and replace any shared transaction with an explicit saga or compensation.
Sam Newman makes the same argument in Monolith to Microservices (2019): a well-modularised monolith is the best starting point for decomposition, because the hard part, finding and enforcing boundaries, is already done. Section 5.8 discusses the signs that it is time to extract, and Section 11.6 covers incremental migration techniques such as the strangler fig.
Check your understanding
What distinguishes a modular monolith from a layered monolith?
Its top level is partitioned by business domain rather than by technical layer; each module has an explicit API, private internals and its own data, and may be layered inside.
Why should module APIs pass identifiers and simple records rather than internal domain objects?
Returned internal objects let callers reach into the module's internals and couple them to its model; simple data keeps the internals free to change and the API ready to cross a network later.
A report needs data from the ordering and payments schemas. What are two options that respect data ownership?
Call both modules' APIs and combine the results, or build a read model in a reporting module that listens to both modules' events and keeps its own denormalised copy.
What risk does an in-process event listener that runs after the publisher's transaction commits introduce?
If the process crashes after the commit but before the listener runs, the listener's work is lost unless the event is recorded durably, for example in an event publication registry or outbox, and retried.
Sources and further reading
Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.
Ask: “We have a Django monolith with apps for orders, payments and delivery, but they all import each other's models. How do we turn it into a real modular monolith step by step?”