Architecture Styles
Choosing between Monolith, Modular Monolith, Microservices and event-driven architecture is a trade-off, not a race. Each style solves a specific problem and has a specific cost. For most teams, starting with a Modular Monolith and splitting a service only for a real reason is the safer choice.
Author: bezzad
The problem: “Let’s build ten microservices from the start”
A four-person team wants to build a new online shop. One member says: “Let’s build ten separate microservices from the start: user, product, inventory, order, payment, shipping and the rest. Each one with its own database.”
This idea sounds modern. But to answer it, we first need to know what problem each architecture style solves and what cost it has.
Three main styles
The Monolith style
One app, one deploy, one database. All code is together.
- Benefit. It is the simplest case. One database transaction can place the order and reduce the stock. Either both happen, or neither. Calling a method is also fast and does not go over the network.
- Problem. If there are no borders in the code, over time every part depends on every other part. This is called the “Big Ball of Mud”. A change in one place breaks another place.
The main problem of a Monolith is not “being one app”. The problem is having no borders.
The Modular Monolith style
Still one app and one deploy. But the code is split into separate modules. Each module is one part of the business: catalog, order, inventory.
The rules of a module:
- It has its own tables. For example, in a separate schema in the same database.
- No other module touches its tables directly. Not reading, not writing.
- It has only one public contract. Its other classes are internal.
The result is that today we have the simplicity of a Monolith. If one day a module really needs to be split out, its border is already clear and splitting it is easy.
The Microservices style
Each service is a separate app. It is deployed separately, scaled separately and has its own database.
Microservices mainly solve an organizational problem:
- Imagine five teams work on one app.
- Each team must wait for the others to deploy.
- A bug in one team’s code stops everyone’s deploy.
- With microservices, each team builds and releases its own service independently.
But the technical cost is high:
- There is no shared transaction. Placing the order and reducing the stock are no longer in one transaction. So messaging, Outbox and Saga are needed.
- The network is slow and has errors. Every call may get slow or fail. So Retry, Timeout and Circuit Breaker are needed.
- Finding bugs is hard. One request passes through five services. So distributed tracing is needed.
- Infrastructure work is many times bigger. Ten Pipelines, ten deploys, ten dashboards.
The worst case: Distributed Monolith
Sometimes a team splits the services but keeps the database shared. Or the services depend on each other so much that every change needs several services.
- The order service and the inventory service both use one table.
- The inventory team changes a column. The order service breaks.
- So the two services must always be deployed together.
- This means we have no independence, but there is still a slow, error-prone network between them.
This is called a Distributed Monolith. Its sign is simple: if you cannot deploy one service without the others, you do not have microservices.
Event-Driven architecture
This style is more about the way of communication. You can combine it with a Modular Monolith or Microservices.
In synchronous (sync) communication, the order service calls the others and waits for an answer. In event-driven communication, the order service only announces “the order was placed”. Anyone who needs this news reads it on their own.
Why is a sync chain fragile?
- Imagine each service is healthy 99.9% of the time.
- One request passes through five services, one after another.
- The probabilities multiply. So the total chance of success becomes about 99.5%.
- The longer the chain, the more fragile the system.
But events also have a cost:
- Eventual consistency. It takes a few seconds for all services to learn about the new order.
- Duplicate messages. An event may arrive twice. So every consumer must be idempotent.
- A hidden flow. Nobody sees the whole flow in one place. Understanding “what happens after an order is placed” is harder.
Code: one module in a Modular Monolith
The inventory module has only one public contract. Its implementation and its database are internal.
// Project: Shop.Inventory.Contracts (the only thing other modules reference)
namespace Shop.Inventory.Contracts;
public sealed record ReserveItem(Guid ProductId, int Quantity);
public interface IInventoryApi
{
Task<bool> TryReserveAsync(Guid orderId, IReadOnlyList<ReserveItem> items, CancellationToken ct);
}
// Project: Shop.Inventory (internal details)
namespace Shop.Inventory;
internal sealed class InventoryDbContext(DbContextOptions<InventoryDbContext> options)
: DbContext(options)
{
public DbSet<StockItem> Stock => Set<StockItem>();
protected override void OnModelCreating(ModelBuilder b) =>
b.HasDefaultSchema("inventory"); // this module owns these tables
}
internal sealed class InventoryApi(InventoryDbContext db) : IInventoryApi
{
public async Task<bool> TryReserveAsync(
Guid orderId, IReadOnlyList<ReserveItem> items, CancellationToken ct)
{
// Business rules of the inventory live here, not in the order module.
// ...
await db.SaveChangesAsync(ct);
return true;
}
}
public static class InventoryModule
{
public static IServiceCollection AddInventoryModule(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<InventoryDbContext>(o => o.UseSqlServer(connectionString));
services.AddScoped<IInventoryApi, InventoryApi>();
return services;
}
}
A few points:
- The order module references only the Contracts project. So the compiler does not let it touch InventoryDbContext.
- If one day inventory becomes a separate service, only the implementation of IInventoryApi changes. For example, to an HTTP client or a message. The order module code stays the same.
- So that nobody breaks the borders, write architecture tests. Libraries like NetArchTest make this easy.
When should we split out a service?
Only when we have a real reason:
- Independent teams. We have several separate teams that do not want to wait for each other to deploy.
- Different load. One part must scale separately. For example, product search has a hundred times more load than the rest.
- Different technical needs. One part needs another technology, or must work even when the rest are down.
- Security or law. For example, the payment part must be fully separate in terms of access.
A service border comes from the business border (Bounded Context in DDD), not from database tables. “Product” in the catalog means a name and a photo. In inventory it means a count and a shelf. These are two separate models with two separate owners.
Common mistakes
| Mistake | Result | The right way |
|---|---|---|
| Microservices for a small team and a new product | The team spends its time on infrastructure, not the product. | Start with a Modular Monolith. |
| A shared database between services | Distributed Monolith: all must be deployed together. | Each service owns its own data. |
| Splitting by table or technical layer | Every simple job needs several services. | Split by business area. |
| A Monolith with no borders | Tangled code that nobody dares to change. | Separate modules with a public contract. |
| A long chain of sync calls | One broken service breaks everything. | Events for work that can wait. |
| Events without idempotency | A duplicate message does the work twice. | A duplicate check in every consumer. |
Which style?
Default choice
- The Modular Monolith style for a small or medium team and a new product.
- Clear borders from day one, but one deploy.
- Internal events for work that can wait.
Only with a reason
- The Microservices style when you have several independent teams or a real scale or technical need.
- Being ready for the costs: messaging, tracing, many Pipelines.
- Splitting step by step, one module at a time.
Summary in six lines
- Every architecture style is a trade-off: it solves a problem and has a cost.
- The main problem of a Monolith is having no borders, not being one app.
- The Modular Monolith style combines the simplicity of one app with clear borders.
- Microservices solve an organizational problem and have a high technical cost.
- A shared database between services means a Distributed Monolith, the worst case.
- Sync communication for an instant answer, events for work that can wait.