02.07.2026 • 15 min read

Modular Monolith with Event-Driven Architechture for MVP to Scale

Cover Image

Introduction 🎯

In the modern backend landscape, the debate between the monolith and microservices is often framed as a binary choice. Developers frequently jump straight to microservices to support future scale, only to find themselves drowning in network latency, distributed transactions, and operational complexity. Conversely, a naive monolith quickly decays into a “big ball of mud”—a spaghettified codebase where touching one line of code breaks five unrelated features.

There is a better way: the Modular Monolith.

By designing your application with strict module boundaries and decoupling them using Event-Driven Architecture (EDA), you get the best of both worlds: local development simplicity, strong compile-time boundaries, and the flexibility to split modules into independent microservices later with minimal rewrite.

In this guide, we’ll build a modular monolith using Express.js (v5 - TypeScript). We will walk through an evolutionary architecture path, starting from a lightweight in-memory event bus, transitioning to Redis Streams, and planning for enterprise message brokers like RabbitMQ or Kafka.


Part 1: Architectural Foundations (DDD & Lite CQRS)

Before writing code, we must establish our structural rules. A Modular Monolith is only modular if we enforce boundaries.

1.1 Strict Module Boundaries

A modular monolith divides the system into logical domains (e.g., Users, Orders, Notifications). Every module owns its database tables, business logic, and entry points.

flowchart TD
    subgraph Client["Client Interface"]
        API["HTTP Client / frontend"]
    end

    subgraph Monolith["Modular Monolith Boundary"]
        Entry["Express.js App Router (v5)"]
        
        subgraph OM["Order Module"]
            OR["Order Controller"] --> OS["Order Service"]
            OS --> ODB[("Order Database Tables")]
        end

        subgraph NM["Notification Module"]
            NS["Notification Consumer"] --> NService["Email/SMS Service"]
        end
        
        subgraph EB["Event Bus Interface"]
            Bus["Event Bus (Implementation Agnostic)"]
        end
    end

    API --> Entry
    Entry --> OR
    OS -- "Publishes 'OrderCreated'" --> Bus
    Bus -- "Dispatches to Subscribers" --> NS
    
    style Monolith fill:#1e1e2e,stroke:#cdd6f4,stroke-width:2px
    style OM fill:#313244,stroke:#a6adc8,stroke-width:1px
    style NM fill:#313244,stroke:#a6adc8,stroke-width:1px
    style EB fill:#45475a,stroke:#f38ba8,stroke-width:1px
  • Rule #1: No Cross-Database Joins. The Order Module cannot perform an SQL JOIN on tables owned by the User Module.
  • Rule #2: Direct Calls are Read-Only (Optional). If the Order Module needs to fetch a user’s status, it can query the User Module’s public TypeScript interface directly. However, it should never modify another module’s state directly.
  • Rule #3: Side Effects are Asynchronous (EDA). When an order is created, the Order Module does not call the Notification Module. Instead, it publishes an OrderCreated event. The Notification Module subscribes to this event and handles it asynchronously.

1.2 “Lite” CQRS & Domain-Driven Design (DDD)

We don’t need the full weight of separate read/write databases to benefit from CQRS (Command Query Responsibility Segregation). In a “lite” implementation:

  • Commands (Writes): Encapsulate state changes (e.g., PlaceOrderCommand). They run via services that validate invariants, update the database, and publish domain events.
  • Queries (Reads): Fetch data optimized for the UI. They bypass complex domain logic, querying tables directly to keep read operations lightning fast.

Let’s look at the directory structure of our modular project:

src/
├── app.ts                  # Express.js v5 Entry
├── bus/                    # Shared Event Bus Interface & Implementations
   ├── event-bus.interface.ts
   ├── in-memory.bus.ts
   └── redis-stream.bus.ts
└── modules/
    ├── orders/             # Order Domain Boundary
   ├── orders.controller.ts
   ├── orders.service.ts
   ├── orders.entity.ts
   └── events.ts       # Order Domain Events
    └── notifications/      # Notification Domain Boundary
        ├── notifications.consumer.ts
        └── email.service.ts

