What an in-process event bus is, and which events should cross module boundaries

An in-process event bus is an in-memory dispatcher that delivers messages from one module to handlers in other modules within the same process. There is no broker and no network hop. The publisher hands a message to the bus, and the bus calls every registered handler. Because it is cheap, it is easy to misuse. Before building one, be clear about which messages are allowed to travel on it.

Three kinds of message are worth separating:

Kind Intent Owner Handlers Naming Visibility
Domain event Record a fact inside a module The module itself Zero or more, same module Past tense: OrderLineAdded Private
Integration event Announce a fact to other modules Publishing module (public contract) Zero or more, any module Past tense: OrderPlaced Public
Command Ask for something to be done Receiving module Exactly one Imperative: ReserveStock Public or private

Only integration events should cross module boundaries. Domain events describe internal state changes, and their shape follows the module's private model. If another module subscribes to a domain event, every refactor of that model becomes a breaking change for the subscriber. An integration event is a deliberate, versioned translation of the internal fact into something the module promises to keep stable.

Past-tense names matter because they signal that the event is a fact, not a request. A handler cannot reject OrderPlaced. It can only react to it.

Keep payloads small. Include the aggregate ID, plus only the stable data that consumers need to act without calling back, such as a customer ID or a total amount. Every extra field is one more thing you are obliged to keep stable.

Why teams use it and who it fits

The decoupling gain is narrower than it sounds, and more useful. When the Orders module publishes OrderPlaced, it does not know who is listening. Billing can start an invoice, Inventory can reserve stock, and Notifications can send a confirmation. None of them requires an edit to Orders. Adding a fourth reaction next quarter means writing a new handler in the module that cares, not opening a pull request against the publisher. Ownership follows the code: the team that wants the side effect writes and maintains it.

Compare this with a direct call. If Orders invokes billing.createInvoice() and inventory.reserve(), Orders depends on every downstream module. Each new reaction makes it harder to change and harder to test in isolation.

The pattern fits a few groups well:

  • Teams running a modular monolith with several bounded contexts, where modules already have separate owners and the main source of friction is cross-module calls.
  • Teams preparing for a possible later split. Events that already cross boundaries without shared transactions or callbacks are much easier to move onto a message broker than synchronous method calls.
  • Platform engineers standardising cross-module communication, who want one convention for publishing, handling, and tracing events instead of a different ad hoc mechanism in each module.

It is overkill for a small app with one or two modules, where a direct call is clearer and easier to debug. It is also the wrong tool for flows that are really queries. If the caller needs an answer back, such as a price or a stock level, use a direct call or a mediator-style request handler, not an event.

Building a minimal bus step by step

The bus needs four pieces, and none of them requires a framework.

Event marker. An empty interface such as IEvent. It only exists to let generic constraints and assembly scanning tell events apart from other types.

Handler interface. IEventHandler<TEvent> with a single Handle(event, cancellationToken) method. Each handler class deals with one event type.

Registration. At startup, scan each module and register every handler against its closed interface in the DI container. Adding a reaction then means adding a class. No central switch statement needs editing.

Publish. Publish<TEvent>(event) asks the container for every registered IEventHandler<TEvent> and calls each one.

A sample event: OrderPlaced lives in Ordering.Contracts, the only Ordering package that other modules may reference. It carries OrderId, CustomerId, PlacedAt and the total. It does not carry the Order entity, so Ordering can refactor its internals without breaking consumers. Billing then implements IEventHandler<OrderPlaced> inside its own module.

Mediator-style send Publish
Handlers Exactly one Zero to many
Return value A result Nothing
Missing handler Error Valid no-op

Watch for handlers that write a value back onto the event, or for publishers that wait for a reply event. Both are queries in disguise. They hide synchronous coupling behind an API that looks decoupled.

Synchronous dispatch runs on the caller's thread. Every handler adds to the request's latency, and an exception in any handler surfaces in the publisher. Container registration order decides which handler runs first, so no handler should depend on another having run.

// --- Ordering.Contracts: the only Ordering package other modules may import ---
export interface IEvent {} // marker type (TS typing is structural, so this documents intent)
export class OrderPlaced implements IEvent {
  constructor(
    readonly orderId: string,
    readonly customerId: string,
    readonly placedAt: Date,
    readonly total: number, // IDs and minimal data, never the Order entity
  ) {}
}
// --- Shared kernel: handler contract and bus ---
export interface IEventHandler<T extends IEvent> {
  handle(event: T, signal?: AbortSignal): Promise<void>;
}
type Ctor<T> = new (...args: any[]) => T;
export class EventBus {
  private handlers = new Map<Function, IEventHandler<any>[]>();
  // Called at startup by each module's registration code; stands in for the DI container
  register<T extends IEvent>(type: Ctor<T>, handler: IEventHandler<T>): void {
    this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
  }
  // Zero to many handlers, returns nothing; no handlers is a valid no-op.
  // Runs in the caller's flow: each handler adds latency and any throw reaches the publisher.
  async publish<T extends IEvent>(event: T, signal?: AbortSignal): Promise<void> {
    for (const h of this.handlers.get(event.constructor) ?? []) {
      await h.handle(event, signal); // registration order; handlers must not depend on it
    }
  }
}
// --- Billing module: reacts to Ordering's public event, writes nothing back ---
class CreateInvoiceOnOrderPlaced implements IEventHandler<OrderPlaced> {
  async handle(e: OrderPlaced): Promise<void> {
    console.log(`Billing: invoice for order ${e.orderId}, total ${e.total}`);
  }
}
// --- Composition root ---
const bus = new EventBus();
bus.register(OrderPlaced, new CreateInvoiceOnOrderPlaced());
await bus.publish(new OrderPlaced("ord-1042", "cus-77", new Date(), 129.5));

