Event Sourcing:

The Very Basics

Milko Kosturkov

  • Developer for over 16 years
  • Contractor
  • Founder of Ty's Software - consultancy and contracting
  • Head of Software Development @ mysuply

Definitions

Capture all changes to an application state as a sequence of events.

Martin Fowler

Instead of storing just the current state of the data in a domain, use an append-only store to record the full series of actions taken on that data.

 

https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing

A Football Game Aggregate

  • Track the start and end of the game

  • Track the game score

class FootballGame { 
  private UUID $id;
  private bool $hasStarted = false;
  private bool $hasEnded = false;
  private int $teamAScore = 0;
  private int $teamBScore = 0;

  public function __contstructor() {
    $this->id = UUID::uuid4();
  }
  ...
}

A naive little game

class FootballGame { 
  ...
  public function startGame(): void {
    if ($this->hasStarted) {
      throw new GameAlreadyStarted();
    }
    $this->hasStarted = true;
  }
    
  public function endGame(): void {....}
    
  ...
}

A naive little game

class FootballGame { 
  ...
  public function score(Target $target): void {
    if (!$this->hasStarted || $this->hasEnded) {
      throw new GameIsInactive();
    }
    if ($target->isTeamA()) {
      $this->teamBScore++;
    } else {
      $this->teamAScore++;
    }
  }
}

A naive little game

Better tracking of events

  • We want to know when the game started and ended

  • We want to know when and who scored the goal

class FootballGame { 
  private UUID $id;
  private DateTime $startDate;
  private DateTime $endDate;
  /** @var Goal */
  private array $goals = [];

  public function __contstructor(
    private Team $teamA, 
    private Team $teamB
  ) {
    $this->id = UUID::uuid4();
  }
  ...
}

Better tracking of events

class FootballGame { 
  ...
  public function startGame(): void {
    if (isset ($this->startDate)) {
      throw new GameAlreadyStarted();
    }
    $this->startDate = new DateTime();
  }
  ... 
  public function score(Target $target, Player $scorer): void {
    if (!$this->hasStarted() || $this->hasEnded()) {
      throw new GameIsInactive();
    }
    $this->goals[] = new Goal($target, $scorer, new DateTime());
  }
}

Better tracking of events

Getting the score

We can look at the goals and count based on team

class FootballGame { 
  ...
  /** @return Goal[] */
  public function getGoalsForTeam(Team $team): array {
    return array_filter(
      $this->goals,
      fn (Goal $g) => $g->target()->isTeam($team)
    );
  }
  
  public function getGoalsCountForTeam(Team $team): int {
    return count($this->getGoalsForTeam($tema));
  }
  ...
}

Querying the goals list

Adding some more stuff to track...

class FootballGame { 
  private UUID $id;
  private DateTime $firstHalfStartTime;
  private DateTime $firstHalfEndTime;
  private DateTime $secondHalfStartTime;
  private DateTime $firstExtentionStartTime;
  ...
  /** @var Goal[] */
  private array $goals = [];
  
  /** @var Touch[] */
  private array $touches = [];
  
  /** @var Corner[] */
  private array $corners = [];
  
  /** @var Out[] */
  private array $outs = [];
  
  /** @var PenaltyShot[] */
  private array $penaltyShots = [];
  
  /** @var PenalyCard[] */
  private array $penaltyCards = [];
  
  /** @var PlayerSwap[] */
  private array $playerSwaps = [];
  ...  
}

Let's just record all the events in one array

Let's just record all the events

class FootballGame { 
  private array $events = [];
  
  public function __construct(Team $teamA, Team $teamB) {
    $this->events[] = GameCreatedEvent(UUID::uuid4(), $teamA, $teamB);
  }
  
  public function startFirstHalf(): void {
    if ($this->hasFirstHalfStarted()) {
      throw new FirstHalfHasAlreadyStartedException();
    }
    $this->events[] = new FirstHalfStartedEvent(new DateTime());
  }
  ...
}

Let's just record all the events

class FootballGame { 
  ...
  public function score(Target $target, Player $scorer): void {
    if ($this->isStopped()) {
      throw new GameIsStoppedException();
    }
    $this->events[] = new GoalEvent($target, $player, new DateTime());
  }
  ...
}

Let's just record all the events

class FootballGame { 
  ...
  public function swapPlayers(Player $out, Player $in): void {
    if (!$this->isStopped()) {
      throw new GameIsNotStoppedException();
    }
    if ($this->playerSwapsCount($out->team()) === 3) {
      throw new TeamCanNotSwapAnyMorePlayersException();
    }
    $this->events[] = new PlayersSwappedEvent($out->id, $in->id);
  }
  ...
}

We can get all the information by going through the events and examining them

Querying the events list

class FootballGame {
  ...
  private function hasFirstHalfStarted(): bool {
    foreach ($this->events as $e) {
      if ($e instanceof FirstHalfStartedEvent) {
        return true;
      }
    }
    return false;
  }
  ...
}

Querying the events list

class FootballGame {
  ...
  private function isStopped(): bool {
    $isStopped = true;
    foreach ($this->events as $e) {
      if ($e instanceof GameStoppedEvent) {
        $isStopped = true;
      } else if (
        $e instanceof GameResumedEvent 
        || $e instanceof FirstHalfStartedEvent
      ) {
        $isStopped = false;
      }
    }
    return $isStopped;
  }
  ...
}

Querying the events list