Part 2: Express.js v5 & TypeScript Module Boundaries

Express.js v5 simplifies route handlers by natively handling rejected promises (unhandled async exceptions) without needing custom wrapper middleware or repetitive try/catch blocks.

First, let’s define our event interface structure inside src/bus/event-bus.interface.ts:

// src/bus/event-bus.interface.ts
export interface EventEnvelope<T = any> {
  id: string;
  eventName: string;
  timestamp: Date;
  payload: T;
  correlationId?: string;
}

export type EventHandler<T = any> = (event: EventEnvelope<T>) => Promise<void> | void;

export interface IEventBus {
  publish<T>(eventName: string, payload: T, correlationId?: string): Promise<void>;
  subscribe<T>(eventName: string, handler: EventHandler<T>): Promise<void>;
  start?(): Promise<void>;
}

Now, let’s write our OrderCreated event payload and domain definition inside src/modules/orders/events.ts:

// src/modules/orders/events.ts
export const ORDER_EVENTS = {
  CREATED: "order.created",
  CANCELLED: "order.cancelled",
} as const;

export interface OrderCreatedPayload {
  orderId: string;
  customerId: string;
  totalAmount: number;
  items: Array<{ productId: string; quantity: number; price: number }>;
}

Let’s construct the orders.service.ts to implement our order creation command using Express v5 style async programming:

// src/modules/orders/orders.service.ts
import crypto from "crypto";
import { IEventBus } from "../../bus/event-bus.interface";
import { ORDER_EVENTS, OrderCreatedPayload } from "./events";

export interface CreateOrderDto {
  customerId: string;
  items: Array<{ productId: string; quantity: number; price: number }>;
}

export class OrdersService {
  constructor(private readonly eventBus: IEventBus) {}

  async createOrder(dto: CreateOrderDto) {
    // 1. Business Validation & Logic (DDD)
    if (dto.items.length === 0) {
      throw new Error("Cannot place an order with zero items");
    }

    const totalAmount = dto.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    const orderId = crypto.randomUUID();

    const newOrder = {
      id: orderId,
      customerId: dto.customerId,
      items: dto.items,
      totalAmount,
      status: "PENDING",
      createdAt: new Date(),
    };

    // 2. Persist to DB (Mocked here)
    console.log(`[Order Service] Order ${orderId} saved to database.`);

    // 3. Publish event asynchronously to decouple inventory/notifications
    const correlationId = crypto.randomUUID();
    await this.eventBus.publish<OrderCreatedPayload>(
      ORDER_EVENTS.CREATED,
      {
        orderId,
        customerId: dto.customerId,
        totalAmount,
        items: dto.items,
      },
      correlationId
    );

    return newOrder;
  }
}

Here’s the controller definition using Express.js v5 where async errors are natively caught by the main Express error boundary:

// src/modules/orders/orders.controller.ts
import { Router, Request, Response } from "express";
import { OrdersService } from "./orders.service";

export function createOrdersRouter(ordersService: OrdersService): Router {
  const router = Router();

  // POST /orders - Natively supports async errors in Express v5!
  router.post("/", async (req: Request, res: Response) => {
    const { customerId, items } = req.body;
    
    const order = await ordersService.createOrder({
      customerId,
      items,
    });

    res.status(201).json({
      success: true,
      data: order,
    });
  });

  return router;
}

Part 3: Reliable Event Publishing with the Transactional Outbox Pattern

In a naive Event-Driven implementation, developers often publish events directly from their service classes right after committing a database transaction. For example:

await db.save(order);
await eventBus.publish("order.created", payload);

This creates the Dual Write Problem. If the database write succeeds but the network blips while calling the message broker, the event is lost forever (inconsistent state). If we publish the event inside the transaction but the transaction fails to commit, our consumers process an event for an order that doesn’t exist.

To achieve At-Least-Once Delivery, we must use the Transactional Outbox Pattern.

3.1 How the Transactional Outbox Works

