← Back to Blog
Architecture13 minSep 5, 2026

Modular Monolith: The Architecture to Try Before Microservices.

Learn how a modular monolith uses domain boundaries, module APIs and data ownership to control coupling, and when extracting a module into a microservice makes sense.

Modular Monolith: The Architecture to Try Before Microservices.

Modular Monolith: The Architecture to Try Before Microservices#

Microservices can solve real architectural problems.

They can also create problems you did not have yet.

A backend starts relatively simple. The application grows. Dependencies become harder to reason about. A change in orders unexpectedly breaks payments. Database tables are accessed from unrelated parts of the codebase. Deployments feel riskier than they should.

The diagnosis is often correct:

the system has too much coupling.

But the proposed treatment is frequently more aggressive:

“We should split everything into microservices.”

That jump is not always necessary.

Before turning function calls into network calls, local consistency into cross-service workflows, and one deployment into many independently operated components, there is another architecture worth considering:

the modular monolith.

A modular monolith keeps a single deployment boundary while introducing explicit internal boundaries between business capabilities.

That distinction matters.

The first problem to solve is often not distribution.

It is modularity.

And if some modules later develop a real need for independent scaling, deployment, ownership or fault isolation, those existing boundaries can make service extraction substantially safer.

What Is a Modular Monolith?#

A modular monolith is a single deployable application whose internal design is divided into clearly separated modules.

The important word is not monolith.

It is modular.

Imagine an e-commerce backend with three business capabilities:

Users
Orders
Payments

A traditional monolith might already contain directories with those names while still allowing almost any part of the system to access any repository, model or table.

traditional monolith architecture

A modular monolith goes further.

Each module exposes a deliberate boundary:

Users
 ├── public API
 ├── domain logic
 ├── internal implementation
 └── owned data

Orders
 ├── public API
 ├── domain logic
 ├── internal implementation
 └── owned data

Payments
 ├── public API
 ├── domain logic
 ├── internal implementation
 └── owned data
modular monolith architecture

Other modules should communicate through explicitly exposed contracts rather than casually importing internal repositories, domain objects or persistence code.

The result is still:

one application and one deployment unit.

But internally, responsibilities are isolated much more deliberately.

That is the first important distinction:

deployment topology and code modularity are separate architectural decisions.

Strong boundaries do not require network boundaries.

Modular Monolith vs Traditional Monolith vs Microservices#

These architectures make different trade-offs.

| Characteristic | Traditional Monolith | Modular Monolith | Microservices | | -------------------------------------------- | -------------------- | --------------------------------- | --------------------------------------------------------------- | | Deployment | Single unit | Single unit | Multiple independent units | | Internal boundaries | Often weak | Explicit and enforced | Service/runtime boundaries | | Communication | Direct calls | Controlled in-process APIs/events | Network calls/messages | | Data ownership | Often shared | Defined by module | Defined by service | | Transactions | Usually local | Usually local | Local per service; cross-service workflows require coordination | | Debugging | Centralized | Mostly centralized | Distributed | | Independent scaling | No | No | Yes | | Independent deployment | No | No | Yes | | Network failures between business components | No | No | Yes | | Operational complexity | Low | Low to moderate | Higher | | Cross-boundary refactoring | Relatively cheap | Relatively cheap | More expensive |

microservices architecture

A modular monolith is therefore not a smaller version of microservices.

It makes a different architectural choice:

logical isolation before physical distribution.

The Real Problem Is Usually Coupling#

When developers say:

“Our monolith is impossible to maintain.”

the word monolith often receives more blame than it deserves.

The real symptoms usually look more like this:

orders -> users repository
orders -> payments tables
payments -> orders entities
users -> notification internals
notifications -> orders persistence

Eventually the dependency graph becomes difficult to reason about:

      ┌───────────┐
      │   Users   │
      └─┬─────┬───┘
        │     │
     ┌──▼──┐  │
     │Order│◄─┘
     └─┬─┬─┘
       │ │
 ┌─────▼─▼─────┐
 │  Payments   │
 └────┬───┬────┘
      │   │
 ┌────▼───▼────┐
 │Notification │
 └─────────────┘

Everything knows too much about everything else.

A useful module boundary should make some dependencies legal and others impossible or at least explicitly rejected.