A minimal in-process event bus in TypeScript. It has a marker interface, a handler interface, registration and a publish method that awaits every handler for the event type in turn. The OrderPlaced contract belongs to Ordering's public package, and the handler that reacts to it lives in Billing.

Dispatch timing, transactions and the in-monolith outbox

Aggregates should only record events: append them to an internal list when state changes, and never publish them directly. The unit of work collects those pending events and decides when they are dispatched. That decision sets your consistency model, so make it on purpose.

Strategy Consistency Failure mode
Dispatch inside the transaction Atomic with the write Any failing subscriber rolls back the publisher's change
Dispatch after commit Eventual A crash between commit and dispatch silently loses side effects
Transactional outbox Eventual, at-least-once Duplicate deliveries, so handlers must be idempotent

Dispatching inside the transaction looks safe. In practice, a bug in the reporting module can stop orders from saving. Dispatching after commit removes that coupling, but nothing records that an event still needed delivery. If the process dies or a handler throws after the commit, that delivery is gone.

The outbox closes the gap. In its pre-commit hook, the unit of work serializes each pending event into an outbox table, so the event rows commit or roll back with the business data. A background dispatcher polls for undelivered rows, invokes the in-process handlers, and marks each row as processed. A failed delivery stays pending and is retried, so delivery is at least once.

request ─▶ unit of work ──(one transaction)──▶ business tables
                       └─────────────────────▶ outbox table
                                                   │ poll
                                              dispatcher ─▶ handlers

The outbox rows are already serialized events in a durable store. If you later move to a message broker, you change only the dispatcher's target, and the modules stay as they are.

import json, sqlite3, uuid
from dataclasses import dataclass, field, asdict

@dataclass
class OrderPlaced:
    order_id: str
    total_cents: int

@dataclass
class Order:
    id: str
    total_cents: int
    pending_events: list = field(default_factory=list)

    def place(self):
        # Aggregates only record events; they never publish them.
        self.pending_events.append(OrderPlaced(self.id, self.total_cents))

class UnitOfWork:
    def __init__(self, conn: sqlite3.Connection):
        self.conn, self.tracked = conn, []

    def commit(self):
        # 'with conn' commits on success and rolls back on any exception,
        # so business rows and outbox rows share one fate.
        with self.conn:
            for order in self.tracked:
                self.conn.execute("INSERT INTO orders (id, total_cents) VALUES (?, ?)",
                                  (order.id, order.total_cents))
            self._before_commit()
        for order in self.tracked:
            order.pending_events.clear()  # cleared only after a successful commit

    def _before_commit(self):
        # Pre-commit hook: serialize events instead of dispatching them here.
        for order in self.tracked:
            for event in order.pending_events:
                self.conn.execute(
                    "INSERT INTO outbox (id, event_type, payload, processed_at) VALUES (?, ?, ?, NULL)",
                    (str(uuid.uuid4()), type(event).__name__, json.dumps(asdict(event))))

An aggregate records an OrderPlaced event, and the unit of work's pre-commit hook writes it to the outbox table in the same SQLite transaction as the order row, leaving delivery to a separate background dispatcher.

Idempotent handlers, enforced boundaries and observability

The dispatcher can crash after a handler commits but before the outbox row is marked sent, so the same event will eventually run twice. Every handler must tolerate that.

Idempotent handlers

Three techniques cover most cases:

  • Processed-event table. Before doing work, insert a row keyed by event ID plus handler name, in the same transaction as the handler's writes. A unique-key violation means the event was already handled. The handler name matters because several handlers consume the same event independently.
  • Natural idempotency. Write with upserts keyed on business identity, such as "set loyalty tier for customer X". Repeating the write leaves the same state.
  • Guarded transitions. Update only from the expected prior state, for example moving an order from PENDING to PAID. If zero rows change, the work is already done.

Enforced boundaries

A module may depend only on another module's public contracts package: event types and query interfaces. It may never use another module's internal classes or read its tables. Convention alone does not hold this line, because a shortcut import compiles fine and gets merged. Use architecture tests or build-level dependency rules so that a violation fails CI.