Instead of publishing the event directly, we write both the entity change and the event payload into the same database using a single ACID transaction. A separate, independent process (the Outbox Relay) polls the database for unprocessed events, publishes them to our event bus, and marks them as dispatched.

sequenceDiagram
    autonumber
    actor Client
    participant Controller as Order Controller
    participant Service as Order Service
    participant DB as "Database (ACID)"
    participant Relay as "Outbox Relay"
    participant Bus as "Event Bus"

    Client->>Controller: POST /orders
    Controller->>Service: createOrder(dto)
    Note over Service, DB: Start SQL Transaction
    Service->>DB: INSERT INTO orders (Order Data)
    Service->>DB: INSERT INTO outbox (Event Envelope)
    Note over Service, DB: Commit SQL Transaction
    Service-->>Controller: Return Order response
    Controller-->>Client: 201 Created

    loop Every 500ms
        Relay->>DB: SELECT * FROM outbox WHERE processed = false
        DB-->>Relay: Return unprocessed events
        loop For each event
            Relay->>Bus: publish(event)
            Bus-->>Relay: Publish Confirmed
            Relay->>DB: UPDATE outbox SET processed = true WHERE id = event.id
        end
    end

3.2 Implementing the Outbox in Express.js v5

Let’s define the interface for our outbox database records:

// src/bus/outbox.interface.ts
export interface OutboxRecord {
  id: string;
  eventName: string;
  payload: string; // JSON string
  correlationId?: string;
  processed: boolean;
  createdAt: Date;
  processedAt?: Date;
}

Now, let’s update our OrdersService to write the event to the outbox table as part of a database transaction. We will mock the transactional db client for demonstration:

// src/modules/orders/orders.service.ts (Outbox Version)
import crypto from "crypto";
import { ORDER_EVENTS, OrderCreatedPayload } from "./events";

export class OrdersService {
  async createOrder(dto: CreateOrderDto) {
    if (dto.items.length === 0) {
      throw new Error("Cannot place an order with zero items");
    }

    const totalAmount = dto.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    const orderId = crypto.randomUUID();

    const newOrder = {
      id: orderId,
      customerId: dto.customerId,
      items: dto.items,
      totalAmount,
      status: "PENDING",
      createdAt: new Date(),
    };

    // Begin ACID transaction
    const dbTransaction = await mockDb.beginTransaction();
    try {
      // 1. Write business entity
      await dbTransaction.query(
        "INSERT INTO orders (id, customer_id, total_amount) VALUES (?, ?, ?)",
        [newOrder.id, newOrder.customerId, newOrder.totalAmount]
      );

      // 2. Write event to the outbox in the same transaction
      const eventEnvelope = {
        id: crypto.randomUUID(),
        eventName: ORDER_EVENTS.CREATED,
        payload: JSON.stringify({
          orderId,
          customerId: dto.customerId,
          totalAmount,
          items: dto.items,
        }),
        correlationId: crypto.randomUUID(),
        processed: false,
        createdAt: new Date()
      };

      await dbTransaction.query(
        "INSERT INTO outbox_events (id, event_name, payload, correlation_id, processed, created_at) VALUES (?, ?, ?, ?, ?, ?)",
        [
          eventEnvelope.id,
          eventEnvelope.eventName,
          eventEnvelope.payload,
          eventEnvelope.correlationId,
          eventEnvelope.processed,
          eventEnvelope.createdAt
        ]
      );

      // 3. Commit both writes atomically
      await dbTransaction.commit();
    } catch (error) {
      await dbTransaction.rollback();
      throw error; // Let Express v5 catch and handle the error
    }

    return newOrder;
  }
}

3.3 Implementing the Outbox Relay (Background Publisher)

The final piece is the OutboxRelay, which periodically scans the outbox_events table and forwards them to the event bus.

// src/bus/outbox-relay.ts
import { IEventBus } from "./event-bus.interface";
import { OutboxRecord } from "./outbox.interface";

export class OutboxRelay {
  private isPolling = false;
  private intervalId?: NodeJS.Timeout;

  constructor(
    private readonly eventBus: IEventBus,
    private readonly pollIntervalMs: number = 500
  ) {}