legal and illegal cross-module dependencies

Moving those same dependencies into separate processes does not automatically improve the design.

You can easily create this:

orders-service
      ↓
users-service
      ↓
payments-service
      ↓
orders-service
      ↓
notification-service

The coupling is still there.

Except now every dependency can also fail over a network.

Distribution makes boundaries more expensive.

It does not automatically make them better.

The Microservices Tax#

Microservices offer capabilities a monolith cannot provide as easily:

  • independent deployment;
  • independent scaling;
  • stronger runtime isolation;
  • autonomous operational ownership;
  • different infrastructure choices per service.

Those capabilities are valuable when the system actually needs them.

But they have a cost.

A local call such as:

payment = payments.authorize(order)

might become:

Orders Service
      │
      │ HTTP / gRPC
      ▼
Payments Service

That boundary immediately introduces new questions.

What happens if Payments is unavailable?

How long should Orders wait?

Should it retry?

Can the operation safely be retried?

What if Payments processes the request but Orders loses the response?

How do you trace the workflow?

How are services authenticated?

How are API changes deployed?

How is consistency maintained when more than one service owns part of the workflow?

How is the complete flow tested?

These are not arguments against microservices.

They are consequences of distribution.

Martin Fowler has repeatedly described remote communication, eventual consistency and operational complexity as meaningful microservice trade-offs. AWS guidance similarly emphasizes that service decomposition introduces concerns such as latency, data consistency and additional operational coordination.

If the organization does not need the benefits of distribution yet, paying those costs early can become architectural overinvestment.

Conway's Law Matters#

Architecture is not purely a code-structure problem.

Team structure matters.

Conway's Law describes the tendency for systems to reflect the communication structures of the organizations that build them.

Consider an organization with several autonomous product teams.

Each team needs to:

build
test
deploy
scale
operate

its own business capability without coordinating every release with every other team.

Independent services may reinforce that organizational structure.

Now imagine a five-developer backend team operating sixteen microservices.

The same five developers may now be responsible for:

multiple deployment pipelines
runtime configuration
service discovery
distributed tracing
message infrastructure
API contracts
network security
cross-service testing
multiple persistence systems

The service boundaries did not create sixteen autonomous teams.

They created more operational surfaces for the same team.

Microservices become especially useful when organizational independence and runtime independence reinforce one another.

Before that point, strong internal modules may solve the more immediate problem at significantly lower operational cost.

How to Structure a Modular Monolith#

A directory called modules/ does not create modular architecture.

The architecture comes from constraints.

1. Organize Around Business Capabilities#

Avoid making the top-level structure purely technical:

controllers/
services/
repositories/
models/

That organizes code by implementation concern.

A single feature may require developers to jump through several unrelated directories.

Prefer business-oriented boundaries:

modules/
├── users/
├── catalog/
├── orders/
├── payments/
└── shipping/

Each module can still contain internal layers:

modules/
└── orders/
    ├── api/
    ├── application/
    ├── domain/
    ├── infrastructure/
    └── tests/

Most changes related to Orders can now stay inside orders.

Domain-Driven Design can help here.

A bounded context is not automatically a module or a microservice. It defines a boundary within which a particular domain model is valid.

That makes bounded contexts a useful input when discovering architectural modules.

A strong domain boundary can first become an internal module.

Whether it should later become a service is a separate decision.

2. Give Every Module a Public API#

Suppose Orders needs to authorize a payment.

This creates unnecessary coupling:

from modules.payments.infrastructure.repositories import PaymentRepository

Orders now depends on a Payments implementation detail.

Prefer an intentional contract:

from modules.payments.api import PaymentService

Conceptually:

class PaymentService:
    def authorize(
        self,
        customer_id: str,
        amount: Decimal,
    ) -> PaymentResult:
        ...

Orders knows the capability it needs.

It does not need to know:

which tables Payments uses
which ORM Payments uses
which provider processes the transaction
which internal domain objects Payments maintains

That is encapsulation.

Frameworks such as Spring Modulith follow a similar philosophy by distinguishing module APIs, internal implementation components and application events, and by providing mechanisms to verify module relationships.

3. Establish Explicit Data Ownership#

One strong rule for this architecture is:

Only the owning module should directly access its persistence implementation.

