The Last Event-Sourced Repository You'll Ever Need

Yazan AlaboudiYazan Alaboudi
6 min read

Introduction

In Domain-Driven Design (DDD), repositories play a crucial role—they represent a boundary between the domain model and the underlying infrastructure. But in event-sourced systems, developers often fall into one of two traps: either oversimplifying the repository to the point of uselessness, or overengineering it to the point of confusion.

This article builds up a production-grade event-sourced repository step-by-step, evolving it as new needs and insights emerge. Each design choice is carefully justified. By the end, you'll have a flexible, cleanly layered, and testable repository structure built in TypeScript that embraces the nuances of event sourcing.


Why Repositories Matter in DDD

A repository isn’t a fancy name for a database access class. It’s a semantic boundary. In DDD, a repository is a contract that provides access to aggregates, and its purpose is to abstract away how aggregates are persisted and reconstructed.

A general repository contract starts simple:

interface Repository<A> {
  getById(id: Identifier): Promise<A | undefined>;
  save(aggregate: A): Promise<void>;
}

Then, we define aggregate-specific repositories:

interface UserRepository extends Repository<User> {}

This makes the domain model dependent on what a repository does—not how it does it. Whether you're using a relational database, a document store, or an event store is irrelevant at this level. That abstraction is intentional.

But when you do use event sourcing, things get more interesting.


Why Event Sourcing Requires More Structure

In an event-sourced system, aggregates are rehydrated from event streams. The repository must:

  • Reconstruct an aggregate from a sequence of past events

  • Store newly produced events

  • Track versions to ensure optimistic concurrency

  • Translate between domain events and serialized forms

These are nontrivial concerns, and they repeat for every aggregate. That’s why it makes sense to create an abstract base class that handles this boilerplate while leaving domain-specific behavior to concrete implementations.

We call this base class EventSourcedRepository:

abstract class EventSourcedRepository<A extends AggregateRoot<E>, E extends Event> {
  constructor(private readonly eventStore: EventStore) {}

  protected abstract getStreamId(id: Identifier): string;
  protected abstract rehydrate(events: E[]): A;
  protected abstract toESEvent(event: E): ESEvent;
  protected abstract fromESEvent(es: ESEvent): E;

  async getById(id: Identifier): Promise<AggregateEnvelope<A> | undefined> {
    const streamId = this.getStreamId(id);
    const esEvents = await this.eventStore.getEventsByStreamId(streamId);
    if (esEvents.length === 0) return undefined;

    const domainEvents = esEvents.map(this.fromESEvent.bind(this));
    const aggregate = this.rehydrate(domainEvents);

    return { aggregate, version: esEvents.length };
  }

  async save(envelope: AggregateEnvelope<A>): Promise<void> {
    const { aggregate, version } = envelope;
    const events = aggregate.events.map(this.toESEvent.bind(this));
    await this.eventStore.save(this.getStreamId(aggregate.id), version, events);
    aggregate.clearEvents();
  }
}

Let’s unpack why each of these responsibilities is handled here.


Why the AggregateEnvelope Matters

At first glance, you might wonder why we don't just return the aggregate from getById. But remember: event-sourced systems use optimistic concurrency. That means the stream version at the time of loading is crucial when saving back later.

Instead of tracking that version inside the aggregate—which pollutes the domain with infrastructure—we wrap the aggregate in an envelope:

interface AggregateEnvelope<A extends AggregateRoot> {
  aggregate: A;
  version: number;
}

This design keeps the domain pure while still giving the repository the data it needs to safely persist changes.

In the Application Layer

class RenameUserHandler {
  constructor(private readonly userRepo: UserRepository) {}

  async execute(id: Identifier, newName: string) {
    const envelope = await this.userRepo.getById(id);
    if (!envelope) throw new Error("User not found");

    envelope.aggregate.rename(newName);
    await this.userRepo.save(envelope);
  }
}

Note that the application code doesn’t care about event sourcing or stream versions—it just performs business logic.


Why Event Translation Must Be Explicit

You might be tempted to serialize domain events directly into the store, but this introduces long-term risks:

  • You can’t evolve the schema easily

  • Value objects may not serialize correctly

  • Refactoring becomes brittle

Instead, we define translation methods:

Flatten events for storage:

protected toESEvent(event: UserEvent): ESEvent {
  switch (event.constructor) {
    case UserCreated:
      return {
        type: "UserCreated",
        data: { id: event.id.value, name: event.name.value },
      };
    case UserNameUpdated:
      return {
        type: "UserNameUpdated",
        data: { name: event.name.value },
      };
  }
}

