Monolith, Modular Monolith, Microservices
Key Points
- Monolith = one deployable, one process, one database. Default for new projects. Cheapest to build, deploy, and reason about.
- Modular monolith = one deployable, but the codebase is split into bounded modules with enforced boundaries (no cross-module DB access; communication via in-process contracts). The pragmatic middle ground.
- Microservices = many deployables, many processes, ideally one database per service. Pays a heavy distributed-systems tax (network, observability, data consistency) for independent deployability and scaling.
- The senior question is never "monolith or microservices?" It's "what cost am I willing to pay for what scaling/team property?" If you can't answer that, pick monolith.
- Migration path: monolith → modular monolith → extract the one or two modules that actually need independent scaling. Almost never the other direction.
- Common failure mode: jumping to microservices before discovering bounded contexts. You end up with a distributed monolith — all of the network cost, none of the independence.
Concepts (deep dive)
The shape of each style
Monolith — one process, one DB:
┌──────────────────────────────────┐
│ MyApp.exe │
│ ┌────────┐ ┌──────────┐ ┌────┐ │
│ │ Orders │ │ Customers│ │ ...│ │
│ └────────┘ └──────────┘ └────┘ │
│ ↓ ↓ ↓ │
│ ┌──────────────────┐ │
│ │ AppDb (single) │ │
│ └──────────────────┘ │
└──────────────────────────────────┘
Modular monolith — still one process, but modules don't reach into each other's data:
┌──────────────────────────────────────────────────────┐
│ MyApp.exe │
│ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
│ │ Orders │ ←──│ Customers│ ←─│ Notifications │ │
│ │ module │ │ module │ │ module │ │
│ └────┬────┘ └────┬─────┘ └──────┬──────────┘ │
│ │ │ │ │
│ [Orders schema] [Customers schema] [Notif schema] │
│ (same DB, (own tables; (own tables) │
│ own tables) no FK across) │
└──────────────────────────────────────────────────────┘
Cross-module calls use module-published interfaces,
not direct repository or DbContext access.
Microservices — many processes, many DBs, network between:
┌────────────┐ ┌────────────┐ ┌──────────────────┐
│ Orders svc │←─►│ Customers │←─►│ Notifications svc│
│ (.exe) │ │ svc (.exe)│ │ (.exe) │
└────┬───────┘ └────┬───────┘ └──────┬───────────┘
│ │ │
[Orders DB] [Customers DB] [Notif DB]
▲ ▲ ▲
└──────── HTTP / gRPC / queues ─────┘
+ service discovery, distributed tracing,
retries, circuit breakers, sagas, ...
The visible difference is "how many boxes." The invisible difference — the one that breaks projects — is the network between them: timeouts, retries, partial failure, data consistency, observability, deployment ordering.
Monolith: what people get wrong about it
The pejorative use of "monolith" is mostly a 2014-era artifact from the Netflix/Spotify cloud-native era. A monolith isn't a code-quality verdict — it's a deployment shape: the unit of deploy is the whole app.
A well-built monolith has:
- Internal module boundaries — folders or projects, with clear ownership.
- A shared DB, often with schema-per-module separation, accessed via repositories or
DbContexts scoped to each module. - In-process function calls between modules — no network, no serialization, no retry policy.
- One deployment, one config, one log stream, one trace span tree.
What's actually bad ("the big ball of mud") is a monolith without boundaries — anyone can call anything from anywhere, every change risks every feature. That's a code-quality problem, not a deployment-shape problem. The fix is to add internal modules. Extracting services first is harder, slower, and more expensive.
💡 Rule of thumb: if your monolith hurts because of code coupling, a modular monolith fixes it. If it hurts because one part of the system has a different scaling profile or release cadence than the rest, that's the case for extracting that one part into a service.
Modular monolith: the pragmatic middle
Modular monolith = a monolith with enforced internal boundaries, designed so that a future service extraction is mechanical, not heroic.
What "enforced" means in practice:
- Project structure reflects bounded contexts:
src/
├── MyApp.Host/ ← composition root, Program.cs
├── Modules/
│ ├── Orders/
│ │ ├── MyApp.Orders.Api/ ← endpoints (public)
│ │ ├── MyApp.Orders.Application/← handlers, use cases
│ │ ├── MyApp.Orders.Domain/ ← aggregates, value objects
│ │ ├── MyApp.Orders.Infrastructure/ ← DbContext, EF mappings
│ │ └── MyApp.Orders.Contracts/ ← published types other modules may consume
│ ├── Customers/
│ │ └── ...
│ └── Notifications/
└── Shared/
└── MyApp.SharedKernel/ ← only truly shared primitives
- Each module owns its schema.
OrdersDbContextonly knowsOrders.*tables;CustomersDbContextonly knowsCustomers.*. No cross-schema foreign keys. - Cross-module communication goes through
Contracts/— published interfaces, MediatR notifications, or in-process events. Direct access to another module'sDbContextis a build break (enforced by project references orArchUnitNETtests). - One database is fine. "One DB per module" is a microservices-era idea that costs you transactional consistency. A single DB with schema separation gets you 90% of the isolation for 10% of the operational cost.
Why this is the default recommendation in 2026:
- You usually don't know the bounded contexts on day one. Module boundaries inside one repo are cheap to move. Service boundaries are not.
- In-process is fast. A function call is ~10ns; a localhost HTTP call is ~500µs; a cross-AZ call is ~1ms. Don't pay 100,000× for a boundary you're not sure of.
- You can extract later. If
Notificationsgenuinely needs to scale independently or be owned by a different team, liftMyApp.Notifications.*projects into their own repo and put HTTP/queue plumbing where the in-processIMediatorcall used to be. The hard work (the boundary) was already done.
Microservices: the real cost ledger
The microservices marketing talks about independence; the engineering reality is a long list of new problems you didn't have before. A non-exhaustive ledger:
| Problem you now own | Monolith answer | Microservices answer |
|---|---|---|
| Transactions across modules | One DB transaction | Sagas, Outbox |
| Calling another module | Method call | HTTP/gRPC + retries + timeouts + circuit breaker |
| Tracing a request | Stack trace | Distributed tracing (OTel + propagation) |
| Deploying a change | One deploy | Service version compatibility matrix |
| Local dev | Run one app | Run N apps, or stub them |
| "Where is the bug" | One log stream | Correlate across N log streams |
| Data consistency | ACID | Eventual; idempotency keys; reconciliation |
| Schema change | Migration | Backward-compatible event/contract evolution |
| Network failure | Doesn't exist | Default. Plan for it. |
This is the distributed-systems tax. You pay it on every request. It buys you:
- Independent deploy — each service ships on its own cadence.
- Independent scale — scale only the hot service.
- Independent tech — write
recommendationsin Python without affectingorders. - Team boundaries — Conway's Law applied deliberately.
Pay the tax when the benefit clears it. Don't pay it because the conference talks said to.
The distributed monolith anti-pattern
The most common failed microservices migration looks like this:
- Five services were extracted from a monolith.
- They still all read/write the same shared database.
- A user request now hits three services synchronously; if any is down, the request fails.
- A "small" feature change requires coordinated deploys of two or three services.
- Tests can only run end-to-end; nothing runs in isolation.
This is a distributed monolith: all the operational cost of microservices, none of the independence. The fix is to either re-merge or do the boundaries properly (separate DBs, async communication, eventual consistency). Both are expensive.
The way to avoid it is to extract one service at a time, only after the module boundary has held inside a modular monolith for months, and only when there's a specific reason (scale, team ownership, regulatory) to extract.
Choosing the right boundary
Three questions, in order:
- Is there a different scaling profile? ML inference, image processing, and stream ingestion need different hardware than the CRUD app. → Service candidate.
- Is there a different release cadence? The risk-scoring engine needs daily model refreshes; the rest of the app deploys monthly. → Service candidate.
- Is there a different team / accountability boundary? The payments team can't be blocked on the orders team's deploys. → Service candidate.
If the answer to all three is "no," the right boundary is a module, not a service.
Decision matrix
| Situation | Pick |
|---|---|
| Greenfield, 1-5 engineers, requirements still moving | Monolith |
| Greenfield, 5-20 engineers, multiple clear bounded contexts | Modular monolith |
| Modular monolith with one hot module needing independent scale | Extract that one module to a service |
| Multiple teams with autonomous release cadence | Microservices (carefully) |
| You can't articulate the bounded contexts | Don't go to services yet |
| Cloud team wants "containers" | Containerize the monolith — that's a deploy concern, not an architecture one |
Where this fits with other patterns
- Vertical Slice Architecture is orthogonal — you can run vertical slices inside a monolith, modular monolith, or service.
- DDD tactical patterns apply equally well in any of the three; bounded contexts become projects (modular monolith) or services (microservices).
- CQRS is a per-module choice; it doesn't require microservices.
- Clean / Onion / Hexagonal is layering inside a module — independent of deployment shape.
How it works under the hood
In-process call vs network call: the latency budget
in-process method call: ~10 ns
local function (different DLL):~50 ns
localhost HTTP/JSON: ~300-800 µs
same-AZ HTTP/JSON: ~1-2 ms
cross-AZ HTTP/JSON: ~1-5 ms
cross-region HTTP/JSON: ~30-150 ms
A monolith making 50 internal calls per request adds ~500 ns. A microservices system making 5 cross-service calls per request adds ~5-25 ms — and that's the happy path, no retries. This is why "let's microservice everything" silently murders user-perceived latency.
Transactional consistency across boundaries
A monolith holds a single DbContext transaction across all modules touched by one request — BEGIN; INSERT order; UPDATE inventory; INSERT outbox_event; COMMIT; is atomic.
In microservices, the same operation is three separate transactions in three separate databases. You can't BEGIN; ...; COMMIT; across them. Options:
- Saga / process manager (sagas) — orchestrate compensating actions on failure.
- Outbox pattern (outbox & inbox) — atomically write business state + the event to publish; a relay reads from the outbox.
- Eventual consistency — accept that "inventory reserved" and "order placed" converge over seconds, not instantly. Design the UX accordingly.
None of these are free. All of them are simpler if you don't need them, which is the case in a monolith.
Deployment unit and blast radius
The single biggest operational difference: in a monolith, one bad deploy breaks everything. In microservices, one bad deploy breaks one service — but cross-service contract breaks affect every consumer of that service. Independent deploy is real, but contract compatibility becomes a first-class concern (consumer-driven contract tests, contract versioning, dual-publish during transitions).
Why "DB per service" is non-negotiable for real microservices
If two services share a DB, they share a schema, which means a schema migration breaks both, which means they have to deploy together, which means they aren't independent. Sharing a database silently re-couples services back into a monolith — usually a worse one, because the database is now also a coordination bottleneck. In a modular monolith, sharing a DB across modules is fine (the deploy is one anyway). In a service-oriented split, it isn't.
Code: correct vs wrong
❌ Wrong: "modular monolith" with cross-module DB access
// In Notifications module:
public class NotificationsService(OrdersDbContext ordersDb) // ← reads another module's DbContext
{
public async Task NotifyOnOrderPlaced()
{
var orders = await ordersDb.Orders.Where(...).ToListAsync(); // ← coupling
// ...
}
}
This is a monolith pretending to be modular. The boundary doesn't exist — any schema change in Orders breaks Notifications.
✅ Correct: modular monolith with in-process contract
// MyApp.Orders.Contracts (the only Orders project Notifications references)
public record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total) : INotification;
// In Orders module, after persisting:
await _mediator.Publish(new OrderPlaced(order.Id, order.CustomerId, order.Total), ct);
// In Notifications module:
public class OnOrderPlaced(INotificationSender sender) : INotificationHandler<OrderPlaced>
{
public Task Handle(OrderPlaced e, CancellationToken ct)
=> sender.SendAsync(e.CustomerId, "Your order was placed", ct);
}
The cross-module dependency is now Contracts — a small, stable surface. Extracting Notifications to a service later becomes "swap IMediator.Publish for a queue producer."
❌ Wrong: synchronous chain across services
// OrderService:
var customer = await customersHttp.GetCustomer(id); // hop 1
var pricing = await pricingHttp.GetPricing(customer.Tier); // hop 2
var inventory = await inventoryHttp.Reserve(items); // hop 3
await ordersDb.SaveAsync(order);
Three sequential network calls, each a failure mode. Cumulative p99 latency = sum of p99s. One service down = the whole request fails.
✅ Correct: async events + idempotent consumers
// OrderService writes order + outbox event in one transaction:
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Orders.Add(order);
db.OutboxEvents.Add(new OrderPlaced(order.Id, ...).ToOutbox());
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// Pricing, Inventory, Notifications each consume OrderPlaced independently.
// Each is idempotent (deduplicates by OrderId).
See outbox & inbox patterns and idempotency keys.
❌ Wrong: extracting a service before the boundary holds
Sprint 3: "Let's extract Payments into its own service."
Sprint 6: Payments service still reads Customers.Profile via shared DB.
Every Customers schema change needs a coordinated Payments deploy.
Local dev requires running 4 services + a message broker.
You haven't created a service; you've created a remote dependency on a coupled module.
✅ Correct: extract only after the in-process module is stable
The module has had its own contract surface for months, no cross-module DB reads, its own tests run in isolation, and there's a specific reason to extract (scale profile, team boundary, regulatory). The extraction is then mostly mechanical.
Design patterns for this topic
Pattern 1 — "Start with a monolith"
- Intent: lowest total cost when requirements are still being discovered.
Pattern 2 — "Modular monolith with module-owned schemas"
- Intent: boundaries inside a single deploy; cheap to refactor, cheap to extract later.
Pattern 3 — "Contracts project per module"
- Intent: publish the minimum surface other modules consume; no
DomainorInfrastructurereferences across modules.
Pattern 4 — "In-process events via INotification"
- Intent: decouple modules at runtime; same code path swaps to a queue when extracted.
Pattern 5 — "Extract one service for one reason"
- Intent: never extract a service "for cleanliness" — only for a measurable property (scale, cadence, ownership).
Pattern 6 — "Containerize the monolith first"
- Intent: if cloud-native is the goal, package one container, don't split.
Pros & cons / trade-offs
| Style | Pros | Cons |
|---|---|---|
| Monolith | Cheapest to build/operate; ACID transactions; one log/trace tree; in-process speed | One deploy = whole app; coupling risk without internal modules |
| Modular monolith | Module boundaries; one DB transaction; future service extraction is mechanical; great team-of-teams default | Discipline to enforce boundaries; some upfront project structure |
| Microservices | Independent deploy/scale/tech; team autonomy; per-service failure isolation | Distributed-systems tax (latency, consistency, observability, ops); contract evolution; "where is the bug?" |
| Distributed monolith | (none) | All microservices costs, none of the benefits |
When to use / when to avoid
- Use a monolith when you can: small team, requirements still moving, no scaling pressure on a specific subsystem.
- Use a modular monolith when the team or codebase has grown past one-person-knows-it-all but you still don't have measurable reasons to pay distributed costs.
- Use microservices when a specific subsystem has independent scale, cadence, or ownership requirements — and only for that subsystem.
- Avoid microservices as the day-one architecture for a new product.
- Avoid "DB per service" without the modular monolith step — you'll discover bounded contexts the hard way.
- Avoid extracting more than one service at a time — too many simultaneous unknowns.
Interview Q&A
Q1. What's the difference between a monolith and a modular monolith? Both are a single deploy and one process. The modular monolith adds enforced internal boundaries: module-owned schemas, contracts projects, no cross-module DB access, in-process events instead of direct references. The deployment shape is identical; the code coupling is not.
Q2. Why isn't "more services" automatically better? Every service boundary is a network hop, a failure mode, a contract to evolve, a deployment to coordinate, and a trace span to correlate. Independence is a benefit you buy with that cost. Without a specific reason to buy it — scale, cadence, ownership — you're paying for nothing.
Q3. When should you extract a service from a monolith? When the subsystem has measurably different scaling needs, release cadence, or team ownership — and ideally only after the boundary has lived as a module for months without leaking.
Q4. What's a distributed monolith? Services that share a database, deploy together, and fail together. All the cost of microservices, none of the independence. Usually a result of extracting too early.
Q5. Can microservices share a database? For modules in a modular monolith, yes — schema-per-module is fine. For services that claim independence, no — sharing a DB silently re-couples them; a schema change forces coordinated deploys.
Q6. How do transactions work across services? They don't, in the ACID sense. You use sagas (orchestrated or choreographed compensating actions), the outbox pattern, or accept eventual consistency. All are simpler if you don't need them.
Q7. What's the cost of a network call vs an in-process call? Roughly six orders of magnitude. ~10 ns for a method call vs ~1 ms same-AZ HTTP. This is why "split everything into services" silently destroys p99 latency.
Q8. How do you decide a module boundary? Bounded contexts from DDD: where does the ubiquitous language change? Order in the sales context and Order in the fulfillment context are different aggregates — that's a boundary signal.
Q9. Should microservices use one tech stack or many? "Polyglot" is a benefit of microservices, but also a cost — every stack adds CI, deploy, observability, and security overhead. Most senior teams keep a primary stack (.NET or Go) and reach for another language only when the workload requires it.
Q10. What about the team Conway's-Law argument? Real. If you have three teams shipping independently, three services often reflects the org. But the reverse — adopting microservices to cause team independence — usually fails: the teams have to figure out service boundaries while also learning distributed systems while also delivering features.
Q11. Containers and Kubernetes — does that mean microservices? No. Containers are a deployment packaging concern. A containerized monolith is a perfectly valid 2026 architecture and a common cloud migration target. K8s runs whatever you give it.
Q12. What's the migration order if I have to refactor a big-ball-of-mud monolith? Add modules first (folders, then projects, then schema separation). Get the boundaries stable. Then consider extracting the one or two modules with the strongest "we need independence" case. Never the other way around.
Gotchas / common mistakes
- ⚠️ "Microservices for greenfield" — you don't know the bounded contexts yet. You'll re-draw them three times. Each redraw is a service rewrite, not a refactor.
- ⚠️ "Modular monolith" without enforcement — folders alone aren't a boundary. Use project references, schema separation, and
ArchUnitNET-style tests. - ⚠️ Shared
SharedKernelthat grows — once everyone depends onSharedKernel, you have a god module. Keep it tiny (truly cross-cutting primitives; not domain). - ⚠️ Synchronous chains of services — every hop multiplies failure probability. Prefer async events + idempotent consumers.
- ⚠️ Premature
OrderIdconsistency — synchronous reads from another service "to enrich the response" recreate the distributed-monolith pattern. Cache or denormalize. - ⚠️ Container = service — packaging an existing monolith in Docker doesn't make it microservices. It's still a monolith; you just changed the runtime.
- ⚠️ One service per team, even if it doesn't deserve a boundary — organisationally tidy, technically a distributed monolith.