Skip to content

Modular Monolith

The hard architecture decision isn't monolith vs. microservices. It's when to split. Split too early and you're debugging distributed systems problems before you've validated the business model. Split too late and the monolith is too entangled to separate.

Event sourcing gives you a third path: start as a monolith, evolve into services, without rewriting.

The problem with premature decomposition

Microservices solve scaling and team-autonomy problems. They also introduce network partitions, saga orchestration, distributed tracing, and independent deployment pipelines.

If your team is three people and your product is pre-market-fit, that complexity has no return. You need to iterate fast, not manage a service mesh.

But if you build a traditional monolith with shared database tables and synchronous method calls between modules, you'll hit a wall when it's time to split.

The modular monolith approach

A modular monolith is a single deployable application composed of bounded contexts that communicate through well-defined interfaces. Each bounded context:

  • Owns its own data (no shared tables)
  • Communicates with other contexts through a message broker, not direct method calls
  • Has a clear public API
Single ProcessOrderseventsShippingeventsbrokerbrokerbrokerInventorySame process, broker between contexts

The modules share a process but not a schema. Each module has its own aggregate streams, its own projections, its own event store table.

How Nagare enables this

Each bounded context is a set of aggregates

csharp
// Orders context
builder.Services.AddAggregate<OrderAggregate, OrderCommand, OrderEvent, OrderState>();
builder.Services.AddSqliteEventStore<OrderEvent>("order_events");

// Shipping context
builder.Services.AddAggregate<ShipmentAggregate, ShipmentCommand, ShipmentEvent, ShipmentState>();
builder.Services.AddSqliteEventStore<ShipmentEvent>("shipment_events");

// Inventory context
builder.Services.AddAggregate<InventoryAggregate, InventoryCommand, InventoryEvent, InventoryState>();
builder.Services.AddSqliteEventStore<InventoryEvent>("inventory_events");

Each context has its own event store table, its own aggregate types, its own projections. They share a database connection but not a schema.

Two levels of communication

This is a critical distinction. There are two fundamentally different reasons aggregates communicate, and they use different mechanisms.

Between bounded contexts: always use a message broker

Bounded contexts are autonomous business domains. Orders, Shipping, Inventory — these represent different teams, different deployment units, different reasons to change. Communication between them must go through a durable message broker from day one.

Why? Because these boundaries are where you will eventually split. If you use in-process subscriptions between contexts, you create invisible coupling. When you try to extract a context into its own service, you discover that half its behavior depends on reading another context's event store directly. That's not a boundary — it's a shared database with extra steps.

Enrichment projections emit integration events to the broker:

csharp
// In the Orders context: publish integration events when orders change
public class OrderIntegrationProjection
    : SqlDocumentEnrichmentProjection<OrderEvent, OrderReadModel>
{
    protected override (OrderReadModel, IEnumerable<object>?) Apply(
        OrderReadModel? current, EventEnvelope<OrderEvent> envelope)
    {
        var doc = current ?? new OrderReadModel { Id = envelope.AggregateId.Value };

        return envelope.Event switch
        {
            OrderPlaced e => (
                doc with { Status = "placed", ProductId = e.ProductId },
                new object[] { new OrderPlacedIntegration(
                    envelope.AggregateId.Value, e.ProductId, e.Quantity) }),
            _ => (doc, null)
        };
    }
}

The Inventory context consumes these integration events from the broker:

csharp
// In the Inventory context: react to order integration events from the broker
public class InventoryOrderHandler : IMessageHandler<OrderPlacedIntegration>
{
    public async Task Handle(MessageContext<OrderPlacedIntegration> context)
    {
        var aggregate = await _repo.Load(new AggregateId(context.Message.ProductId));
        await aggregate.Ask(new ReserveStock(context.Message.Quantity));
    }
}

The integration event contract is the boundary between contexts. As long as that contract is maintained, the implementation on each side can change freely. The broker gives you durability, replay, and the ability to split into separate services without changing a line of business logic.

csharp
// Wire up Kafka for cross-context integration events
builder.Services.AddNagareMessaging();
builder.Services.AddNagareKafka(options =>
{
    options.BootstrapServers = "localhost:9092";
});

Within a bounded context: subscriptions coordinate aggregates

Sometimes a single bounded context contains multiple aggregates that need to coordinate. This is different from cross-context communication. These aggregates live in the same domain, share the same event store, and will never be split apart. They use in-process subscriptions — event → command — to stay in sync.

This is uncommon. Most bounded contexts have a single aggregate, and a second aggregate should prompt you to ask: is this actually a separate bounded context? But legitimate cases exist.

Example: Concert ticketing

A ticketing context manages both shopping carts and seat reservations. These are separate aggregates because they have different lifecycles and different consistency boundaries:

  • A CartAggregate tracks what a customer wants to buy. It can be modified freely — add seats, remove seats, change quantities.
  • A SeatReservationAggregate tracks which seats are temporarily held. Reservations have a TTL, enforce capacity limits, and must be atomically released if the cart expires.

Merging these into one aggregate would be wrong: a cart modification shouldn't require locking the seat inventory, and a reservation expiry shouldn't touch the cart. But they must coordinate — when a customer confirms their cart, seats need to be reserved.

