Domain Events in a Monolith

How in-process domain events decoupled aggregates inside a monolith at a London product agency, where domain and integration events part ways, and the ordering and testing scars that came with it.

At the London product agency I led engineering at, we had this recurring pain. A booking got placed, and then five different services inside the same Laravel monolith all wanted to know about it. Email confirmation. Loyalty points. Stock decrement. A webhook out to a partner CRM. Some analytics ping nobody could ever quite explain. The booking controller had grown into a 400-line procedure that called five collaborators in a fixed order, and any one of them failing took the whole booking down. That’s the kind of thing that pushes teams to go reach for Kafka and microservices and a six-month rewrite. We didn’t. We added domain events to the monolith and kept moving.

I want to talk about that work specifically. Not “events” in the Kafka sense, not even in the “outbox to RabbitMQ” sense yet. Just in-process domain events inside one deployable, fired and handled in the same process, often in the same database transaction. They’re the cheapest decoupling tool DDD gives you and I think they’re under-used.

What a domain event actually is

A domain event is a fact about something that happened inside one aggregate, recorded by that aggregate, that other parts of the same context might care about. OrderPlaced. ClassBooked. InvoiceIssued. Past tense. Immutable. Owned by the aggregate that produced it.

The thing that trips people up is the distinction between a domain event and an integration event. They look the same in a tutorial. They are not the same. A domain event lives inside one bounded context and crosses no process boundary. An integration event is the public, versioned shape you emit when a fact needs to leave the context. Same underlying truth, two completely different contracts. I’ll come back to that.

Here’s what an aggregate raising a domain event looks like. This is from the boutique fitness product we were rebuilding.

// scheduling/domain/class-session.ts
import { DomainEvent } from "../../shared/domain-event";

export class ClassBooked implements DomainEvent {
  readonly occurredAt = new Date();
  constructor(
    readonly classSessionId: string,
    readonly memberId: string,
    readonly bookedAt: Date,
  ) {}
}

export class ClassSession {
  private events: DomainEvent[] = [];

  book(memberId: string): void {
    if (this.bookedCount >= this.capacity) {
      throw new ClassFullError(this.id);
    }
    this.bookedCount += 1;
    this.events.push(new ClassBooked(this.id, memberId, new Date()));
  }

  pullEvents(): DomainEvent[] {
    const e = this.events;
    this.events = [];
    return e;
  }
}

The aggregate doesn’t know who’s listening. It doesn’t call the email service. It doesn’t decrement the loyalty counter. It records a fact, in past tense, and goes back to minding its own state. That’s the whole trick.

Dispatching in-process, inside the transaction

The repository pulls the events off after persisting the aggregate and hands them to a dispatcher. Synchronous handlers run inside the same transaction. That’s the deliberate choice.

// shared/in-process-bus.ts
type Handler<E> = (e: E) => Promise<void>;

export class InProcessBus {
  private handlers = new Map<string, Handler<any>[]>();

  on<E>(eventName: string, h: Handler<E>): void {
    const list = this.handlers.get(eventName) ?? [];
    list.push(h);
    this.handlers.set(eventName, list);
  }

  async dispatch(events: DomainEvent[]): Promise<void> {
    for (const e of events) {
      const list = this.handlers.get(e.constructor.name) ?? [];
      for (const h of list) {
        await h(e);
      }
    }
  }
}

// scheduling/infra/class-session-repository.ts
async save(session: ClassSession, tx: Transaction): Promise<void> {
  await this.write(session, tx);
  const events = session.pullEvents();
  await this.bus.dispatch(events);
}

Two things people get wrong here. One, they make the dispatcher async-fire-and-forget so handler failures get swallowed. Two, they let handlers reach back across context boundaries and mutate someone else’s aggregate. Both kill the pattern.

If ClassBooked triggers a loyalty-points update, that handler should not be opening a Member aggregate and calling addPoints on it inside the same transaction. It should issue a command into the Loyalty context, through whatever in-process command bus the team uses, and Loyalty owns the call. The event is the handoff. Not the work.