Reconstruct events for rehydration:

protected fromESEvent(es: ESEvent): UserEvent {
  switch (es.type) {
    case "UserCreated":
      return new UserCreated(new Identifier(es.data.id), new Name(es.data.name));
    case "UserNameUpdated":
      return new UserNameUpdated(new Name(es.data.name));
    default:
      throw new Error(`Unknown event: ${es.type}`);
  }
}

This makes versioning, refactoring, and interoperability explicit and safe.


Stream ID Namespacing

In real-world systems, the same conceptual entity—like a user—might be represented across multiple aggregate boundaries. For example, you might have one aggregate modeling user identity, another modeling account preferences, and another modeling user activity. All of these aggregates might share the same user ID value.

In a traditional database, this is typically not a problem—tables differentiate data implicitly. But in an event store, stream IDs must be globally unique. That’s where namespacing comes in.

protected getStreamId(id: Identifier): string {
  return `user:${id.value}`;
}

Here, we use user: as a namespace to differentiate the aggregate stream from others that may use the same ID. This prevents collisions, respects the autonomy of bounded contexts, and reinforces that stream ID construction is a responsibility of the repository—not the domain.


Capability of Testing at Every Layer (and Why It's Powerful)

Thanks to the clean separation of concerns, testing is now straightforward:

Unit test the aggregate

const user = User.create(new Identifier("1"), "Alice");
user.rename("Bob");
expect(user.events[0]).toBeInstanceOf(UserNameUpdated);

Test the repository with a mock store

const store = new InMemoryEventStore();
const repo = new UserEventSourcedRepository(store);

const user = User.create(new Identifier("1"), "Alice");
await repo.save({ aggregate: user, version: 0 });

const result = await repo.getById(user.id);
expect(result?.aggregate.name).toBe("Alice");

Test the application layer

const store = new InMemoryEventStore();
const repo = new UserEventSourcedRepository(store);
const handler = new RenameUserHandler(repo);

await handler.execute(new Identifier("1"), "Charlie");
const result = await repo.getById(new Identifier("1"));
expect(result?.aggregate.name).toBe("Charlie");

You can mock just the event store or the entire repository—it’s up to you. Because each piece is clearly separated, you can even swap the whole repository out for an in-memory implementation if that better suits your test needs. This allows you to bypass event serialization, concurrency logic, or store-specific behavior when testing higher-level workflows or application services. The composability of this design means you can tune your test setup to match your context.


Final Output: A Complete User Repository

class UserEventSourcedRepository extends EventSourcedRepository<User, UserEvent> {
  constructor(eventStore: EventStore) {
    super(eventStore);
  }

  protected getStreamId(id: Identifier): string {
    return `user:${id.value}`;
  }

  protected rehydrate(events: UserEvent[]): User {
    return User.rehydrate(events);
  }

  protected toESEvent(event: UserEvent): ESEvent {
    switch (event.constructor) {
      case UserCreated:
        return {
          type: "UserCreated",
          data: { id: event.id.value, name: event.name.value },
        };
      case UserNameUpdated:
        return {
          type: "UserNameUpdated",
          data: { name: event.name.value },
        };
    }
  }

  protected fromESEvent(es: ESEvent): UserEvent {
    switch (es.type) {
      case "UserCreated":
        return new UserCreated(new Identifier(es.data.id), new Name(es.data.name));
      case "UserNameUpdated":
        return new UserNameUpdated(new Name(es.data.name));
      default:
        throw new Error(`Unknown event type: ${es.type}`);
    }
  }
}

Conclusion

A great event-sourced repository isn’t just about reading and writing events. It’s about aligning your technical model with your business domain, keeping responsibilities clear, and making your system a pleasure to work with.

Throughout this article, we built a repository layer that:

  • Starts from a clean, behavioral contract with the domain

  • Introduces a flexible and reusable abstract base class for event-sourced concerns

  • Keeps persistence details out of aggregates by using envelopes

  • Explicitly translates events between the domain and the event store

  • Namespaces stream IDs to reflect aggregate context boundaries

  • Makes every layer—from aggregate to application—easy to test

The result is a well-structured repository pattern that scales with your system’s complexity, supports evolving business needs, and resists rot over time.

This is more than just an implementation—it’s a mental model for building robust systems.

This really is the last event-sourced repository you'll ever need.

0
Subscribe to my newsletter

Read articles from Yazan Alaboudi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yazan Alaboudi
Yazan Alaboudi