Disclaimer: This blog post is automatically generated from project documentation and technical proposals using AI assistance. The content represents our development journey and architectural decisions. Code examples are simplified illustrations and may not reflect the exact production implementation.
Table of Contents
- The Architecture Evolution
- Understanding the Repository Pattern
- Aggregate Design
- Single Transaction Strategy
- Implementation Journey
- Benefits Realized
The Architecture Evolution
When Caroline, Claude, and I first built the event sourcing system for Scores, we followed a pattern we’d seen in many examples: separate command handlers, an intermediate evolver service, and projections consuming events. It worked, but as we added more features, we realized we’d created unnecessary complexity.
The old flow looked like this:
graph LR
A[Command Handler] --> B[Insert to Outbox]
B --> C[Evolver Service]
C --> D[Update Score]
C --> E[Publish Events]
E --> F[Projections]
E --> G[Real-time Updates]
style C fill:#ffcdd2
The problem? The evolver service was just orchestration overhead. It didn’t add business logic—it just moved data around. Plus, we had atomicity concerns: what if the outbox insert succeeded but the evolver failed?
Understanding the Repository Pattern
The Repository pattern is about creating a clean abstraction between domain logic and data persistence. Instead of command handlers directly manipulating database rows, they work with domain aggregates through repositories.
Here’s the conceptual shift:
Before (Anemic Domain):
// Handler does everything
async function playShotHandler(command) {
// Insert event to outbox
await db.query('INSERT INTO outbox...');
// Wait for evolver to process
// Hope everything stays consistent
}
After (Rich Domain):
// Handler orchestrates, aggregate contains logic
async function playShotHandler(command) {
let match = await repository.findById(matchId);
if (!match) {
match = MatchAggregate.Create(matchId);
}
const shot = createShot(type, outcome, player);
match.playShot(shot); // Business logic in aggregate
await repository.save(match); // Persistence abstracted
}
Aggregate Design
The Match aggregate is the heart of our domain model. It maintains invariants and generates domain events:
export class MatchAggregate {
#matchId: string;
#events: DomainEvent<any>[] = [];
#score: Score;
static Create(matchId: string): MatchAggregate {
const match = new MatchAggregate(matchId);
match.#addEvent({
type: 'MatchCreated',
detail: { matchId },
});
return match;
}
playShot(shot: Shot): void {
this.#addEvent({
type: 'ShotPlayed',
detail: {
matchId: this.#matchId,
shotType: shot.type,
outcome: shot.outcome,
player: shot.player,
},
});
this.#updateScore(shot);
}
#addEvent(event: Partial<DomainEvent<any>>): void {
this.#events.push({
id: ulid(),
namespace: 'matches',
type: event.type!,
timestamp: new Date(),
detail: event.detail,
});
}
}
Notice how the aggregate:
- Generates complete domain events with metadata
- Maintains business logic (score updates)
- Doesn’t know about persistence
Single Transaction Strategy
The key innovation in our repository is atomic persistence. When we save an aggregate, three things happen in one transaction:
graph TB
A["Repository.save()"] --> B{"Transaction Start"}
B --> C["Upsert Match"]
B --> D["Save Score Snapshot"]
B --> E["Insert Outbox Events"]
C --> F{"Commit"}
D --> F
E --> F
F --> G["Success"]
F --> H["Rollback on Error"]
style G fill:#c8e6c9
style H fill:#ffcdd2
Here’s the core repository implementation:
export class Repository extends OutboxRepository {
async save(aggregate: MatchAggregate): Promise<void> {
const span = this.tracer.startActiveSpan('Repository.save');
try {
await this.db.query('BEGIN');
// 1. Upsert match record
await this.db.query(
`INSERT INTO matches (match_id, created_at, updated_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (match_id) DO UPDATE
SET updated_at = NOW()`,
[aggregate.matchId]
);
// 2. Save score snapshot
const score = aggregate.getScore();
await this.db.query(
`INSERT INTO match_scores (match_id, score_data, created_at)
VALUES ($1, $2, NOW())`,
[aggregate.matchId, JSON.stringify(score)]
);
// 3. Insert events to outbox (bulk insert)
const events = aggregate.getEvents();
await this.insertOutboxEvents(events);
await this.db.query('COMMIT');
span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
await this.db.query('ROLLBACK');
span.recordException(error);
throw error;
} finally {
span.end();
}
}
}
The insertOutboxEvents uses PostgreSQL’s UNNEST for efficient bulk inserts:
protected async insertOutboxEvents(events: DomainEvent<any>[]): Promise<void> {
if (events.length === 0) return;
await this.db.query(
`INSERT INTO outbox (id, namespace, type, detail, timestamp, trace_context)
SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[],
$4::jsonb[], $5::timestamptz[], $6::jsonb[])`,
[
events.map(e => e.id),
events.map(e => e.namespace),
events.map(e => e.type),
events.map(e => e.detail),
events.map(e => e.timestamp),
events.map(e => this.getTraceContext())
]
);
}
Implementation Journey
Caroline and I approached this migration in phases:
Phase 1: Create Domain Structure We started by building the aggregate and repository classes without touching existing code. This let us test the pattern in isolation:
// domain/match/index.mts
export class MatchAggregate {
/* ... */
}
export class Repository extends OutboxRepository {
/* ... */
}
// domain/repositories.mts
export abstract class OutboxRepository {
protected abstract insertOutboxEvents(
events: DomainEvent<any>[]
): Promise<void>;
}
Phase 2: Update Command Handlers Next, we modified command handlers to use the repository:
// Before
const handler = new PlayShotHandler(db, outboxHandler);
// After
const handler = new PlayShotHandler(repository);
Phase 3: Database Optimizations We added strategic indexes to support the new query patterns:
-- Support fast lookups by match_id
CREATE INDEX idx_match_scores_match_id_created
ON match_scores(match_id, created_at DESC);
-- Single query to fetch match with latest score
SELECT m.*, s.score_data
FROM matches m
LEFT JOIN LATERAL (
SELECT score_data
FROM match_scores
WHERE match_id = m.match_id
ORDER BY created_at DESC
LIMIT 1
) s ON true
WHERE m.match_id = $1;
Benefits Realized
The repository pattern gave us several concrete benefits:
Atomicity: No more distributed transaction concerns. Everything succeeds or fails together.
Performance: Single query for match + score instead of multiple round trips. Bulk event insertion is 3x faster than individual inserts.
Testability: We can test aggregates without databases:
test('playing a shot generates correct events', () => {
const match = MatchAggregate.Create('match123');
match.playShot({ type: 'FOREHAND', outcome: 'WINNER', player: 1 });
const events = match.getEvents();
expect(events).toHaveLength(2); // MatchCreated + ShotPlayed
expect(events[1].type).toBe('ShotPlayed');
});
Simplicity: The evolver service is gone. One less thing to deploy, monitor, and debug.
Clean Boundaries: Domain logic lives in aggregates, persistence logic in repositories, orchestration in handlers. Each has a clear responsibility.
Working through this refactoring with Caroline and Claude taught us that good architecture isn’t about following patterns blindly—it’s about understanding the problems they solve. The repository pattern made sense for us because we had clear aggregate boundaries (matches) and consistency requirements (atomic event + score updates).
The key insight was recognizing that the evolver service was just indirection. By moving that logic into the repository’s save() method, we maintained the same guarantees (atomicity, event publishing) while eliminating a whole service. Sometimes the best refactoring is deletion.
The migration continues incrementally—we still have some command handlers using the old pattern—but the new architecture is proving its worth. When we add new features now, they fit naturally into the aggregate model instead of requiring complex orchestration across services.