  start() {
    this.intervalId = setInterval(() => this.pollAndPublish(), this.pollIntervalMs);
    console.log("[Outbox Relay] Background poller started.");
  }

  stop() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
    }
  }

  private async pollAndPublish() {
    if (this.isPolling) return; // Prevent concurrent overlaps
    this.isPolling = true;

    try {
      // 1. Fetch unprocessed events from DB (ordered by creation date)
      const unprocessed: OutboxRecord[] = await mockDb.query(
        "SELECT * FROM outbox_events WHERE processed = false ORDER BY created_at ASC LIMIT 10"
      );

      for (const record of unprocessed) {
        try {
          // 2. Publish to the event bus (In-memory, Redis, or RabbitMQ)
          const payload = JSON.parse(record.payload);
          await this.eventBus.publish(record.eventName, payload, record.correlationId);

          // 3. Mark as processed in the database
          await mockDb.query(
            "UPDATE outbox_events SET processed = true, processed_at = ? WHERE id = ?",
            [new Date(), record.id]
          );
          console.log(`[Outbox Relay] Successfully published event ${record.id}`);
        } catch (eventError) {
          console.error(`[Outbox Relay] Failed to publish event ${record.id}:`, eventError);
          // Do NOT block subsequent events. Let it retry on the next tick.
        }
      }
    } catch (err) {
      console.error("[Outbox Relay] Database poll error:", err);
    } finally {
      this.isPolling = false;
    }
  }
}

By decoupling the publish operation from the request lifecycle, we guarantee that any event generated by a successful database operation is eventually published to the event bus, even in the event of broker crashes, network outages, or application restarts.


Part 4: The Evolutionary Event Bus (MVP to Enterprise)

The core power of this architecture lies in dependency inversion. Since our modules rely on the IEventBus interface, we can upgrade the physical broker implementation from local memory to distributed systems without changing our core business modules.

Let’s walk through the scaling tiers.

Level 1: The In-Memory Event Bus (MVP/Local Dev)

For early MVP stages, keep the infrastructure footprint zero. Run a local Event Bus in memory using Node’s native EventEmitter.

// src/bus/in-memory.bus.ts
import { EventEmitter } from "events";
import crypto from "crypto";
import { IEventBus, EventHandler, EventEnvelope } from "./event-bus.interface";

export class InMemoryEventBus implements IEventBus {
  private emitter = new EventEmitter();

  constructor() {
    // Increase listener limits for scalability in monolithic modules
    this.emitter.setMaxListeners(100);
  }

  async publish<T>(eventName: string, payload: T, correlationId?: string): Promise<void> {
    const envelope: EventEnvelope<T> = {
      id: crypto.randomUUID(),
      eventName,
      timestamp: new Date(),
      payload,
      correlationId,
    };

    // Emit event asynchronously to prevent blocking the caller's stack
    setImmediate(() => {
      this.emitter.emit(eventName, envelope);
    });
  }

  async subscribe<T>(eventName: string, handler: EventHandler<T>): Promise<void> {
    this.emitter.on(eventName, async (envelope: EventEnvelope<T>) => {
      try {
        await handler(envelope);
      } catch (err) {
        console.error(`[In-Memory Bus Error] Failed to handle event "${eventName}":`, err);
      }
    });
  }
}

Level 2: Redis Streams (Scaling monoliths with durability & persistence)

As your monolith starts to handle more traffic, or if you run multiple instances of your monolith behind a load balancer, an in-memory bus fails because:

  1. It is volatile: if the process crashes, events in-flight are lost.
  2. It is localized: instance A cannot publish to subscribers running on instance B.

Redis Streams provide an excellent middle-ground. They act as a append-only log with support for Consumer Groups, meaning we get persistent events and load-balanced consumption without deploying heavy queue brokers.

// src/bus/redis-stream.bus.ts
import { createClient, RedisClientType } from "redis";
import crypto from "crypto";
import { IEventBus, EventHandler, EventEnvelope } from "./event-bus.interface";

