The Last Event-Sourced Repository You'll Ever Need
A step-by-step guide to designing semantically clean, testable, and flexible repositories for event-sourced systems in TypeScript.

Search for a command to run...
A step-by-step guide to designing semantically clean, testable, and flexible repositories for event-sourced systems in TypeScript.

SPEAK WITH A LICENSED CYPTO RECOVERY HACKER EXPERT, ALPHAI KEY
I've read a lot of reviews about recovery hackers online and have fallen victim to them twice, but Alpha Key has been amazing and has left me speechless for a while. All I can say is that I'm really grateful that they saved me from some coin base brokers who had forced me to invest over $98.779 in bitcoin. To put it briefly, Alpha Key has shown me that there are still genuine recovery hackers out there, so you should all get in touch with Alpha Key Recovery right away. Email:service@alphakeyrecovery.com Email: Alphakey@consltant.com WhatsApp: +15714122170 Telegram: Alphakeyhacker Signal: +18622823879
sdfsdfdsfsdfsfsdfsdfs
Let's Export Better Components

You're (Probably) Using Them Wrong

A DDD Heresy or a Practical Approach?

Avoid abstracting incidental duplications

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.
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.
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.
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.
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.
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:
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: ${es.type}`);
}
}
This makes versioning, refactoring, and interoperability explicit and safe.
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.
Thanks to the clean separation of concerns, testing is now straightforward:
const user = User.create(new Identifier("1"), "Alice");
user.rename("Bob");
expect(user.events[0]).toBeInstanceOf(UserNameUpdated);
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");
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.
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}`);
}
}
}
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.