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 Mixin Composition Challenge
- Why Mixins for Event Sourcing
- Building WithShots: Shot Tracking
- Building WithTiming: Match Events
- The Event Ordering Problem
- Testing Strategy with node:test
- Testing Individual Mixins
- Testing Composition
- Data-Driven Testing with Scenarios
- Key Learnings
The Mixin Composition Challenge
When Caroline, Claude, and I refactored our Match aggregate to use the Repository pattern, we wanted to separate concerns using TypeScript mixins. The aggregate needed to:
- Track shots and update scores
- Track match timing (start/finish)
- Maintain event sourcing capabilities
- Be testable in isolation
The challenge? Making these mixins compose cleanly while maintaining proper event ordering and testability.
graph TB
Aggregate["Base Aggregate<br/>Event Sourcing"]
WithTiming["WithTiming Mixin<br/>MatchStarted/MatchFinished"]
WithShots["WithShots Mixin<br/>Shot tracking & scoring"]
MatchAggregate["MatchAggregate<br/>Final composition"]
Aggregate --> WithTiming
WithTiming --> WithShots
WithShots --> MatchAggregate
style Aggregate fill:#e3f2fd
style WithTiming fill:#ffe0b2
style WithShots fill:#fff3e0
style MatchAggregate fill:#c8e6c9
Why Mixins for Event Sourcing
Traditional inheritance creates rigid hierarchies. With mixins, we can:
- Compose behaviors:
WithTiming(WithShots(Match))layers functionality - Test in isolation: Each mixin gets its own test suite with mock base classes
- Reuse across aggregates: A
WithAuditingmixin could apply to any aggregate - Single responsibility: Each mixin focuses on one behavioral aspect
Here’s the composition in code:
// Base aggregate provides event sourcing
class Match extends Aggregate {
// Core match logic
}
// Layer 1: Add timing tracking
const MatchWithTiming = WithTiming(Match);
// Layer 2: Add shot tracking and scoring
const MatchWithTimingAndShots = WithShots(MatchWithTiming);
// Final aggregate
export class MatchAggregate extends MatchWithTimingAndShots {
static create(id: string, createdAt: string): MatchAggregate {
return new MatchAggregate(id, createdAt);
}
}
Building WithShots: Shot Tracking
The WithShots mixin handles shot recording and score updates:
export const WithShots = <T extends Constructor<Match>>(Base: T) =>
class extends Base {
#totalShotsPlayed = 0;
#matchScore: MatchScore;
playShot(shot: Shot): void {
// Validate shot
if (!this.#isValidShot(shot)) {
throw new Error('Invalid shot');
}
// Increment sequence
this.#totalShotsPlayed++;
// Call parent (WithTiming will emit MatchStarted if needed)
super.playShot?.(shot);
// Emit ShotPlayed event
this.emit('ShotPlayed', {
matchId: this.id,
shotType: shot.type,
outcome: shot.outcome,
playerNumber: shot.playerNumber,
sequenceNumber: this.#totalShotsPlayed,
team: shot.playerNumber <= 2 ? 0 : 1,
occurredAt: shot.playedAt,
});
// Update score if point-scoring shot
if (this.#isPointScoring(shot.outcome)) {
const winningTeam = this.#determineWinningTeam(shot);
this.#matchScore.addPoint(winningTeam);
this.emit('ScoreChanged', {
matchId: this.id,
score: this.#matchScore.toState(),
sequenceNumber: this.#totalShotsPlayed,
});
}
}
#isPointScoring(outcome: ShotOutcome): boolean {
return ['WINNER', 'ERROR', 'UNFORCED_ERROR'].includes(outcome);
}
#determineWinningTeam(shot: Shot): number {
const shootingTeam = shot.playerNumber <= 2 ? 0 : 1;
return shot.outcome === 'WINNER' ? shootingTeam : 1 - shootingTeam;
}
get totalShotsPlayed(): number {
return this.#totalShotsPlayed;
}
};
Key Design Decisions:
- Private fields (
#totalShotsPlayed) ensure encapsulation super.playShot?.(shot)safely calls parent implementation- Team calculation: Players 1-2 are team 0, players 3-4 are team 1
- Sequence numbers start at 1 (not 0) for human readability
Building WithTiming: Match Events
The WithTiming mixin tracks when matches start and finish:
export const WithTiming = <T extends Constructor<Match>>(Base: T) =>
class extends Base {
#startedAt: Date | null = null;
#finishedAt: Date | null = null;
playShot(shot: Shot): void {
// Emit MatchStarted BEFORE first shot
if (this.#startedAt === null) {
this.#startedAt = new Date(shot.playedAt);
this.emit('MatchStarted', {
matchId: this.id,
occurredAt: shot.playedAt,
});
}
// Call parent implementation (or WithShots if composed)
super.playShot?.(shot);
// Emit MatchFinished if match is complete
if (this.isFinished && this.#finishedAt === null) {
this.#finishedAt = new Date(shot.playedAt);
this.emit('MatchFinished', {
matchId: this.id,
occurredAt: shot.playedAt,
});
}
}
get startedAt(): Date | null {
return this.#startedAt;
}
get finishedAt(): Date | null {
return this.#finishedAt;
}
};
Critical Detail: MatchStarted is emitted before calling super.playShot(). This ensures correct event ordering in the event stream.
The Event Ordering Problem
When we first implemented timing, the events came out in the wrong order:
// WRONG: MatchStarted after ShotPlayed
playShot(shot: Shot): void {
super.playShot?.(shot); // WithShots emits ShotPlayed
if (this.#startedAt === null) {
this.emit('MatchStarted', { ... }); // Too late!
}
}
// Events: ShotPlayed, MatchStarted ❌
This broke ClickHouse projections because the match_timing table didn’t exist yet when ShotPlayed tried to JOIN to it.
The Fix: Emit timing events at the right moments in the call chain:
// CORRECT: MatchStarted before ShotPlayed
playShot(shot: Shot): void {
if (this.#startedAt === null) {
this.emit('MatchStarted', { ... }); // First!
}
super.playShot?.(shot); // Then WithShots emits ShotPlayed
if (this.isFinished && this.#finishedAt === null) {
this.emit('MatchFinished', { ... }); // Last!
}
}
// Events: MatchStarted, ShotPlayed, ScoreChanged, MatchFinished ✅
Call Chain Visualization
When playShot() is called on the composed aggregate:
1. MatchAggregate.playShot()
↓
2. WithShots.playShot()
- Increment counter
- Validate shot
↓
3. super.playShot() → WithTiming.playShot()
- Emit MatchStarted (first shot only)
↓
4. super.playShot() → Match (base)
- Core match logic
↓
5. Return to WithTiming.playShot()
- Emit MatchFinished (if complete)
↓
6. Return to WithShots.playShot()
- Emit ShotPlayed
- Emit ScoreChanged (if point scored)
Testing Strategy with node:test
We chose Node.js native test framework (node:test) over Jest/Vitest:
Benefits:
- ✅ Zero dependencies (built into Node 18+)
- ✅ Works seamlessly with TypeScript ESM
- ✅ Fast (no transpilation overhead)
- ✅ Simple (minimal configuration)
- ✅ Type-safe (full TypeScript support)
Basic test structure:
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
describe('MatchAggregate', () => {
let aggregate: MatchAggregate;
beforeEach(() => {
aggregate = MatchAggregate.create('match-123', new Date().toISOString());
});
it('should create a new match aggregate', () => {
assert.strictEqual(aggregate.id, 'match-123');
assert.ok(aggregate.createdAt);
});
});
Testing Individual Mixins
Each mixin has its own test file with a mock base class:
// mixin.with-shots.test.mts
import { WithShots } from './mixin.with-shots.mts';
import { MockMatch } from './aggregate.mock.mts';
describe('WithShots Mixin', () => {
// Compose mixin with mock
const MatchWithShots = WithShots(MockMatch);
let aggregate: InstanceType<typeof MatchWithShots>;
beforeEach(() => {
aggregate = new MatchWithShots('match-123', new Date().toISOString());
});
it('should emit ShotPlayed event', () => {
const shot: Shot = {
playerNumber: 1,
type: 'forehand',
outcome: 'WINNER',
playedAt: new Date().toISOString(),
};
aggregate.playShot(shot);
const events = aggregate.getEvents();
const shotPlayed = events.find((e) => e.type === 'ShotPlayed');
assert.ok(shotPlayed, 'ShotPlayed event should be emitted');
assert.strictEqual(shotPlayed.detail.sequenceNumber, 1);
});
});
Mock Base Class:
export class MockMatch {
#events: DomainEvent<any>[] = [];
constructor(public id: string, public createdAt: string) {}
emit(type: string, detail: any): void {
this.#events.push({ type, detail, timestamp: new Date().toISOString() });
}
getEvents(): DomainEvent<any>[] {
return [...this.#events];
}
clearEvents(): void {
this.#events = [];
}
}
Testing Composition
The aggregate test file verifies mixins work together:
// aggregate.test.mts
describe('Mixin Composition', () => {
it('should emit events in correct order', () => {
const aggregate = MatchAggregate.create(
'match-123',
new Date().toISOString()
);
// Play first shot
const shot: Shot = {
playerNumber: 1,
type: 'forehand',
outcome: 'WINNER',
playedAt: new Date().toISOString(),
};
aggregate.playShot(shot);
const events = aggregate.getEvents();
const eventTypes = events.map((e) => e.type);
// Verify order
const matchStartedIndex = eventTypes.indexOf('MatchStarted');
const shotPlayedIndex = eventTypes.indexOf('ShotPlayed');
const scoreChangedIndex = eventTypes.indexOf('ScoreChanged');
assert.ok(matchStartedIndex !== -1, 'Should emit MatchStarted');
assert.ok(shotPlayedIndex !== -1, 'Should emit ShotPlayed');
assert.ok(scoreChangedIndex !== -1, 'Should emit ScoreChanged');
assert.ok(
matchStartedIndex < shotPlayedIndex,
'MatchStarted should come before ShotPlayed'
);
assert.ok(
shotPlayedIndex < scoreChangedIndex,
'ShotPlayed should come before ScoreChanged'
);
});
it('should maintain state across mixins', () => {
const aggregate = MatchAggregate.create(
'match-123',
new Date().toISOString()
);
// Play multiple shots
for (let i = 0; i < 3; i++) {
aggregate.playShot({
playerNumber: 1,
type: 'forehand',
outcome: 'NEUTRAL',
playedAt: new Date().toISOString(),
});
}
// WithShots state
assert.strictEqual(aggregate.totalShotsPlayed, 3);
// WithTiming state
assert.ok(aggregate.startedAt !== null, 'Should have started');
assert.ok(aggregate.finishedAt === null, 'Should not be finished');
});
});
Data-Driven Testing with Scenarios
For comprehensive coverage, we use scenario-based testing:
const eventScenarios: Array<{
outcome: ShotOutcome;
shouldHaveScoreChanged: boolean;
description: string;
}> = [
{
outcome: 'WINNER',
shouldHaveScoreChanged: true,
description: 'WINNER outcome',
},
{
outcome: 'ERROR',
shouldHaveScoreChanged: true,
description: 'ERROR outcome',
},
{
outcome: 'NEUTRAL',
shouldHaveScoreChanged: false,
description: 'NEUTRAL outcome',
},
{
outcome: 'FORCED_ERROR',
shouldHaveScoreChanged: false,
description: 'FORCED_ERROR outcome',
},
];
for (const { outcome, shouldHaveScoreChanged, description } of eventScenarios) {
it(`should emit correct events for ${description}`, () => {
const shot: Shot = {
playerNumber: 1,
type: 'forehand',
outcome,
playedAt: new Date().toISOString(),
};
aggregate.playShot(shot);
const events = aggregate.getEvents();
const shotPlayedEvent = events.find((e) => e.type === 'ShotPlayed');
assert.ok(shotPlayedEvent, 'Should emit ShotPlayed');
if (shouldHaveScoreChanged) {
const scoreChangedEvent = events.find((e) => e.type === 'ScoreChanged');
assert.ok(
scoreChangedEvent,
'Should emit ScoreChanged for point-scoring shot'
);
} else {
const scoreChangedEvent = events.find((e) => e.type === 'ScoreChanged');
assert.ok(
!scoreChangedEvent,
'Should not emit ScoreChanged for non-scoring shot'
);
}
});
}
Benefits of scenario-based testing:
- Comprehensive: Easily add new test cases
- Maintainable: Test logic written once
- Readable: Clear description of each case
- DRY: No repeated test boilerplate
Timing Edge Cases
Scenario testing is perfect for edge cases:
const timingScenarios = [
{
description: 'single shot',
shots: [{ playedAt: '2025-12-04T10:00:00Z' }],
expectedStart: '2025-12-04T10:00:00Z',
},
{
description: 'multiple shots with different timestamps',
shots: [
{ playedAt: '2025-12-04T10:00:00Z' },
{ playedAt: '2025-12-04T10:00:05Z' },
{ playedAt: '2025-12-04T10:00:10Z' },
],
expectedStart: '2025-12-04T10:00:00Z', // First shot
},
{
description: 'same timestamp for multiple shots',
shots: [
{ playedAt: '2025-12-04T10:00:00Z' },
{ playedAt: '2025-12-04T10:00:00Z' },
{ playedAt: '2025-12-04T10:00:00Z' },
],
expectedStart: '2025-12-04T10:00:00Z',
},
];
for (const scenario of timingScenarios) {
it(`should capture start time correctly for ${scenario.description}`, () => {
for (const shot of scenario.shots) {
aggregate.playShot({
playerNumber: 1,
type: 'forehand',
outcome: 'NEUTRAL',
playedAt: shot.playedAt,
});
}
assert.strictEqual(
aggregate.startedAt?.toISOString(),
scenario.expectedStart,
`Start time should be ${scenario.expectedStart} for ${scenario.description}`
);
});
}
Key Learnings
Working through mixin composition and testing with Caroline taught us several important lessons:
Composition Over Inheritance
Mixins provide true composition—we can layer behaviors without rigid hierarchies. The key insight: order matters. WithTiming(WithShots(Match)) is different from WithShots(WithTiming(Match)).
Event Ordering is Critical
In event sourcing, the order of events affects everything downstream. Projections, event replay, and audit logs all depend on correct ordering. Emit events at the right moment in the call chain.
Test in Isolation First
Testing each mixin independently with mock base classes caught issues before integration testing. This made debugging much easier—we knew exactly which mixin was responsible for a failure.
node:test is Production-Ready
The native Node.js test framework proved capable for complex testing scenarios. We don’t need Jest or Vitest for most projects—the standard library is enough.
Data-Driven Testing Scales
Scenario-based testing let us cover edge cases comprehensively without duplicating test code. When we add new shot outcomes or timing scenarios, we just add to the array.
Private Fields Enforce Encapsulation
TypeScript’s #privateField syntax ensures mixin state stays truly private. This prevents accidental coupling between mixins and makes composition safer.
State Management Matters
Each mixin manages its own state independently. There’s no shared mutable state between WithTiming and WithShots, which eliminates an entire class of bugs.
Testing Validates Composition
Our test suite caught the event ordering bug immediately. Without comprehensive testing, this would have been a production incident—projections would have failed with foreign key errors.
Conclusion
Building composable mixins for event-sourced aggregates is a powerful pattern. By separating concerns into focused mixins, we get:
- Testability: Each behavior tested independently
- Reusability: Mixins apply to different aggregates
- Maintainability: Single responsibility per mixin
- Flexibility: Compose different combinations easily
The key is getting event ordering right and having comprehensive tests to validate composition. With node:test and scenario-based testing, we built a robust test suite with zero external dependencies.
This pattern has served us well in the Scores project, and we’re already thinking about other mixins: WithAuditing for tracking all state changes, WithOptimisticLocking for concurrency control, and WithSoftDelete for logical deletion.
Mixin composition in TypeScript isn’t just a clever trick—it’s a practical way to build maintainable, testable domain models for event-sourced systems.