Event-Driven Architecture
What's an event?
An event is a record that something happened: "a user registered", "a password was reset". Instead of one piece of code directly calling another to make the next thing happen, it just announces the event, and whoever cares reacts.
That indirection is the whole point. The code that does something doesn't need to know who's interested or what they'll do. You can add a new reaction (send a welcome email, update a projection, notify another team's service) without touching the code that emitted the event. Things stay decoupled.
This matters most at the seams between bounded contexts. Each context is its own little system. Several may share a process today, but any of them should be splittable into its own microservice later. They collaborate only through events, never by importing each other or sharing a database, so that split is always possible.
Two kinds of events
There are two, and they exist for different reasons. Getting the distinction right is the key to everything else on this page: one kind stays inside a context, the other is how contexts talk across the boundary.
Domain events: internal to one context
A domain event is emitted by an aggregate when something meaningful happens in the business. It's handled in the same context, in the same process, and it never leaves. Because it stays internal, it can carry rich domain objects (value objects, entities, whatever the handlers need):
// auth context — in-process only, never serialized over the wire
export class UserRegistered_DomainEvent extends Base_DomainEvent {
constructor(
public readonly userId: Id, // domain value objects, not strings
public readonly email: Email,
public readonly role: UserRole,
) {
super(userId);
}
}Use them to keep a single context's own logic decoupled: an aggregate records what happened, and one or more handlers in that same context react to it.
Integration events: the contract between contexts
An integration event is the public contract that crosses a boundary, between bounded contexts, and between services. It travels over Kafka, so it must be easily serializable: plain, primitive fields only, no domain objects. It's also versioned, because other systems depend on its shape and you need to be able to evolve it without breaking them:
// the public "a user was created" contract — crosses context/service boundaries
export class UserCreated_IntegrationEvent extends Base_IntegrationEvent {
public readonly userId: string; // plain strings: serializable, stable
public readonly email: string;
public readonly role: string;
constructor(props: UserCreated_IntegrationEventProps) {
// topic name version
super(props, Topics.USERS.topic, Topics.USERS.events.USER_CREATED, '1.0');
this.userId = props.userId;
this.email = props.email;
this.role = props.role;
this.validate();
}
}The rule of thumb: a domain event says "this happened inside me." An integration event says "here's something I'm announcing to everyone else, in a shape they can rely on."
Why the strict separation
Keeping domain objects out of integration events is what keeps contexts truly independent. If one context could hand its aggregates to another, they'd be coupled to each other's internals and you could never split them apart. Plain, versioned messages are the contract that lets each context evolve (and relocate into its own service) on its own schedule.
The handlers
Each kind of event has its own kind of handler, and they live in different layers because they answer to different sides of the boundary.
Domain-event handlers
A domain-event handler reacts to a domain event in the same context, in
the same process. It lives in application/domain-event-handlers/<event>/,
extends Base_DomainEventHandler(SomeEvent), and is named
<Event>_<Action>_DomainEventHandler, one handler per reaction, so an event
with three consequences has three small handlers instead of one big one:
// …/domain-event-handlers/user-registered/user-registered_create-email-verification.domain-event-handler.ts
export class UserRegistered_CreateEmailVerification_DomainEventHandler extends Base_DomainEventHandler(
UserRegistered_DomainEvent,
) {
constructor(@Inject(COMMAND_BUS) private readonly commandBus: ICommandBus) {
super();
}
async handleEvent(event: UserRegistered_DomainEvent) {
await this.commandBus.execute(
new CreateEmailVerification_Command({
userId: event.aggregateId.toValue(),
email: event.email.toValue(),
emailAlreadyVerified: event.emailVerified || event.authProvider === 'google',
}),
);
}
}Handlers run when the command handler that mutated the aggregate calls
sendDomainEvents(), which it does after its transaction has committed, so
an in-process reaction never acts on a change that was rolled back. The
handler above shows the most common shape: dispatching a follow-up command
through the command bus (that command then writes UserCreated to the outbox
in its own transaction). Other same-service side effects, such as updating a
read model, belong here too; the generated example context's
GreetingCreated_Log_DomainEventHandler simply logs the new greeting.
Domain-event handlers are not how events leave the service. Anything other contexts or services need is written to the outbox by the command handler, inside the same transaction as the state change (see the outbox).
Integration-event handlers
An integration-event handler is the consume side of the public contract:
it reacts to an integration event that arrived over Kafka from another
context or service. Because it's an entry point into the context (like a
controller, just event-driven instead of HTTP-driven), it lives in
interfaces/integration-events/ and is registered with a decorator:
// interfaces/integration-events/user-created.integration-event-handler.ts
@IntegrationEventHandler(UserCreated_IntegrationEvent)
export class UserCreated_IntegrationEventHandler {
constructor(@Inject(COMMAND_BUS) private readonly commandBus: ICommandBus) {}
async handleEvent(event: UserCreated_IntegrationEvent) {
await this.commandBus.execute(new CreateGreeting_Command(`Welcome, ${event.email}!`, event.name));
}
}The decorator is all the registration there is. The Kafka listener subscribes
to the topics that have handlers, and the inbox deduplicates each event before
calling handleEvent (see the inbox). Keep the
handler thin: translate the event into one of the context's own commands and
let the application layer do the work. Make it idempotent: if any handler
for an event fails, the inbox retries the event and runs its handlers again.
Getting it reliable
Publishing an integration event sounds simple: change some data, then send a message to Kafka. But that naive approach has two classic failure modes:
- On the publish side: if you save to the database and then send to Kafka, a crash in between means the change happened but the event never went out (lost event). Send first and save second, and you can publish an event for a change that ends up rolling back (phantom event).
- On the consume side: Kafka guarantees at-least-once delivery, so the same message can arrive twice. Processing it twice (charging a card, sending an email again) is a bug.
ForgeStack solves both with two patterns: the transactional outbox on the way out and the idempotent inbox on the way in. The rest of this page is how they fit together.
The flow end to end
1. A command mutates an aggregate, which records DOMAIN events.
2. In ONE DB transaction, the command handler saves the change and writes
the INTEGRATION event to the OUTBOX.
3. After the commit, it publishes the domain events to their in-process
handlers.
4. A relay reads unprocessed outbox rows and PUBLISHES them to Kafka.
5. Consumers receive the message into their INBOX (which dedupes it).
6. The inbox dispatches it to the registered handlers, retrying on failure.
Step 2 commits atomically, so you can never publish an event for a change that rolled back, nor lose one for a change that committed. Step 3 comes after the commit for the same reason: in-process reactions only see changes that actually happened.
The outbox (publish side)
The command handler that changes the state writes the integration event to the
outbox inside the same transaction, through the base class's
sendIntegrationEvent(event, context). Deleting a user is the worked example:
the removal and the UserDeleted outbox row commit together or not at all, and
the in-process domain events go out only after the commit:
// …/commands/delete-user/delete-user.command-handler.ts (essence)
async handle(command: DeleteUser_Command) {
const user = await this.userRepository.findById(new Id(command.userIdToDelete));
if (!user) throw new NotFoundException('User', command.userIdToDelete);
user.delete(); // records the in-process domain events
const integrationEvent = new UserDeleted_IntegrationEvent({
id: Id.random().toValue(),
occurredOn: new Date(),
userId: user.id.toValue(),
email: user.email.toValue(), // the row is gone by the time consumers run
});
await Transaction.run(async (context) => {
await this.userRepository.remove(user.id, context);
await this.sendIntegrationEvent(integrationEvent, context); // same transaction
});
await this.sendDomainEvents(user); // only after the commit
}sendIntegrationEvent wraps the event in an OutboxEvent, which snapshots it
as a JSON payload and tracks retry state.
The OutboxEvent aggregate owns its lifecycle (retry counting, max retries,
and the processed flag) as behaviour, not setters:
// libs/common/src/outbox/domain/outbox.aggregate.ts
export class OutboxEvent extends SharedAggregate {
canRetry(): boolean {
return this.retryCount.isLessThan(this.maxRetries) && !this.isProcessed();
}
markAsProcessed(): void {
this.processedAt = OutboxProcessedAt.now();
}
incrementRetry(): void {
if (!this.canRetry()) throw new DomainValidationException(/* … */);
this.retryCount = this.retryCount.increment();
}
}The generated example context follows the same rule:
CreateGreeting_CommandHandler builds GreetingCreated_IntegrationEvent
itself, saves the greeting and calls sendIntegrationEvent inside one
Transaction.run, and sends its domain events after the commit. Don't save an
OutboxEvent from a domain-event handler: that write would not be part of the
command's transaction, so the event could be lost, or announced for a change
that rolled back.
Kafka (transport)
The publisher sends the serialised event to its topic, keyed by event id:
// libs/kafka/src/kafka.integration-event-publisher.ts
async publish(topic: string, message: string) {
const producer = this.kafkaService.getProducer();
const key = JSON.parse(message).id; // dedupe key
await producer.send({ topic, messages: [{ key, value: message, timestamp: `${Date.now()}` }] });
}The listener subscribes to the topics that have registered handlers, and on each message routes it through the inbox:
// libs/kafka/src/kafka.integration-event-listener.ts — eachMessage
await consumer.run({
eachMessage: async ({ topic, message }) => {
await this.handleMessage(topic, this.parseMessage(message));
},
});The inbox (consume side)
The inbox makes sure a redelivered message is not processed twice.
Incoming messages are recorded as InboxEvents with a status that transitions
through a small state machine; duplicates are detected and dropped:
// libs/common/src/inbox/domain/inbox.aggregate.ts
export class InboxEvent extends SharedAggregate {
canProcess(): boolean { return this.status.isPending(); }
markAsProcessing(): void {
if (!this.canProcess()) throw new DomainValidationException(/* not pending */);
this.status = InboxStatusVO.processing();
}
markAsProcessed(): void {
if (!this.isProcessing()) throw new DomainValidationException(/* not processing */);
this.status = InboxStatusVO.processed();
this.processedAt = InboxProcessedAt.now();
}
markAsDuplicate(): void {
this.status = InboxStatusVO.duplicate();
this.processedAt = InboxProcessedAt.now();
}
}When the listener has an inbox service, handleMessage hands the message to it;
the inbox stores, dedupes, and then dispatches to the registered handler. Without
an inbox service, the listener dispatches directly (useful in tests).
Why outbox + inbox together
The outbox stops you from losing or phantom-publishing events. The inbox stops you from processing the same delivery twice. Together they give you at-least-once delivery with deduplication, which is effectively-once processing without distributed transactions. It is not a strict exactly-once guarantee: handlers run and the event is marked processed in separate steps, so a crash or a failing sibling handler in between means the handlers run again. Write integration-event handlers to be idempotent.
Anatomy of an integration event
Every integration event extends Base_IntegrationEvent, which standardises the
envelope (topic, name, version, id, occurredOn, tracing metadata) and the
serialisation that makes it safe to send over the wire:
// libs/common/src/integration-events/events/base.integration-event.ts
export abstract class Base_IntegrationEvent {
readonly id: string;
readonly occurredOn: Date;
constructor(props, readonly topic: string, readonly name: string, readonly version: string) {
this.id = props.id;
this.occurredOn = props.occurredOn;
}
toJSON(): Record<string, unknown> {
return {
topic: this.topic, name: this.name, version: this.version,
id: this.id, occurredOn: this.occurredOn.toISOString(),
metadata: TracingService.getTraceMetadata() || {},
...this.toEventJSON(),
};
}
protected abstract toEventJSON(): Record<string, unknown>;
}A concrete event defines its payload and how to (de)serialise it: toEventJSON
to send, fromJSON to reconstruct on the other side:
// libs/common/src/integration-events/events/user-deleted.integration-event.ts
export class UserDeleted_IntegrationEvent extends Base_IntegrationEvent {
public readonly userId: string;
public readonly email: string;
constructor(props: UserDeleted_IntegrationEventProps) {
super(props, Topics.USERS.topic, Topics.USERS.events.USER_DELETED, '1.0');
this.userId = props.userId;
this.email = props.email;
this.validate();
}
protected toEventJSON() {
return { userId: this.userId, email: this.email };
}
static fromJSON(json: Record<string, unknown>): UserDeleted_IntegrationEvent {
Base_IntegrationEvent.validateJson(json);
return new UserDeleted_IntegrationEvent({
id: json.id as string,
occurredOn: new Date(json.occurredOn as string),
userId: json.userId as string,
email: json.email as string,
});
}
}The metadata carries the current trace context, so a consumer's spans link
back to the producer's trace in Tempo.
Adding your own event
- Define an integration event extending
Base_IntegrationEvent(withtoEventJSON/fromJSON). - In the producing command handler, build it and write it with
sendIntegrationEvent(event, context)inside the sameTransaction.runas the state change. - In the consuming context, register a handler for the topic/event name; the inbox delivers it.
Next: Testing.