Skip to content
Leo Jung
Go back

[DDD Intro #28] Modular Monolith: What to think about before microservices

Edit page

As a system grows, we start wanting to split services. It feels like creating independent services for orders, payments, members, notifications, and settlement will make things cleaner. It seems each team can own its service, deploy independently, and isolate failures.

But if we split into microservices before model boundaries are organized, problems can become larger. Coupling that was solved inside code turns into network calls and deployment dependencies. The complexity of the monolith merely moves into a distributed environment.

So in many cases, a good starting point is a modular monolith. It clearly splits module boundaries inside one deployment unit and first secures model independence.

A monolith is not bad

The word monolith is often used negatively. But the monolith itself is not the problem. The problem is a monolith without boundaries.

The problem is a structure where every piece of code references every other piece of code, every feature directly modifies every table, and common modules absorb rules from every domain. This kind of structure can be called a Big Ball of Mud.

But the story changes if internal module boundaries are clear, even when the system is deployed as one application.

subscription module
payment module
authorization module
notification module
settlement module

Each module has its own domain model and does not directly know the internal implementation of other modules. Necessary interactions happen through explicit interfaces or events.

This is the core of a modular monolith.

Expressing Bounded Contexts as modules

In DDD, Bounded Context is a model boundary. In a modular monolith, this boundary can be expressed as a code module.

For a subscription service, we can think of a structure like this:

com.example.subscription
  application
  domain
  infrastructure

com.example.payment
  application
  domain
  infrastructure

com.example.authorization
  application
  domain
  infrastructure

Splitting packages alone is not enough. What matters is dependency rules. If the subscription module directly uses internal Entities of the payment module, the boundary collapses.

Communication between modules should be restricted to public APIs or events.

public interface PaymentFacade {
    PaymentResult pay(PaymentRequest request);
}

The subscription module does not know the internal tables or Entities of the payment module. It only uses the interface exposed by the payment module.

Internal calls are also contracts

In a modular monolith, modules can communicate through method calls inside the same process. Because these are not network calls, they are simple and fast. But that does not mean boundaries disappear.

Internal calls are also contracts. It should be clear which interface the payment module exposes and what result the subscription module expects.

PaymentResult result = paymentFacade.pay(request);
subscription.applyPaymentResult(result, now);

What matters here is that PaymentResult should be language the subscription module can understand. If an internal Entity of the payment module or a payment gateway response DTO is passed as it is, the module boundary becomes blurred.

Even in a modular monolith, we need the sense of ACL and Published Language.

The reality of sharing a database

In a modular monolith, a single database is often shared. In this case, we must be careful not to freely join and modify tables owned by other modules.

Even if tables are physically in the same DB, ownership should be logically divided.

The subscription module owns subscription-related tables.
The payment module owns payment-related tables.
Other modules do not directly modify tables they do not own.

Joins may be needed for query screens. In such cases, we can create a Query-only model, use a public query API between modules, or create a separate read model. We cannot prohibit every case unconditionally, but if direct access that ignores ownership becomes a habit, boundaries quickly collapse.

A single DB and a single model are different things.

A practice field before microservices

A modular monolith can be a stage before microservices. Before splitting into services, we can verify the following:

Are module boundaries valid?
Are there too many dependencies between modules?
Can this context change independently?
Which data must be shared?
Which interactions can be handled with events?

When module boundaries stabilize, it becomes easier to separate a particular module into a separate service later. Conversely, if boundaries keep shaking even inside a modular monolith, splitting into microservices is unlikely to solve the problem.

Microservices can make boundaries more explicit, but they do not fix wrong boundaries.

Especially useful for small teams

Microservices have high operational cost. Per-service deployment, monitoring, log tracing, incident response, API version management, and data synchronization are needed. It is hard for a small team to bear all of this cost.

A modular monolith, on the other hand, allows a team to train internal boundaries while operating one application. Deployment remains simple, while design can be divided along domain boundaries.

If a small team is starting DDD, a modular monolith is a good choice. It can reflect Bounded Contexts and module boundaries in code without taking on distributed system complexity from the beginning.

Devices are needed to enforce boundaries

The difficulty of a modular monolith is that boundaries are not physically enforced. Because everything is in the same codebase, we can import internal classes of another module if we want to.

Therefore devices are needed to protect boundaries.

Package dependency rules
Separation between public APIs and internal APIs per module
Architecture tests
Code review criteria
Module ownership
Limits on common module usage

Common modules need special care. A common package used by every module can quickly grow. Commonization reduces duplication, but if done poorly, it becomes a giant coupling point that ties every module together.

Instead of removing all duplication automatically, we should check whether we are forcing models from different contexts into a common model.

Closing

A modular monolith is not a structure that gives up on microservices. It is a realistic structure that verifies model and module boundaries before moving to good microservices.

What matters is not whether the system is deployed as one application or as several services. What matters is whether domain model boundaries are clear, whether dependencies between modules are controlled, and whether each module has its own language and responsibility.

If we split services first, complexity moves to the network. If we split modules first, complexity is observed and refined inside the code.

For many teams starting DDD, what they need may not be a huge microservice transition, but first creating a monolith with boundaries.


Edit page
Share this post:

Previous Post
[DDD Intro #29] DDD adoption checklist
Next Post
[DDD Intro #27] Applying DDD to legacy systems