Suppose Orders owns order data.

Payments should not casually query:

SELECT *
FROM orders
WHERE id = ?;

And Orders should not reach into:

SELECT *
FROM payment_transactions
WHERE order_id = ?;

Even when all modules use the same PostgreSQL instance, logical ownership can remain explicit:

Database
│
├── users schema
│   └── owned by Users
│
├── orders schema
│   └── owned by Orders
│
└── payments schema
    └── owned by Payments

Physical separation is not mandatory.

Ownership is.

Other modules should obtain information through the owning module's public contract or through events that the module publishes.

This discipline matters because shared database access creates some of the strongest cross-boundary coupling in a system.

If Payments is eventually extracted into a service, separating its persistence is much easier when other modules were never allowed to depend directly on its tables.

4. Use Synchronous Calls When You Need an Immediate Result#

Not every dependency needs an event.

Suppose Orders cannot proceed without knowing whether a payment was authorized.

A synchronous module call is reasonable:

result = payment_service.authorize(
    customer_id=customer.id,
    amount=order.total,
)

if not result.approved:
    raise PaymentRejected()

The architectural rule is not “avoid synchronous calls.”

It is:

call the public contract instead of the implementation.

That makes the dependency explicit and controlled.

5. Use Events When Consumers Should Not Become Direct Dependencies#

Now suppose an order has been placed.

Several modules may care:

Inventory
Analytics
Notifications
Shipping

Orders does not necessarily need direct knowledge of each consumer.

It can publish:

OrderPlaced(
    order_id=order.id,
    customer_id=order.customer_id,
)

and allow interested modules to react:

               ┌──────────────► Inventory
               │
Orders ──► OrderPlaced ───────► Analytics
               │
               ├──────────────► Notifications
               │
               └──────────────► Shipping

The application is still one process.

No Kafka cluster is required simply to preserve module boundaries.

But an important distinction remains:

events are not automatically asynchronous or durable.

An in-process event bus may invoke consumers synchronously.

If guaranteed delivery is required, a durable publication mechanism, transactional outbox or another persistence strategy may be necessary.

Loose coupling and reliable messaging are separate concerns.

6. Enforce Encapsulation With the Language and Build System#

Architecture diagrams do not stop incorrect imports.

Code can.

Different languages offer different mechanisms.

Java can use package visibility and module boundaries.

Go can use packages and internal.

Rust has explicit module visibility.

Python relies more heavily on package APIs and conventions, which makes architectural tests and import rules particularly useful.

For example:

payments/
├── __init__.py
├── api.py
└── _internal/
    ├── models.py
    ├── repository.py
    └── stripe_gateway.py

Other modules may use:

from payments.api import PaymentService

but architectural validation should reject dependencies such as:

from payments._internal.repository import PaymentRepository

The objective is straightforward:

make the correct dependency easier than the incorrect one.

7. Test the Architecture#

Module boundaries are architectural requirements.

Treat them as executable requirements.

CI should be able to detect rules such as:

Orders cannot import Payments internals.

Payments cannot depend on Shipping.

Notifications can consume Order events
but cannot access Orders persistence.

Users cannot depend on Payments.

Without enforcement, exceptions accumulate.

Someone eventually says:

“I'll call the repository directly this once.”

A few months later, the exception has become the architecture.

Tools such as Spring Modulith and architecture-testing libraries in other ecosystems are useful precisely because they turn boundary rules into something measurable.

A Practical Directory Structure#

An e-commerce backend could look like this:

src/
│
├── app/
│   ├── bootstrap.py
│   └── event_bus.py
│
├── modules/
│   ├── users/
│   │   ├── api.py
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── tests/
│   │
│   ├── orders/
│   │   ├── api.py
│   │   ├── events.py
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── tests/
│   │
│   ├── payments/
│   │   ├── api.py
│   │   ├── events.py
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── tests/
│   │
│   └── shipping/
│       ├── api.py
│       ├── domain/
│       ├── application/
│       ├── infrastructure/
│       └── tests/
│
└── main.py

Notice what is not at the center of the design:

global_models/
global_repositories/
shared_business_logic/
utils_everything/

Shared technical infrastructure can be reasonable.

Shared mutable business models are much more dangerous.

What Should Actually Be Shared?#

