Be Software Architecture Wise
Be Wise Apps

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 captions
Listen 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

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

Two modules inside one deployment unit: each has a small public API and a larger internal part, and owns its own database schema; ordering may call the payments API but must not reach into payments internals or its tablesOne deployment unit: the food-delivery applicationOrdering modulePublic APIfunctions, DTOs, eventsInternaldomain model, rules,repositoriesordering schemaPayments modulePublic APIfunctions, DTOs, eventsInternaldomain model, rules,repositoriespayments schemacall APIforbiddenno cross-schema joins
Each module exposes a small public API and owns its own schema; reaching into another module's internals or tables is forbidden.

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))
A Python package as a module: the payments package exports a small API, and ordering imports only that API.

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.customers
import-linter contracts (one per module in practice): other modules may not import payments internals, and module dependencies must follow one direction, with modules on the same line independent of each other.

Data 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.

CustomersAccounts, 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.
OrderingBasket, checkout, order lifecycle. Calls Customers, Menus and Payments; publishes OrderPlaced and OrderCancelled. Team C.
PaymentsAuthorisation, capture and refunds through an external provider. API: authorise_payment, refund; publishes PaymentCaptured. Team C.
DeliveryCourier matching and tracking. Listens for OrderPlaced; publishes CourierAssigned and Delivered. Team D.
Notifications and ReportingPure listeners: send messages and maintain read models. They call no other module. Team A.
Dependency rulePayments, 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.
DeploymentOne 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

Module map of the food-delivery platform: eight modules in one deployable application; Ordering calls the APIs of Payments, Menus and Customers, Restaurants calls Menus, Ordering publishes OrderPlaced events consumed by Delivery, Notifications and Reporting, and Delivery publishes CourierAssigned to NotificationsFood-delivery platform: one deployable applicationCustomersMenusRestaurantsOrderingPaymentsDeliveryReportingNotificationspayprice itemsaddressupdateOrderPlacedOrderPlacedOrderPlacedCourierAssignedin-process call to module APIin-process event
Solid arrows are calls to another module's API; dashed arrows are in-process events. Ordering calls three modules, and its listeners depend only on the events it publishes.

Characteristics of the modular monolith

CharacteristicRatingReason
SimplicityMedium to highOne deployment and in-process calls, but finding good module boundaries takes thought.
Overall costHigh (cheap)Infrastructure and operations of a monolith; some extra effort for boundary tooling.
Modifiability for business changeHighDomain partitioning keeps most changes inside one module owned by one team.
TestabilityMedium to highModules can be tested through their APIs with neighbours replaced; whole-system tests run in one process.
DeployabilityLow to mediumStill one deployment unit: every release ships all modules, though smaller blast radius per change helps.
Scalability and elasticityLow to mediumReplicas scale the whole application; one module cannot be scaled alone, though worker processes can separate background load.
Fault toleranceLowOne process: a crash or memory leak in any module affects all of them.
PerformanceHighCalls between modules are in memory, with no serialisation or network latency.
EvolvabilityHighExplicit 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.

Next · 5.4Pipes and filtersData flowing through a chain of independent steps: producers, transformers, testers and consumers joined by pipes. From Unix pipes to ETL, compilers and stream processing, with throughput, back pressure and error handling.Continue →

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?”