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

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:

  1. Track shots and update scores
  2. Track match timing (start/finish)
  3. Maintain event sourcing capabilities
  4. 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 WithAuditing mixin 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:

  1. Comprehensive: Easily add new test cases
  2. Maintainable: Test logic written once
  3. Readable: Clear description of each case
  4. 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.