export class RedisStreamBus implements IEventBus {
  private client: RedisClientType;
  private subscribers: Array<{ eventName: string; handler: EventHandler }> = [];
  private isRunning = false;

  constructor(redisUrl: string) {
    this.client = createClient({ url: redisUrl });
  }

  async start(): Promise<void> {
    await this.client.connect();
    this.isRunning = true;
    this.startListening();
  }

  async publish<T>(eventName: string, payload: T, correlationId?: string): Promise<void> {
    const envelope: EventEnvelope<T> = {
      id: crypto.randomUUID(),
      eventName,
      timestamp: new Date(),
      payload,
      correlationId,
    };

    const streamName = `events:${eventName}`;
    
    // Add event to Redis Stream
    await this.client.xAdd(streamName, "*", {
      data: JSON.stringify(envelope),
    });
  }

  async subscribe<T>(eventName: string, handler: EventHandler<T>): Promise<void> {
    this.subscribers.push({ eventName, handler });
    if (this.isRunning) {
      await this.setupConsumerGroup(eventName);
    }
  }

  private async setupConsumerGroup(eventName: string) {
    const streamName = `events:${eventName}`;
    const groupName = "monolith_consumers";

    try {
      // Create consumer group; MKSTREAM creates stream if it doesn't exist
      await this.client.xGroupCreate(streamName, groupName, "$", {
        MKSTREAM: true,
      });
    } catch (err: any) {
      if (!err.message.includes("BUSYGROUP")) {
        throw err;
      }
    }
  }

  private async startListening() {
    const groupName = "monolith_consumers";
    const consumerName = `instance:${crypto.randomBytes(4).toString("hex")}`;

    while (this.isRunning) {
      for (const sub of this.subscribers) {
        const streamName = `events:${sub.eventName}`;
        try {
          // Read new messages from the stream
          const response = await this.client.xReadGroup(
            groupName,
            consumerName,
            [{ key: streamName, id: ">" }],
            { COUNT: 1, BLOCK: 100 }
          );

          if (response) {
            for (const stream of response) {
              for (const message of stream.messages) {
                const envelope: EventEnvelope = JSON.parse(message.message.data);
                
                try {
                  await sub.handler(envelope);
                  // Acknowledge processed event
                  await this.client.xAck(streamName, groupName, message.id);
                } catch (handlerErr) {
                  console.error(`[Redis Stream Handler Error] Exception:`, handlerErr);
                  // Handle retries or send to dead-letter queue (DLQ)
                }
              }
            }
          }
        } catch (err) {
          console.error(`[Redis Stream Listener Error]`, err);
          await new Promise((resolve) => setTimeout(resolve, 1000)); // avoid thrashing on connection failure
        }
      }
    }
  }
}

Level 3: Swapping to Enterprise Brokers (RabbitMQ / Kafka / NATS)

For high-scale enterprise needs (e.g., millions of messages/sec, advanced routing, complex event streaming, native partitioning), you can implement IEventBus with RabbitMQ, Kafka, or NATS.

Here is a conceptual implementation outline of what your RabbitMQ transition wrapper looks like using the standard amqplib client. No business logic in your order module changes when you plug this in:

// src/bus/rabbitmq.bus.ts (Conceptual Implementation)
import amqp from "amqplib";
import { IEventBus, EventHandler, EventEnvelope } from "./event-bus.interface";

export class RabbitMQEventBus implements IEventBus {
  private channel!: amqp.Channel;

  constructor(private readonly amqpUrl: string) {}

  async start() {
    const connection = await amqp.connect(this.amqpUrl);
    this.channel = await connection.createChannel();
    // Use topic exchange for rich event routing capabilities
    await this.channel.assertExchange("monolith_exchange", "topic", { durable: true });
  }

  async publish<T>(eventName: string, payload: T, correlationId?: string): Promise<void> {
    const envelope: EventEnvelope<T> = {
      id: crypto.randomUUID(),
      eventName,
      timestamp: new Date(),
      payload,
      correlationId,
    };

    this.channel.publish(
      "monolith_exchange",
      eventName, // Route routing key (e.g., 'order.created')
      Buffer.from(JSON.stringify(envelope)),
      { persistent: true }
    );
  }

