Levelwise
English
Architecture and system design

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.

Not reviewedWritten with AI helpReading time: 16 minOnline shop example with a four-person teamC# and .NET 10 code

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

MonolithModular MonolithMicroservicesOne app, tangled codeCatalogOrdersStockOne app, clear bordersCatalogOrdersStockTalk only over the networkMany apps, many databases
From right to left: apps become more separate, but the cost of communication between them also grows.

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.

One app and one releaseOrder modulePlaceOrderHandlerschema: ordersInventory moduleIInventoryApiPublic contractschema: inventoryOK: via contractBanned: its tables
The order module talks to inventory only through the public contract. The inventory tables belong to inventory itself.

The rules of a module:

  1. It has its own tables. For example, in a separate schema in the same database.
  2. No other module touches its tables directly. Not reading, not writing.
  3. 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:

  1. Imagine five teams work on one app.
  2. Each team must wait for the others to deploy.
  3. A bug in one team’s code stops everyone’s deploy.
  4. 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.
So the answer to the first question: A four-person team does not have the organizational problem of many teams. So with ten microservices it only pays the cost and gets no benefit. For this team, a Modular Monolith is a better choice.

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.

Worst case: the cost of both, the benefit of neitherCatalog serviceOrder serviceInventory serviceOne shared databaseOne column change breaks allSo all must be released togetherBut a slow, error-prone network is still between them
Separate services with one shared database: all the costs of microservices, with none of the benefits.
  1. The order service and the inventory service both use one table.
  2. The inventory team changes a column. The order service breaks.
  3. So the two services must always be deployed together.
  4. 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.

Sync chainOrderEmailLoyalty pointsReportOne service went down, so placing the order failed tooEvent-drivenOrderOrderPlacedEmailLoyalty pointsReportOrder placedReads later
In a sync chain, one broken service breaks placing the order. With an event, the order is placed and the broken service reads the event later.

Why is a sync chain fragile?

  1. Imagine each service is healthy 99.9% of the time.
  2. One request passes through five services, one after another.
  3. The probabilities multiply. So the total chance of success becomes about 99.5%.
  4. 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.
A simple rule: If you need the answer right now, use sync communication. For example, the payment page must know the final price right now. If the work can happen a few seconds later, use an event. For example, the order confirmation email.

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:

  1. Independent teams. We have several separate teams that do not want to wait for each other to deploy.
  2. Different load. One part must scale separately. For example, product search has a hundred times more load than the rest.
  3. Different technical needs. One part needs another technology, or must work even when the rest are down.
  4. 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

  1. Every architecture style is a trade-off: it solves a problem and has a cost.
  2. The main problem of a Monolith is having no borders, not being one app.
  3. The Modular Monolith style combines the simplicity of one app with clear borders.
  4. Microservices solve an organizational problem and have a high technical cost.
  5. A shared database between services means a Distributed Monolith, the worst case.
  6. Sync communication for an instant answer, events for work that can wait.