Skip to Content
Course content

172: Practice Exercise: Building a Simple Event Sourcing System in Ruby

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've mostly talked about CRUD—creating a record, updating its columns, and saving the current state to a database. But in complex systems, knowing how you got to the current state is often more important than the state itself. That's where Event Sourcing comes in. Instead of storing a row that says balance: 100, we store a list of events: Deposited(50), Deposited(70), Withdrawn(20).

Defining our Event Types

To start, we need a way to represent these events. I prefer using Struct for this in Ruby because events should be immutable data carriers. They shouldn't have logic; they should just describe something that happened in the past.

MoneyDeposited = Struct.new(:amount, :timestamp)
MoneyWithdrawn = Struct.new(:amount, :timestamp)
AccountOpened = Struct.new(:owner, :timestamp)

By separating these into distinct types, we can use pattern matching (which Ruby has become quite good at) to handle them differently when we reconstruct our account state.

Reconstructing State from the Stream

Now we need the "Aggregate"—the object that actually holds the current balance. The trick here is that the BankAccount doesn't have a save method that writes to a row. Instead, it has an apply method that updates the state based on an event.

class BankAccount
  attr_reader :balance, :owner

  def initialize
    @balance = 0
    @owner = nil
  end

  def apply(event)
    case event
    when AccountOpened
      @owner = event.owner
    when MoneyDeposited
      @balance += event.amount
    when MoneyWithdrawn
      @balance -= event.amount
    end
    self
  end

  def load_from_history(events)
    events.each { |event| apply(event) }
    self
  end
end

Wait, I broke the invariants

I initially wrote the apply method to handle the logic of whether a withdrawal was allowed. I tried putting a raise "Insufficient Funds" inside the MoneyWithdrawn case. That was a mistake.

Here is why: Event Sourcing splits "Command" (the request to do something) from "Event" (the fact that it happened). The apply method is used to replay history. If I put a validation check in apply, then a year from now, if I change the overdraft rules, I might find that I can no longer "load" old accounts because they fail the new validation logic during replay. Replaying events must be deterministic and unconditional. Validations happen before the event is created, not during the replay.

I've fixed the code above to ensure apply simply updates the state. If we wanted to validate a withdrawal, we'd create a separate withdraw(amount) method that checks the balance and then emits a MoneyWithdrawn event.

Gluing it together with a basic Store

To make this useful, we need a way to persist these events. In a production app, you'd use something like EventStoreDB or a Postgres table, but for our exercise, a simple hash will do. I'll create a store that keeps track of streams indexed by an account ID.

class SimpleEventStore
  def initialize
    @streams = Hash.new { |h, k| h[k] = [] }
  end

  def append_to_stream(stream_id, event)
    @streams[stream_id] << event
  end

  def get_stream(stream_id)
    @streams[stream_id]
  end
end

# Let's see it in action
store = SimpleEventStore.new
account_id = "acc_123"

# Simulate activity
store.append_to_stream(account_id, AccountOpened.new("Alice", Time.now))
store.append_to_stream(account_id, MoneyDeposited.new(100, Time.now))
store.append_to_stream(account_id, MoneyWithdrawn.new(30, Time.now))

# Reconstruct the account
history = store.get_stream(account_id)
account = BankAccount.new.load_from_history(history)

puts "Account Owner: #{account.owner}" # Alice
puts "Current Balance: #{account.balance}" # 70

The beauty of this is the audit trail. If Alice asks why her balance is 70, we don't have to guess; we have the exact sequence of events that led to that number.




📋 Practical Task

Exercise: Building a Warehouse Inventory Event Stream

You need to build a simplified inventory tracking system using the Event Sourcing pattern we just covered. Instead of a bank account, you are tracking the quantity of items in a warehouse.

Requirements:

  • Define three event types using Struct: ItemReceived (adds stock), ItemShipped (removes stock), and InventoryAdjusted (sets stock to a specific number, used for manual audits).
  • Create an InventoryItem class with an apply method that handles these three events to maintain a @quantity state.
  • Implement a load_from_history method to rebuild the state from an array of events.
  • Create a small script that:
    1. Appends a sequence of these events to a list (representing a stream).
    2. Reconstructs the InventoryItem from that stream.
    3. Prints the final quantity to the console.

Hint: Remember to keep your apply method purely focused on state updates—do not put validation logic (like preventing negative stock) inside the replay loop!

Rating
0 0

There are no comments for now.

to be the first to leave a comment.