class FootballGame {
  ...
  private function playerSwapsCount(Team $t): int {
    $count = 0;
    foreach ($this->events as $e) {
      if ($e instanceof PlayersSwappedEvent && $t->hasPlayer($e->out->id)) {
        $count++;
      }
    }
    return $count;
  }
  ...
}

An Event

  • Holds information for something which has happened in the past
  • Is immutable
  • A value-object from the perspective of the business rules
  • An entity from infrastructure stand-point

The order of the events is of UTTER importance!!!

An Event

final class PlayersSwappedEvent {

  public function __construct(
    public readonly UUID $out,
    public readonly UUID $in
  ) {}
}

Storing Events

We definitely need:

  • a unique event ID - e.g. UUID v.4
  • aggregate ID
  • aggregate version
  • event type
  • payload

It's good to have:

  • recorded time
  • aggregate type/stream id

An Event Envelope

final class EventEnvelope { 
  private function __construct(
    public readonly UUID $id,
    public readonly string $type,
    public readonly UUID $aggregateId,
    public readonly int $aggregateVersion,
    public readonly object $payload
  ) {}
  
  protected static function createNew(
    object $payload,
    UUID $aggregateId,
    int $aggregateVersion
  ): static {...}
  
  public static function fromArray(array $data): static {...}
  
  public function toArray(): array {...}
}

An Event Store

  • stores your events
  • is append only
  • returns events in the right sequence
  • can return events based on aggregate type/stream
  • can return events based on version range
  • can notify subscribers for new events

Essentially a database which:

Restore our aggregate from events

class FootballGame { 
  private array $events = [];
  
  private function __construct() {};
  
  public static function createNew(Team $teamA, Team $teamB): self {
    $instance = new self();
    $instance->events[] = GameCreatedEvent(UUID::uuid4(), $teamA, $teamB);
    return $instance;
  }
  
  public static function fromEvents(array $events): self {
    $instance = new self();
    $instance->events = $events;
    return $instance;
  }
  ..
}

Projections

Projections

  • allow separation between "domain rules" and data queries/views
  • ...or in other words CQRS...
  • allow for multiple data queries/views
  • can be used on multiple aggregates/streams

Projections

class GameStatistics { 
  public int $totalShots = 0;
  public int $onTargetShots = 0;
  public int $goals = 0;
  ...  
  public static function fromEvents(array $events): self {
    $instance = new self();
    foreach ($events as $e) {
      if ($e instanceof Shot) {
        $this->totalShots++;
        if ($e->onTarget) {
          $this->onTargetShots++;
        }
        if ($e->isGoal) {
          $this->goals++;
        }
      }
      ...
    }
  }
}

Projections

class TeamStatistics { 
  public int   $goals = 0;
  public float $possesionPercent = 0;
  public float $passPercent = 0;
  public float $aerialsWon = 0;
  public float $rating = 0;
  ...  
  public static function fromEvents(array $events): self {
    $instance = new self();
    foreach ($events as $e) {
      ...
    }
  }
}

Snapshots

Snapshots

class FootballGame { 
  private UUID $id;
  private bool $hasStarted = true;
  private bool $hasEnded = false;
  private int $teamAScore = 2;
  private int $teamBScore = 3;

  ...
}

Snapshots

class FootballGame { 
  ...
  public function score(Target $target): void {
    if (!$this->hasStarted || $this->hasEnded) {
      throw new GameIsInactive();
    }
    $this->recordEvent(new GoalEvent($target));
  }
  
  private function applyGoalEvent(GoalEvent $e) {
    if ($e->target->isTeamA()) {
      $this->teamBScore++;
    } else {
      $this->teamAScore++;
    }
  }
}

Snapshots

class FootballGame { 
  ...
  private function recordEvent(Event $e) {
    $this->events[] = $e;
    $this->applyEvent($e);
  }
  
  private function applyEvent(Event $e) {
    match ($e::class) {
      GameStartedEvent::class => $this->applyGameStartedEvent($e),
      GoalEvent::class => $this->applyGoalEvent($e),
      ...
    }
  }
}

Snapshots

class FootballGame { 
  ...
  public static function fromEvents(array $events): self {
    $instance = new self();
    foreach ($events as $e) {
      $instance->applyEvent($e);
    }
  }
}

Snapshots

CREATE TABLE games (
  id UUID not null,
  has_started boolean not null,
  has_ended boolean not null,
  team_a_score unsigned int not null,
  team_b_score unsigned int not null,
  aggregate_version unsigned int not null
)

Snapshots of Projections

  • ElasticSearch for indexing and searching
  • Relational for tracking structure
  • Redis for simple documents
  • ...
  • Sky is the limit

Different projections can use different databases for storage!!!

Dispatching Events

Dispatching Events

Events are dispatched...

  • from the event store
  • only after they have been persisted

Subscribers/Consumers can be:

  • projections
  • anything else... :)

We can put the events on a network and have...

  • a distributed system
  • horizontal scaling
  • microservices
  • extra complexity

Gotchas

  • eventual consistency
  • emitting events during replays
  • querying external data from within a replay
  • changes in business rules
  • fixing state due to bugs
  • mixture of all of the above
  • a whole plethora of stuff...

Thank you!

Milko Kosturkov

@mkosturkov

linkedin.com/in/milko-kosturkov

mailto: mkosturkov@gmail.com

 

These slides:

https://slides.com/milkokosturkov/event-sourcing-the-very-basics

 

Event Sourcing: The Very Basics

By Milko Kosturkov

Event Sourcing: The Very Basics

  • 283