Where domain stops and integration begins

The partner CRM webhook is not a domain event handler. The marketing analytics ping is not a domain event handler. Those leave the box. They need their own contract, their own retry semantics, their own versioning. That’s an integration event.

The pattern we standardized across the portfolio was the transactional outbox. Domain events fired in-process inside the transaction. A small subset of them got translated, by an explicit translator, into integration events, written to an outbox table inside the same transaction. A separate worker drained the outbox and published.

// integration/booking-published-translator.ts
bus.on<ClassBooked>("ClassBooked", async (e) => {
  await outbox.append({
    type: "scheduling.booking.placed.v1",
    occurredAt: e.bookedAt.toISOString(),
    payload: {
      session_id: e.classSessionId,
      member_id: e.memberId,
    },
    idempotencyKey: `booking:${e.classSessionId}:${e.memberId}`,
  });
});

The translator is the thing that earns its keep. The domain event is named in the team’s ubiquitous language. The integration event is versioned, public, and stable. Renaming ClassBooked to SessionReserved next quarter is a domain-side refactor. The integration event keeps its name and its shape, and the downstream partner CRM never knows we did anything. That separation is the entire reason to have two layers instead of one.

A war story about ordering

The creator-economy platform I worked at. A creator’s renewal hit Apple’s StoreKit notification endpoint. Our handler returned 200 OK just past Apple’s 30-second deadline. Apple retried. Our handler took the retry as a fresh event and inserted a new creator_subscriptions row. A few thousand customers across dozens of branded apps ended up with two active subscription rows, and Apple had already billed each card twice.

The frontend “fix” went out within an hour. Show only the latest row per customer. Visible-only fix. Apple did not refund anything because we hid a row. The real fix had two parts. A database-level unique constraint on (apple_original_transaction_id, notification_uuid). And rewriting the handler to enqueue a Sidekiq job and return 200 OK in well under five seconds, so Apple’s retries became idempotent at the queue level.

Lesson sat on the wall after that. In-process domain events that hand off to async work need an idempotency key derived from upstream truth, not from anything we generate ourselves. The outbox row’s idempotency key in the snippet above is not optional. It is the contract.

Testing without mocking the world

Domain events make tests boring in the best way. You don’t mock the email service. You don’t stub the loyalty API. You exercise the aggregate, pull the events, and assert against the events directly.

test("booking a class records a ClassBooked event", () => {
  const session = ClassSession.scheduled({
    id: "sess_1",
    capacity: 10,
    bookedCount: 0,
  });
  session.book("mem_42");
  const events = session.pullEvents();
  expect(events).toHaveLength(1);
  expect(events[0]).toBeInstanceOf(ClassBooked);
  expect((events[0] as ClassBooked).memberId).toBe("mem_42");
});

The aggregate test is a unit test in the strict sense. No database, no dispatcher, no handler. Handler tests live separately and test handlers against synthetic events. The boundary stays clean.

The thing that bit us on the boutique fitness build was assuming the handler tests would catch ordering bugs. They didn’t. Order between two handlers for the same event is a property of the registration, and we’d had registrations spread across three different bootstrap files. A loyalty handler ran before the credit-decrement handler on one environment and after on another. Same code, different boot order, subtly different outcomes. We pulled all registrations into one explicit file and added a startup assertion that fails the boot if any event has more than one handler without an explicit order declaration. Boring, but never had that bug again.

Takeaways

  • Domain events let you decouple inside a monolith without buying microservices. Use them first, reach for a broker only when you actually cross a process.
  • Aggregates record facts in past tense. They do not call collaborators. Handlers do the work.
  • Domain events stay inside the context. Integration events are a separate, versioned, public contract. Translate explicitly.
  • Dispatch synchronously inside the transaction by default. Async crossings need an outbox and an idempotency key derived from upstream truth.
  • Pin event ordering in one explicit place. Implicit order across bootstrap files is a future production incident.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.