Observability and testing

Carry a correlation ID in event metadata and propagate it into handler logs. Log publish and handle with event type and event ID, so one request can be traced across modules and duplicate deliveries are easy to spot.

Unit-test handlers in isolation by feeding them constructed events, including a repeated one. Add an integration test per module that triggers a use case and asserts that the expected contract event was published. That test protects downstream consumers from silent contract changes.

-- PostgreSQL. Each statement runs inside the handler's own transaction.

-- 1. Processed-event table: one row per (event, handler) pair.
CREATE TABLE processed_events (
  event_id     uuid        NOT NULL,
  handler_name text        NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id, handler_name)
);

BEGIN;
-- 0 rows inserted means this handler already processed the event: skip the work.
INSERT INTO processed_events (event_id, handler_name)
VALUES ('8f14e45f-ceea-4e7a-9b1c-2d3f4a5b6c7d', 'loyalty.OrderPaidHandler')
ON CONFLICT (event_id, handler_name) DO NOTHING;
-- ... handler writes go here; they commit or roll back with the marker row ...
COMMIT;

-- 2. Natural idempotency: upsert keyed on business identity.
-- Replaying the write produces the same final state.
INSERT INTO customer_loyalty (customer_id, tier, updated_at)
VALUES (42, 'GOLD', now())
ON CONFLICT (customer_id)
DO UPDATE SET tier = EXCLUDED.tier, updated_at = EXCLUDED.updated_at;

-- 3. Guarded transition: change state only from the expected prior state.
-- 0 rows updated means the order is already PAID (or in another state): no-op.
UPDATE orders
SET status = 'PAID', paid_at = now()
WHERE order_id = 1001
  AND status = 'PENDING';

PostgreSQL statements for the three idempotency techniques: a processed-event marker keyed by event ID and handler name, an upsert keyed on business identity, and a state transition guarded by the expected prior status.

When to avoid it, and the alternatives

An event bus trades explicitness for decoupling, and that trade is not always worth it. "Find usages" on a publish call shows you the bus, not the handlers, so a reader can no longer see what happens after an order is placed. Stepping through it in a debugger leads into dispatch plumbing. Fine-grained events such as AddressLineChanged make this worse, because a single business action fans out into chains that no one can hold in their head.

Published events are also public APIs. Renaming a field means changing every subscriber in step with the publisher, or keeping old and new versions side by side.

The bus will not repair a poor decomposition. If two modules exchange events constantly to keep their state in sync, they probably belong in one module. Do not route queries or commands through events either. When the publisher needs an answer, it has to wait for a reply event, which adds temporal coupling and hides a synchronous dependency behind something that looks asynchronous.

Option Coupling Delivery guarantee Readability Fits when
Direct call via public module interface Compile-time, explicit Synchronous, same transaction Highest You need a result or a command must succeed
Mediator request/response Caller knows the request type, not the handler Synchronous Medium You want cross-cutting pipeline behaviour on commands and queries
In-process bus, no outbox Publisher unaware of subscribers Lost on crash after commit Lower Side effects that can be lost, such as cache warming
In-process bus with outbox Same At-least-once Lower Side effects that must happen
External message broker Same, across processes Depends on broker and outbox Lowest locally Consumers run in other deployables

Impact on development cost and engineering time

The bus itself is cheap. The ongoing cost comes from the conventions around it: naming events, deciding what goes in a payload, and reviewing new subscriptions. The outbox adds more. You need a table, a dispatcher loop with retry and backoff, a retention job so delivered rows don't pile up, and idempotency keys in every handler, because at-least-once delivery makes duplicates normal. Contract versioning is the cost teams most often underestimate. Once three modules consume OrderPlaced, renaming a field requires a deprecation window rather than a quick refactor.

The savings show up as code changes. A new reaction is a new handler, and the publisher never changes. Teams stop editing each other's files, which cuts merge conflicts in shared services. Each handler can be tested with a hand-built event and no publisher setup. If you later move to a broker, the event contracts and handlers carry over, and only the transport changes.

Illustrative estimate (assumed numbers, not a benchmark).

  • Net hours = (R × S) − (B + R × C)
  • R is the number of new reactions per quarter.
  • S is the hours saved per reaction by not coordinating with the publisher's owners.
  • B is the fixed upkeep per quarter.
  • C is the per-reaction contract overhead.

Assume R = 12, S = 3, B = 10 and C = 0.5. The result is 36 − 16 = 20 hours per quarter. If R = 2, the result is −5, and the bus costs more than it saves.

Adopt the pieces in this order:

  1. Start with a synchronous bus.
  2. Move to after-commit dispatch once handlers have acted on rolled-back data.
  3. Add the outbox only when a lost side effect, such as an unsent invoice, is unacceptable.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to in-process event bus modular monolith, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.