The 90% Architecture: From Monoliths to Microservices with Domain Driven Design
In the Beginning
I spent the majority of my career building monolithic web applications. In college, we learned to build apps using N-tier architecture. It organizes your application into horizontal layers. The apps we made were organized like the layers of a cake, with the bottommost layer being contracts (settings/configs, interfaces, POCOs), with infrastructure and data modelling layers on top of that, a sweet business logic layer in the middle and a user interface frosting.
block-beta
columns 1
UI["🎂 User Interface (Frosting)"]
BL["Business Logic"]
INF["Infrastructure & Data"]
CON["Contracts (Foundation)"]
style UI fill:#000000,stroke:#5a9a6e,stroke-width:2px
style BL fill:#000000,stroke:#d4ac0d,stroke-width:2px
style INF fill:#000000,stroke:#c0392b,stroke-width:2px
style CON fill:#000000,stroke:#7f8c8d,stroke-width:2pxEven in your professional careers, many of you are likely familiar with using this architecture. It is a basic, logical way to organize your app. In my first role [working for someone else], we also used this design pattern with a .NET Framework back end and an Angular.js front end.
N-Tier Limitations
This approach to building apps, however, has its obvious limitations. We had some customers who needed to use a different database [for one reason or another]. Every time this happened, we were forced to make changes to the entire infrastructure layer and, because the layers [above that] rely on it, other changes would be required in dependent code (especially when it comes to mapping to/from data models).
Even in a domain-driven design, if a customer were to require a new database integration, additional work would be required, however, the amount and scope of that work would be relatively limited. We will explain why later.
More troubling is a lack of bounded contexts. Bounded contexts are explicit boundaries within a system that isolate concerns (think "features" or "vertical slices"). In mature enterprise N-tier apps, a "softcore" bounded context is enforced using interfaces, DTOs and POCOs. This is an "okay" solution but does not change the fact that [code in this design] cannot be readily split out into a microservice due to everything that [depends on it and vice versa].
The Modular Monolith & DDD
The problems stated above were not presented to argue that [you should just build your app as microservices], rather, that there are obvious problems with N-tier design that CAN be fixed through a different design pattern. That pattern is domain driven design. This article will not describe exactly how to implement this pattern (there are enough guides on that out there), rather it is a case study to explain how building your apps, using this design pattern, will allow you to gracefully evolve your application architectures from monolithic apps to distributed apps, with minimal friction.
The overall goal here is to build your apps in such a way that is extensible - from day one of your idealistic startup (solo-deving a monolithic app) to day one thousand (when you've scaled your team and need to scale your infrastructure).
What is DDD, Really?
Domain Driven Design is a way of modeling your software around your business problems. You define aggregates, entities, value objects, domain events. You speak the same language as your business stakeholders (ubiquitous language). You draw explicit boundaries around distinct areas of your system (bounded contexts).
But DDD doesn't tell you where to put all this stuff. It doesn't tell you how to structure your projects or manage your dependencies. That's where Clean Architecture comes in.
The Onion (Clean Architecture)
If N-tier is a cake, Clean Architecture is an onion. Instead of horizontal layers stacked on top of each other, you have concentric rings. At the center is your domain - the core business logic that has zero dependencies on anything external. It doesn't know about databases. It doesn't know about HTTP. It doesn't know about your ORM. It just knows about your business rules.
flowchart TB
subgraph INF["Infrastructure (outer)"]
subgraph APP["Application"]
subgraph DOM["Domain (core)"]
core["Business Rules"]
end
end
end
style INF fill:#000000,stroke:#c0392b,stroke-width:2px
style APP fill:#000000,stroke:#d4ac0d,stroke-width:2px
style DOM fill:#000000,stroke:#5a9a6e,stroke-width:2px
style core fill:#000000,stroke:#5a9a6e,stroke-width:1pxThe dependency rule is simple: dependencies point inward. Your infrastructure layer (databases, APIs, file systems) depends on your application layer. Your application layer depends on your domain. Your domain depends on nothing.
This is the structure that lets DDD actually work. In N-tier, your business logic depends on your data layer. If you swap databases, the ripple effect travels upward through everything. In Clean Architecture, your data layer depends on abstractions defined by your domain. Swap Postgres for MongoDB? The domain doesn't care. It asked for an IContactRepository. How you implement that is your problem - and only your problem.
DDD gives you the modeling tools. Clean Architecture gives you the structure that protects your domain from infrastructure concerns. They're two sides of the same coin.
Bounded Contexts: Vertical Slices Done Right
Here's where it gets interesting. In a modular monolith, each bounded context (or "module") is essentially a mini Clean Architecture. Each module has its own domain, its own application layer, its own infrastructure. They communicate through well-defined contracts - not by reaching into each other's internals.
flowchart TB
subgraph Geo["Geo Module"]
direction LR
GD[Domain]
GA[Application]
GI[Infrastructure]
end
subgraph Contacts["Contacts Module"]
direction LR
CD[Domain]
CA[Application]
CI[Infrastructure]
end
subgraph Auth["Auth Module"]
direction LR
AD[Domain]
AA[Application]
AI[Infrastructure]
end
Geo <-->|Contracts| Contacts
Contacts <-->|Contracts| AuthIn a monolith, these modules live in the same process. They might communicate through in-memory message dispatching (think MediatR or a simple event bus). The critical part is that they don't share databases and they don't reference each other's internals. Module A doesn't inject Module B's repository. It sends a message or calls a contract.
This discipline feels like overhead when you're a solo dev. It's not. It's an investment.
The 90% That Stays the Same
Here's the payoff. When you build with this pattern, roughly 90% of your code doesn't care how it's deployed:
- Your domain entities don't change. A
Contactis aContactwhether it lives in a monolith or a microservice. - Your application handlers don't change. A
CreateContactHandlerprocesses aCreateContactCommandthe same way regardless of how that command arrived. - Your business rules don't change. Validation logic, domain events, aggregate behavior - all of it stays put.
- Your contracts don't change. The shape of your commands, queries, and DTOs remains stable.
What changes is the plumbing:
| Monolith | Microservices |
|---|---|
| In-memory message dispatch | RabbitMQ / MassTransit |
| Direct method calls via DI | gRPC / REST clients |
| Shared process, isolated modules | Separate deployables |
| Single DB server, database per module | One or more servers, database per service |
The interfaces you defined? They're your extraction seams. IMessageDispatcher in a monolith might be a thin wrapper around MediatR. In a distributed system, it publishes to RabbitMQ. The handler doesn't know. The handler doesn't care.
Microservices: When and Why
Let's be clear: microservices are not the goal. They're a tool. You reach for them when you have problems that a monolith can't solve.
Maybe your auth service gets hammered while your reporting module sits idle and you want to scale them separately. Maybe you have multiple teams stepping on each other's toes and separate repos would help. Maybe you need fault isolation so a bug in one area doesn't bring down everything.
If you don't have these problems, a modular monolith is simpler to operate, debug, and deploy. The point of building extraction-ready is that you don't have to decide upfront. You can split out modules when the pain justifies it - not because some architecture diagram told you to.
The Swap: From Local to Distributed
Let's get concrete. Here's what extraction actually looks like in .NET.
Messaging
Interface:
public interface IMessageDispatcher
{
Task PublishAsync<T>(T message, CancellationToken ct = default);
}Monolith (in-process):
public class LocalMessageDispatcher : IMessageDispatcher
{
private readonly IMediator _mediator;
public async Task PublishAsync<T>(T message, CancellationToken ct)
=> await _mediator.Publish(message, ct);
}Microservices (distributed):
public class DistributedMessageDispatcher : IMessageDispatcher
{
private readonly IPublishEndpoint _publishEndpoint;
public async Task PublishAsync<T>(T message, CancellationToken ct)
=> await _publishEndpoint.Publish(message, ct);
}Your handlers never change. Your DI registration does:
// Monolith
services.AddScoped<IMessageDispatcher, LocalMessageDispatcher>();
// Microservices
services.AddScoped<IMessageDispatcher, DistributedMessageDispatcher>();Service-to-Service Calls
Interface:
public interface IGeoService
{
Task<Country?> GetCountryAsync(string code, CancellationToken ct);
}Monolith (direct injection):
public class LocalGeoService : IGeoService
{
private readonly IQueryHandler<GetCountryQuery, Country?> _handler;
public async Task<Country?> GetCountryAsync(string code, CancellationToken ct)
=> await _handler.HandleAsync(new GetCountryQuery(code), ct);
}Microservices (gRPC):
public class RemoteGeoService : IGeoService
{
private readonly GeoService.GeoServiceClient _client;
public async Task<Country?> GetCountryAsync(string code, CancellationToken ct)
{
var response = await _client.GetCountryAsync(
new GetCountryRequest { Code = code }, cancellationToken: ct);
return response.MapToDomain();
}
}The consuming code doesn't change. It asked for IGeoService. It got one.
The Discipline Tax
I won't pretend this is free. Building extraction-ready requires discipline that feels unnecessary when you're small:
- No cross-module database joins. You fetch data through contracts, not SQL.
- Explicit boundaries. You can't just inject whatever you want from another module.
- Contract versioning. When you change a message shape, you think about compatibility.
- More interfaces. Everything meaningful is behind an abstraction.
This is the tax. You pay it upfront in ceremony. You collect the dividend later in flexibility.
Closing Thoughts
The best architecture is one that doesn't paint you into a corner. N-tier apps grow into big balls of mud because the boundaries are soft. Dependencies creep. "Just this once" becomes "just always."
DDD with Clean Architecture gives you hard boundaries. Bounded contexts in a modular monolith give you isolation. Interfaces and dependency injection give you seams.
Build the monolith. Respect the boundaries. When the day comes that you need to extract a service - and it might never come - you'll find that 90% of your code doesn't know the difference.
I've built both. DeCAF was a modular monolith. D²-WORX is its distributed successor. The extraction path works. I've walked it.
Comments (2)
No comments yet. Be the first to comment!