Eventide

At a Glance

Handlers: The Entry Point into a Service

A handler is the entry point to a service. It receives instructions from other services, apps, and clients. You might think of them as controllers in MVC terms, but that's a very loose comparison.

A Handler:

class CommandHandler
  include Messaging::Handle

  handle Deposit do |deposit|
    account_id = deposit.account_id

    account, current_version = store.fetch(account_id, include: :version)

    if account.up_to_date?(deposit)
      logger.warn { "Command ignored. It has been processed previously." }
      return
    end

    deposited = Deposited.follow(deposit)
    deposited.processed_time = time

    stream_name = stream_name(account_id)
    writer.write(deposited, stream_name, expected_version: current_version)
  end
end

This handler does deposits to a bank account.

A handler receives a command, does its work, and when it's done with that work, it reports the status and outcome of that work by publishing an event.

A handler might also respond (or react) to events by other services, or it might respond to events published by its own service (when a service calls itself).


Commands: Telling Services What To Do

Commands are messages. They are the primary inputs to a service. They're just data structures.

A Command:

class Deposit
  include Messaging::Message

  attribute :account_id, String
  attribute :amount, Numeric
  attribute :time, String
end

Events: How Services Tell Each Other What They've Done

An event is also a message, and also just a data structure.

You might think of events as responses, but they're not like responses in an HTTP request/response sense. Events can be received by any number of recipients, not just the sender of the original command. Also, a handler might respond or react to a command by writing many different events.

Often (in simple scenarios), events look a lot like the commands that they respond to.

An Event:

class Deposited
  include Messaging::Message

  attribute :account_id, String
  attribute :amount, Numeric
  attribute :time, String
  attribute :processed_time, String
end

Commands are usually named in the imperative, present tense, e.g.: Deposit. Events are usually named in the past tense, e.g.: Deposited. Events record what has already happened in the past, where as commands are directives and instructions for work that a service carries out.

Events are written to streams. All of the events for a given account are written to that account's stream. If the account ID is 123, the account's stream name is account-123, and all events for the account with ID 123 are written to that stream.

When the deposited event is published in response to handling the deposit stream, it is written to the account's stream.


Entities: The Service's Model Objects

Handlers make use of entities. You might think of entities as model objects, but there's no ORM here, and these are not ORM models.

Entities are data structures, as well. And they may have business logic operations that affect an entity's attributes.

An Entity:

class Account
  include Schema::DataStructure

  attribute :id, String
  attribute :balance, Numeric, :default => 0
  attribute :sequence, Numeric

  def deposit(amount)
    self.balance += amount
  end

  def withdraw(amount)
    self.balance -= amount
  end

  def up_to_date?(message)
    self.sequence >= message.metadata.sequence
  end
end

Stores: Data Retrieval and Caching for a Service's Entities

Entities are retrieved from stores.

In the example handler above, the account entity is retrieved along with the version:

account, current_version = store.fetch(account_id, include: :version)

A Store:

class Store
  include EntityStore

  category 'account'
  entity Account
  projection Projection
  reader EventSource::Postgres::Read
end

But where is the entity? Where is it saved?

It's already saved.

An entity (or model if you prefer) is just a series of things that happened to it. But it doesn't need to be a database record that's constantly worked over, saving and retrieving, continually bending it back and forth until it's deformed, and maybe even inconsistent with reality.

A ORM model is also the sum of its events. But with ORM we discard the events and keep the beaten up model object, with no real way to understand what series of events led to its current state (or it's state at any point in time, really).

When a store retrieves an entity, it's actually retrieving new events, and then applying them to the entity.

When a deposited event is applied to the account entity, the account's balance increases by the amount of the deposit. This would be done by invoking the entity's deposit method with the amount conveyed by the deposited event. A withdrawn event would decrease the account's balance, which is done by invoking the entity's withdraw method with the amount conveyed by the withdrawn event.

Only events that have been published since the last time that an entity was retrieved are read from storage. In practice, only a few or a handful of events are ever read in a single retrieval of an entity.


Projections: The Power of Turning a Stream of Events Into an Entity

When the store retrieves the entity, it runs the projection. But since the entity is event sourced, there's no fixed entity row to retrieve. Instead, the events that pertain to an entity are retrieved, and then processed in order, in order to construct a view of that entity per the things that have happened to it.

The store only retrieves the events that have been recorded since the last retrieval and passes them along to the projection.

The projection has the rules and logic for how an event affects the state of the entity.

A Projection:

class Projection
  include EntityProjection

  apply Deposited do |deposited|
    account.deposit(deposited.amount)
    acount.sequence = deposited.metadata.sequence
  end

  apply Withdrawn do |withdrawn|
    account.withdraw(withdrawn.amount)
    acount.sequence = deposited.metadata.sequence
  end
end

Once the new events are applied to the entity, the store caches the current version of the entity in memory, and returns the entity to the caller (usually, a handler).

The store may also optionally cache the entity on disk as a snapshot. By caching the entity on disk, or snapshotting the entity, lengthy streams don't have to be read in their entirety. Instead, the snapshot is retrieved, and only events that have not been applied to that version of the entity are then read and applied.


And Onward

This is a basic, 1000-foot view of building event-sourced services. There are many other topic to cover, including:

  • Idempotence
  • Concurrency
  • Consumers
  • and a handful of other subjects, which will be covered here

results matching ""

    No results matching ""