  async subscribe<T>(eventName: string, handler: EventHandler<T>): Promise<void> {
    const queueName = `queue_${eventName}`;
    await this.channel.assertQueue(queueName, { durable: true });
    await this.channel.bindQueue(queueName, "monolith_exchange", eventName);

    await this.channel.consume(queueName, async (msg) => {
      if (msg) {
        const envelope: EventEnvelope<T> = JSON.parse(msg.content.toString());
        try {
          await handler(envelope);
          this.channel.ack(msg);
        } catch (err) {
          console.error(`[RabbitMQ Handler Error]`, err);
          this.channel.nack(msg, false, true); // Requeue message on failure
        }
      }
    });
  }
}

Part 5: Scenario-Based Decision Matrix

To choose the right event bus technology, use this scenario-based decision matrix:

TechnologyThroughputPersistenceScaling StrategyComplexityBest Fit Scenario
In-MemoryExtreme (1M+/s)❌ None (Volatile)Single process memoryMinimalMVPs, local unit & integration testing, single-node microservices.
Redis StreamsVery High (100k+/s)Persistent (Snapshot/AOF)Horizontal scaling via Consumer GroupsLow-MediumGrowing applications running multiple API instances needing durability.
RabbitMQHigh (50k+/s)✅ Fully DurableAMQP exchanges, routing patternsMedium-HighComplex transactional business flows, routing topologies, guarantees.
KafkaExtreme (1M+/s)✅ Durable (Logs commit)Log partitions & consumer groupsHighEvent Sourcing, data pipeline streaming, log-based tracking, analytics.
NATS / JetStreamExtreme (1M+/s)✅ Durable (JetStream)Clustering & Leaf nodesMediumReal-time messaging, IoT, microservices grids with lightweight footprint.

Part 6: When & How to Break the Monolith (Transition to Microservices)

The most common architectural mistake is splitting a monolith into microservices too early. Only distribute your system when forced to do so.

6.1 When to Break

Move a module out of the modular monolith only when it hits at least one of these thresholds:

  1. Deployment Friction: You have 30 developers working across different domains. Deploying a change in the User module forces a redeployment of the Order module, leading to release bottlenecks.
  2. Resource Scaling Asymmetry: The Analytics module needs huge RAM and CPU to process logs, while the Auth module only needs minimal memory. Running them together forces you to scale expensive large servers across all instances.
  3. Hardware / Language Specialization: The Recommendation module requires Python and TensorFlow, while the rest of the app is built on Node.js.
  4. Independent Lifecycle: A module has a drastically different rate of change compared to others.

6.2 How to Break (The Migration Flow)

Because you used an event-driven design, transitioning is straightforward. Let’s take the Notification Module out of the Monolith.

flowchart TD
    subgraph MonolithServer["Original Monolith Server Instance"]
        Orders["Order Module"]
        Router["App Router"]
    end

    subgraph RedisBroker["Redis Stream / RabbitMQ (Shared Network Broker)"]
        Stream[("Stream: order.created")]
    end

    subgraph Microservice["New Notifications Microservice"]
        Consumer["Notifications Consumer"]
        Email["Email Service"]
    end

    Router --> Orders
    Orders -- "Publishes Event" --> Stream
    Stream -- "Consumed Asynchronously" --> Consumer
    Consumer --> Email
  1. Extract Code: Move src/modules/notifications into a new, separate code repository.
  2. Switch Bus Implementation: Configure both the monolith and the new microservice to connect to the same external broker (e.g., Redis Stream, RabbitMQ).
  3. Run Consumer: Start the new notification service. It subscribes to the order.created stream.
  4. Disable Monolith Consumer: Comment out or delete the notification subscription registration inside the monolith server configuration.
  5. Deploy: Deploy the microservice and monolith. The monolith still publishes order.created, but the event is now consumed by the remote microservice over the network.

No core business rules inside OrdersService had to change. This is the ultimate power of evolutionary backend architecture. 🚀