csharp
// Two aggregates in the same Ticketing context, same event store
builder.Services.AddAggregate<CartAggregate, CartCommand, TicketingEvent, CartState>();
builder.Services.AddAggregate<SeatReservationAggregate, ReservationCommand, TicketingEvent, ReservationState>();
builder.Services.AddSqliteEventStore<TicketingEvent>("ticketing_events");

A subscription watches for cart events and issues commands to the reservation aggregate:

csharp
public class CartToReservationSubscription : ISubscription<TicketingEvent>
{
    private readonly IAggregateRepository<ReservationCommand> _reservations;

    public CartToReservationSubscription(IAggregateRepository<ReservationCommand> reservations)
    {
        _reservations = reservations;
    }

    public SubscriptionId SubscriptionId => new("cart-to-reservation");
    public Task Prepare() => Task.CompletedTask;

    public async Task Handle(EventEnvelope<TicketingEvent> evt)
    {
        switch (evt.Event)
        {
            case CartConfirmed confirmed:
                // Cart confirmed → reserve the seats
                var reservation = await _reservations.Load(
                    new AggregateId($"reservation-{confirmed.CartId}"));
                await reservation.Ask(new ReserveSeats(
                    confirmed.ConcertId, confirmed.SeatIds, confirmed.CustomerId));
                break;

            case CartAbandoned abandoned:
                // Cart abandoned → release any held seats
                var existing = await _reservations.Load(
                    new AggregateId($"reservation-{abandoned.CartId}"));
                await existing.Ask(new ReleaseSeats(abandoned.CartId));
                break;
        }
    }
}

The event → command flow is:

  1. Customer confirms cart → CartConfirmed event
  2. Subscription picks it up → issues ReserveSeats command
  3. Reservation aggregate validates capacity → emits SeatsReserved or rejects

This is a process manager pattern without the ceremony. The subscription is stateless — it translates events into commands. If it fails, it retries from the checkpoint. If the reservation aggregate rejects (seats no longer available), the rejection propagates and the system can compensate.

When to use this pattern:

  • Two aggregates in the same context have different consistency boundaries
  • One aggregate's state transition should trigger another's
  • The coordination is one-way or simple request-response, not a long-running saga
  • You would never deploy these aggregates separately

When NOT to use this pattern:

  • The aggregates belong to different business domains → use a broker
  • You might split them into separate services someday → use a broker
  • The coordination requires multi-step orchestration with compensation → consider a dedicated process manager

The migration path

When it's time to split, the path is mechanical:

Step 1: Same process, message broker

All contexts run in one application. Within each context, projections and subscriptions read directly from the event store. Across contexts, enrichment projections emit integration events to Kafka. The broker runs locally alongside your app.

One deployable, clean boundaries, durable messaging between contexts.

Step 2: Separate processes

Extract a bounded context into its own service. It connects to its own database, consumes integration events from Kafka, and publishes its own integration events back.

OrdersServiceOwn DBShippingServiceOwn DBKafkaKafkaInventoryServiceOwn DB

Integration event contracts, enrichment projections, aggregates: unchanged. Only the deployment topology changed. The intra-context subscriptions (like cart→reservation) move with the context — they were always internal.

What stays the same

Aggregates, domain events, integration events, projections, tests, and transport all stay identical. Given-When-Then works the same in a monolith and a microservice. Kafka from day one means no transport migration.

The business logic is decoupled from the deployment topology.

What changes

AspectModular MonolithMicroservices
DatabaseShared connection, separate tablesSeparate databases
Between contextsBroker (Kafka)Broker (Kafka) — same
Within contextSubscriptions (event store)Subscriptions (event store) — same
DeploymentSingle artifactIndependent deployments
DebuggingSingle process, stack tracesDistributed tracing

When to split

Stay in the monolith until you feel the pain. Multiple teams stepping on each other's code. A change in Shipping blocks an Orders deployment. One context needs 10x the resources of another. One context needs a different runtime.

If none of those apply, the modular monolith is the right choice.

Design guidelines

  1. Broker between contexts, subscriptions within. This is the fundamental rule. If two aggregates might ever be deployed separately, they communicate through a broker. If they share a bounded context and will always be co-deployed, a subscription is fine.

  2. Keep integration events coarse-grained. They cross context boundaries and become contracts. Make them meaningful business events (OrderPlaced, ShipmentDispatched), not fine-grained state changes (OrderFieldUpdated).

  3. Don't share types across contexts. An Order in the shipping context is not the same type as an Order in the billing context. Each context defines its own view of shared concepts.

  4. Question multiple aggregates in one context. If you're adding a second aggregate to a context, ask whether it's really a separate bounded context. The cart/reservation example is legitimate because they share a domain (ticketing) but have different consistency needs. Two aggregates that never interact probably belong in different contexts.

  5. Test contexts in isolation. Each bounded context should be testable with its own Given-When-Then harness, without starting the other contexts.

  6. Test intra-context subscriptions explicitly. The event → command flow between aggregates is business logic. Write tests that verify: given these events from aggregate A, expect these commands on aggregate B.


Next: Observability

流れ — flow.