Some infrastructure-level concepts can reasonably be shared:

logging
telemetry
configuration
database bootstrap
clock abstractions
base event infrastructure

Be more suspicious of:

shared/entities/
shared/business/
shared/services/
common/domain/

These directories frequently become shortcuts around domain boundaries.

Orders and Payments may both use the word Customer while requiring different representations of that concept.

That is not necessarily bad duplication.

Different bounded contexts are allowed to model the same real-world concept differently because they solve different business problems.

Trying to force every domain into one universal model often creates more coupling than it removes.

A Modular Monolith Does Not Require One Giant Transaction#

A single deployment does not mean every workflow should run inside one enormous database transaction.

A use case that genuinely requires atomic consistency may use a local transaction.

Other workflows can be represented as multiple steps.

The important advantage is that these boundaries can be introduced deliberately.

You can keep consistency local where it belongs and introduce asynchronous or eventually consistent workflows only where the domain justifies them.

Do not add distributed semantics merely to imitate microservices inside one process.

When Should a Module Become a Microservice?#

“The project is getting big” is not a sufficiently precise reason.

A stronger rule is:

extract a module when physical independence solves a demonstrated problem.

Independent scaling#

One workload has a radically different resource profile.

Image processing may consume far more CPU than the rest of the application.

Scaling the entire monolith only to increase image-processing capacity can become wasteful.

Independent deployment#

A domain belongs to an autonomous team that needs to deploy on its own cadence without coordinating every application release.

Fault isolation#

A capability requires availability characteristics that justify isolating failures from the rest of the process.

Different runtime or infrastructure requirements#

A workload genuinely benefits from a different runtime, storage technology or infrastructure model.

Organizational autonomy#

Multiple teams need independent operational ownership of distinct business capabilities.

Security or compliance isolation#

A domain needs stronger infrastructure or access controls than the rest of the application.

These are concrete reasons.

“Netflix uses microservices” is not one.

How a Modular Monolith Can Evolve Toward Microservices#

Suppose Payments eventually requires independent ownership and scaling.

Today:

┌─────────────────────────────┐
│         Application         │
│                             │
│  ┌────────┐    API    ┌──────────┐
│  │ Orders │ ────────► │ Payments │
│  └────────┘           └──────────┘
│                             │
│                       Payments data
└─────────────────────────────┘

Later:

┌───────────────┐      HTTP/gRPC/Event      ┌──────────────────┐
│ Orders Module │ ─────────────────────────► │ Payments Service │
└───────────────┘                            └──────────────────┘
                                                     │
                                              Payments database
extracting a module into a microservice

One major architectural change is that a logical boundary becomes a transport and deployment boundary.

But extraction is not trivial.

You still need to address:

network failure
authentication
timeouts
retries
idempotency
observability
API compatibility
data migration
deployment
failure recovery
cross-service workflows

The modular monolith does not eliminate those problems.

It postpones them until they provide value.

More importantly, it avoids having to discover the business boundary at the same time you are learning how to operate a distributed system.

That is a meaningful reduction in migration risk.

A Safer Migration Strategy#

Instead of:

Big Ball of Mud
        │
        ▼
Split everything
        │
        ▼
20 Microservices

a more deliberate path is:

Big Ball of Mud
        │
        ▼
Identify domain boundaries
        │
        ▼
Modular Monolith
        │
        ▼
Enforce module APIs
        │
        ▼
Establish data ownership
        │
        ▼
Observe real bottlenecks
        │
        ▼
Extract justified modules
        │
        ▼
Hybrid architecture

This is an evolutionary approach.

Expensive decisions are delayed until the team has more information about the domain, workload and organization.

Not every module needs to become a service.

A system may remain a modular monolith indefinitely.

It may also evolve into a hybrid architecture where only a few capabilities are independently deployed.

Both can be valid outcomes.

Common Modular Monolith Mistakes#

Modules Are Just Folders#

If every module can import every other module's internals, the boundary is cosmetic.

Every Module Uses Every Table#

Then the database has become the real dependency graph.

Establish ownership.

shared/ Becomes a New Monolith#

Shared business abstractions can quietly destroy domain isolation.

Keep shared code small and mostly infrastructural.

Everything Uses Events#

Events are useful, but excessive event-driven design can make simple workflows harder to follow.

