
Posted Oct 1, 2026 · 11 min read · 6 views
Before You Build Microservices, Learn to Build a Modular Monolith
Modern backend development often seems to suggest a simple progression:
Monolith → Microservices
You start with one application, and as the system grows, you split it into multiple independently deployable services.
That story sounds reasonable.
But there is a problem.
A lot of teams don't actually have a microservices problem.
They have a boundaries problem.
And splitting a badly structured monolith into ten services does not magically create a good architecture. It often creates ten distributed versions of the same problem.
For many products, a better starting point is:
Modular Monolith → Extract Services When Necessary
The important part is not whether your code runs in one process or ten.
The important part is whether your system has strong, understandable boundaries.
What Is a Modular Monolith?
A modular monolith is still one application.
You may deploy it as one unit, run it as one process, and initially operate one PostgreSQL instance.
But internally, the application is divided into clearly defined modules.
For example:
app/
├── modules/
│ ├── users/
│ ├── orders/
│ ├── payments/
│ ├── inventory/
│ └── notifications/
│
├── platform/
└── entrypoints/
The modules are not simply folders created to make the repository look organized.
Each module represents a meaningful business or system boundary.
For example:
orders
├── commands
├── queries
├── models
├── services
├── repositories
└── events
The important question is not:
"How many folders do I have?"
The important question is:
"What is this module allowed to know about another module?"
That is where architecture actually begins.
A Monolith Becomes Dangerous When Boundaries Disappear
A single application is not automatically a bad architecture.
A tightly coupled application is.
Imagine this:
Orders
↓
queries Payments tables directly
↓
JOINs Inventory tables
↓
imports internal User services
↓
updates Notifications data
Everything works because everything can access everything else.
At first, this feels productive.
Eventually, it becomes difficult to change anything.
A developer modifies the orders module and discovers that:
- a payment query depends on an orders table
- an inventory service imports an internal orders class
- a user table is referenced by several other schemas
- a controller contains business logic that another module also uses
- a database change affects unrelated features
The application may still be called a monolith.
But the real problem is not the monolith.
The problem is the lack of boundaries.
Boundaries Matter More Than Processes
One of the easiest mistakes in microservices discussions is confusing deployment boundaries with architectural boundaries.
A microservice gives you a process or deployment boundary.
A modular monolith gives you an application-level boundary.
You can enforce strong boundaries before introducing distributed systems.
For example:
┌───────────────────────────────────────────────┐
│ Application │
│ │
│ ┌───────────┐ ┌───────────┐ │
│ │ Users │ │ Orders │ │
│ │ Module │ │ Module │ │
│ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ │
│ │ Payments │ │ Inventory │ │
│ │ Module │ │ Module │ │
│ └───────────┘ └───────────┘ │
│ │
└───────────────────────────────────────────────┘
The modules live together.
But they should not behave as if there are no boundaries.
That distinction becomes extremely important later.
One Database Does Not Mean One Data Model
A modular monolith can start with a single PostgreSQL server.
That does not mean every module should behave as if all tables belong to one giant application-wide data model.
A useful approach is to separate module ownership at the database level as well.
For example:
PostgreSQL
│
├── users schema
│
├── orders schema
│
├── payments schema
│
├── inventory schema
│
└── system schema
Each module owns its own schema.
That gives us a much stronger boundary than simply creating:
users_table
orders_table
payments_table
inventory_table
inside one shared namespace.
The application can still use one PostgreSQL deployment.
But ownership becomes explicit.
No Cross-Module Joins
This is where things become interesting.
PostgreSQL makes it incredibly easy to write:
SELECT
o.id,
o.total,
p.status
FROM orders.orders o
JOIN payments.payments p
ON p.order_id = o.id;
Technically, there is nothing wrong with the SQL.
Architecturally, however, we need to ask a different question:
Should the Orders module know about the Payments module's database structure?
In a strongly modular architecture, the answer is generally no.
The Orders module should own its own data.
The Payments module should own its own data.
There should be no direct cross-module table access.
This also means avoiding:
Orders → Payments foreign key
Orders → Inventory foreign key
Payments → Users foreign key
when those relationships cross module boundaries.
Instead, the modules communicate through explicit application contracts.
This may feel restrictive.
But the restriction is intentional.
It makes the boundary real.
But Then How Do We Read Data Across Modules?
This is where many people conclude:
"That sounds impossible."
It isn't.
It simply means the system should distinguish between ownership and composition.
Suppose the frontend needs:
{
"orderId": "ord_123",
"customer": {
"name": "Alice"
},
"payment": {
"status": "paid"
},
"items": [...]
}
The API layer may compose this response from multiple module-level query services.
For example:
API
│
┌──────────┼──────────┐
↓ ↓ ↓
OrdersQuery UsersQuery PaymentsQuery
│ │ │
↓ ↓ ↓
orders users payments
schema schema schema
The API knows how to compose the result.
The Orders module does not need to query the Payments tables directly.
This is a subtle but powerful distinction.
CQRS Doesn't Have to Mean Two Databases
CQRS is often presented as:
Command Database
+
Query Database
That is one possible implementation.
It is not the definition of CQRS.
The basic idea is separating the responsibilities of changing state and reading state.
For example:
Command side
CreateOrderCommand
↓
OrderCommandService
↓
orders schema
And:
Query side
GetOrderQuery
↓
OrderQueryService
↓
orders schema
You can introduce this logical separation while still using the same PostgreSQL cluster.
You do not need:
PostgreSQL #1
PostgreSQL #2
ElasticSearch
Kafka
Redis
on day one just to say that you implemented CQRS.
Architecture should solve an actual problem.
Not satisfy a diagram.
Events Should Represent Real Integration Boundaries
Once modules are separated, events become useful.
Suppose an order is created.
The Orders module may produce:
OrderCreated
The Payments module can react to it.
The Notifications module can react to it.
Analytics can react to it.
The important point is that Orders does not need to know the internal implementation of those consumers.
We have:
OrderCreated
│
┌──────────┼──────────┐
↓ ↓ ↓
Payments Notifications Analytics
This creates a much looser coupling than direct method calls between unrelated modules.
But there is another problem.
The Transactional Outbox
Imagine this sequence:
1. Save order
2. Publish OrderCreated
What happens if step 1 succeeds and step 2 fails?
Now the database says:
Order exists
But the event was never published.
Your system has inconsistent state.
A transactional outbox addresses this by writing the business change and the event record in the same database transaction.
Conceptually:
BEGIN TRANSACTION
INSERT order
INSERT outbox_event
COMMIT
Then a separate publisher processes the outbox:
PostgreSQL
│
↓
Outbox
│
↓
Event Publisher
│
↓
Message Broker
│
├── Payments
├── Notifications
└── Analytics
Now the database transaction and event persistence are coupled atomically.
This is one of the techniques that allows a monolith to adopt distributed-system principles without immediately becoming a distributed application.
Why Not Start With Microservices?
Microservices provide legitimate benefits.
Independent deployment can be valuable.
Independent scaling can be valuable.
Technology isolation can be valuable.
Team ownership boundaries can be valuable.
But every service also creates operational and architectural costs.
Suddenly you have:
Service A
Service B
Service C
Service D
Service E
And then:
Service discovery
Networking
Retries
Timeouts
Distributed tracing
Message brokers
Deployment pipelines
Secrets
Observability
Failure handling
Distributed transactions
Schema evolution
Local development complexity
Your application has gone from:
function call
to:
HTTP request
↓
network
↓
service
↓
database
↓
network
↓
another service
That complexity can absolutely be justified.
But it should be justified by a real requirement.
Not by architectural fashion.
Microservices Are an Organizational and Operational Decision Too
There is another dimension developers often ignore.
A microservice architecture changes how teams work.
Suppose you have:
1 developer
Building:
12 microservices
You have not created a Netflix-scale engineering organization.
You have created a lot of infrastructure for one developer.
A modular monolith can provide many of the important architectural benefits while keeping deployment and operations significantly simpler.
This becomes especially useful for startups, solo developers, small engineering teams, and early-stage products.
Designing for Extraction
The most interesting part of the modular-monolith approach is that the future extraction can be considered from day one.
Suppose we have:
Application
│
├── Users
├── Orders
├── Payments
└── Notifications
Later, Payments becomes independently scalable.
Instead of rewriting Payments from scratch, we can extract the existing module:
Before
Application
│
├── Users
├── Orders
├── Payments
└── Notifications
Into:
After
Application
│
├── Users
├── Orders
└── Notifications
Payments Service
The internal module boundaries already existed.
The extraction changes the deployment topology.
The business boundary does not need to be invented at the last minute.
The Real Extraction Strategy
A useful extraction process looks more like this:
1. Identify a strong module boundary
2. Remove direct database access from other modules
3. Define explicit commands, queries, and events
4. Replace direct calls with contracts where appropriate
5. Ensure the module can operate independently
6. Move its database schema/data ownership
7. Introduce network or messaging communication
8. Deploy it independently
The key is that the hard architectural work happens before the service extraction.
This is why modularity matters.
What Should Make You Extract a Service?
There is no universal number.
Not:
"When the codebase reaches 100,000 lines."
Not:
"When we have 20 developers."
Not:
"When the company becomes a startup."
A better question is:
"What problem would extracting this module solve?"
Possible reasons include:
Independent scaling
Different availability requirements
Independent deployment
Different security boundaries
Different operational characteristics
Different technology/runtime requirements
Separate team ownership
Workload isolation
For example:
Payments
might eventually require stronger operational isolation than:
Notifications
That is a concrete architectural reason.
A Modular Monolith Is Not "A Monolith We Promise to Split Later"
This distinction matters.
A bad monolith says:
Everything can access everything.
We'll fix it later.
A modular monolith says:
Everything lives together,
but the boundaries are intentional.
The second approach is fundamentally different.
It is not an incomplete microservices architecture.
It is a legitimate architecture in its own right.
And it can evolve into a distributed architecture when the product actually demands it.
What I Would Optimize For
When starting a new backend, I would optimize for:
Strong module boundaries
↓
Explicit contracts
↓
Clear data ownership
↓
Reliable transactions
↓
Asynchronous integration where useful
↓
Observability
↓
Simple deployment
Only after those fundamentals are working would I start asking:
Should this become a microservice?
That question should come from the system's needs.
Not from a technology checklist.
The Bigger Idea
There is a tendency in software engineering to believe that more infrastructure means more maturity.
More services.
More brokers.
More databases.
More containers.
More Kubernetes.
More distributed systems.
But mature architecture is not about how complicated the diagram looks.
It is about whether the system is designed to handle the complexity it actually has.
A well-designed modular monolith can be:
simple to deploy
+
strongly bounded
+
easy to understand
+
easy to test
+
transactionally consistent
+
event-driven where appropriate
+
ready for future extraction
That is a pretty powerful starting point.
Final Thought
The goal should not be:
"How quickly can I turn my monolith into microservices?"
A better question is:
"How can I design my system today so that I can change its deployment architecture tomorrow without rewriting its business architecture?"
Start with strong boundaries.
Give every module clear ownership.
Treat database access as part of those boundaries.
Use explicit contracts.
Use events where asynchronous communication actually helps.
Keep the operational model simple while the product is simple.
And when a module genuinely needs to become a service, extract it.
Do not build a distributed system just to prove that you know how to build a distributed system.
Build the boundaries first.
Distribute them when reality gives you a reason.
Discussion (2)
Informative blog :)
yes