Use synchronous calls when synchronous semantics are appropriate.

Nothing Uses Events#

The opposite extreme creates long chains of direct dependencies.

Use events when consumers should react without becoming explicit dependencies of the publisher.

Modules Follow Technical Layers Instead of Business Boundaries#

controllers, repositories and services describe implementation responsibilities.

Top-level modules should normally represent business capabilities.

Architecture Exists Only in Documentation#

A diagram does not prevent an invalid dependency.

Automate important boundary rules.

The Team Designs Imaginary Future Services#

Do not create twenty artificial modules because the organization might have twenty services one day.

Find cohesive domain boundaries for the system you actually have.

Modular Monolith vs Microservices: A Decision Framework#

| Situation | Better Starting Point | | ---------------------------------------------------- | -------------------------- | | One small or medium product team | Modular monolith | | Domain boundaries are still evolving | Modular monolith | | Frequent cross-domain refactoring | Modular monolith | | Strong local transactional workflows | Modular monolith | | Limited distributed-systems experience | Modular monolith | | Several autonomous teams | Consider microservices | | Independent deployment is critical | Consider microservices | | Components have radically different scaling profiles | Consider microservices | | Strong runtime fault isolation is required | Consider microservices | | Service boundaries are already stable | Microservices become safer |

The table is not a decision algorithm.

It is a way to force the right question:

what problem are we asking distribution to solve?

The Architecture Principle That Matters Most#

Modularity and distribution are separate decisions.

You can have:

a badly designed monolith
a well-designed monolith
badly designed microservices
well-designed microservices

Distribution does not create good boundaries.

It makes cross-boundary communication more expensive and more explicit.

A modular monolith lets a team establish the discipline first:

explicit contracts
clear ownership
high cohesion
low coupling
domain boundaries
controlled dependencies

without immediately accepting the operational cost of a distributed system.

That makes the architecture useful even when microservices may eventually be the right destination.

Perhaps especially then.

Frequently Asked Questions#

What is a modular monolith?#

A modular monolith is a single deployable application divided internally into explicit modules with controlled dependencies and clearly defined responsibilities.

Is a modular monolith the same as microservices?#

No. A modular monolith creates logical boundaries within one deployable process. Microservices introduce independently deployable runtime boundaries that communicate across a network or messaging infrastructure.

Is a modular monolith better than microservices?#

Neither is universally better. A modular monolith often has a lower operational cost when independent deployment and scaling are unnecessary. Microservices become more compelling when runtime or organizational independence solves a concrete problem.

Can a modular monolith scale?#

Yes. The complete application can scale horizontally or vertically.

What it generally cannot do is scale one internal module independently from the rest of the deployment. A radically different scaling profile can therefore become a reason to extract a module.

Does each module need its own database?#

No.

Modules can share the same physical PostgreSQL instance while maintaining clear ownership over specific schemas or tables.

The important rule is that other modules should not casually bypass that ownership by accessing another module's persistence directly.

How should modules communicate?#

Common approaches include synchronous calls through explicit module APIs and events when consumers should react without becoming direct dependencies of the producer.

Events may be synchronous or asynchronous, durable or in-memory. Those are separate design choices.

Can a modular monolith become microservices later?#

Yes.

Well-defined modules can be good service candidates because boundaries, contracts and data ownership are already understood.

Extraction still introduces distributed-system concerns such as network failures, authentication, observability, retries, data migration and cross-service consistency.

Should startups use microservices?#

Only when their organizational or technical constraints justify them.

For many small teams, a modular monolith provides room for the domain model and module boundaries to evolve without requiring distributed-system infrastructure from the beginning.

Conclusion#

Microservices are not the opposite of bad architecture.

Good modularity is.

If a monolith has become a big ball of mud, moving the same dependency graph across a network will not repair it.

Start with the boundaries.

Define ownership.

Protect module internals.

Expose explicit contracts.

Use events where they remove unnecessary dependencies.

Test architectural rules.

Then observe the system.

If a module eventually develops a real need for independent deployment, scaling, ownership, infrastructure or fault isolation, extract it.

At that point, the service boundary is no longer based on fashion or anticipation.

It exists because the system has demonstrated that physical independence provides value.

That is a much stronger reason to adopt microservices.