Event sourcing: rabotnik, part 2

This article develops the last missing feature of rabotnik and we take a look at how to manage state in order to accept and validate commands.

In part 1 we looked at implementing a todo list manager — rabotnik — using event sourcing. Using a test-driven approach, we've covered two use cases: creating and listing todo items. The missing use case is marking a todo item as complete.

Before jumping into the code, we need a short definition of the feature, as "marking a todo as complete" is a bit vague. The behavior we're looking for is:

The first requirement means we need to deviate a bit from how we've been handling commands so far. For capturing a todo it was enough to just look at the incoming parameters and log them. Marking todos as completed however requires us to look at the event log to figure whether the todo in question has been captured before at all.

Marking todos as completed

As usual, we write a test case first:

# test/rabotnik_test.rb
def test_rabotnik_marking_a_todo_as_completed_fails_if_todo_has_not_been_captured
  app = Rabotnik::App.new
  mark_as_completed = Rabotnik::MarkTodoAsCompleted.new(todo_id: '1')
  result = app.handle_command(mark_as_completed)

  assert_equal([:todo_not_found], result.errors)
end

This test makes sure that we receive an error from the application when trying to mark a todo, that hasn't been captured before, as completed. The new instance of Rabotnik::App comes with a fresh and empty event log, so we can be sure that no todo has been already captured.

As expected, the test fails:

  1) Error:
  RabotnikTest#test_rabotnik_marking_a_todo_as_completed_fails_if_todo_has_not_been_captured:
  NameError: uninitialized constant Rabotnik::MarkTodoAsCompleted
      /home/dhamidi/code/ruby/rabotnik/test/rabotnik_test.rb:33:in `test_rabotnik_marking_a_todo_as_completed_fails_if_todo_has_not_been_captured'

Let's add the missing command definition in the Rabotnik module:

# lib/rabotnik.rb
# module Rabotnik
class MarkTodoAsCompleted
  attr_reader :todo_id
  def initialize(todo_id:)
    @todo_id = todo_id
  end
end

Running the tests again fails again, this time with a more interesting error!

  1) Error:
  RabotnikTest#test_rabotnik_marking_a_todo_as_completed_fails_if_todo_has_not_been_captured:
  NoMethodError: undefined method `text' for #<Rabotnik::MarkTodoAsCompleted:0x0055e5328ee910 @todo_id="1">
      /home/dhamidi/code/ruby/rabotnik/lib/rabotnik.rb:12:in `handle_command'
      /home/dhamidi/code/ruby/rabotnik/test/rabotnik_test.rb:34:in `test_rabotnik_marking_a_todo_as_completed_fails_if_todo_has_not_been_captured'

What is happening here? The application fails to handle that command because it doesn't respond to a method called text. Looking at Rabotnik::App#handle_command it becomes clear why this is the case:

# lib/rabotnik.rb
# class Rabotnik::App
def handle_command(command)
  event = TodoCaptured.new(todo_id: SecureRandom.uuid, text: command.text)
  @event_store.append event
  @todos.handle_event event
  Result.new([event], nil)
end

Since we only had to handle one command before, we've hard-coded #handle_command to handle just that one type of command, Rabotnik::CaptureTodo. Now it is time to change that!

Generalizing commands

We're looking at the need to react differently to different commands the application receives. If our application needs to handle N types of commands, we need N different behaviors. The easiest way to achieve this is to add an #execute method to our command objects, which returns a list of events and (possibly empty) list of errors (represented by the Result type which was introduced in the previous installment).

The implementation CaptureTodo#execute is easy: we just take first line of #handle_command and return a Result instead of just a single event. Also, we either need to change command.text to either self.text or just text. Here's the final result:

# lib/rabotnik.rb
# class Rabotnik::CaptureTodo
def execute
  Result.new([TodoCaptured.new(todo_id: SecureRandom.uuid,
                               text: text)],
              nil)
end

The #execute method for our new command is equally simple, we're just returning the error expected by the test:

# lib/rabotnik.rb
# class Rabotnik::MarkTodoAsCompleted
def execute
  Result.new([], [:todo_not_found])
end

Finally we need to change Rabotnik::App#handle_command to invoke the command's #execute method and handle the result it returned:

# lib/rabotnik.rb
# class Rabotnik::App
def handle_command(command)
  result = command.execute
  result.events.each do |event|
    @event_store.append event
    @todos.handle_event event
  end
  result
end

That's all that was necessary to make the tests pass!

# Running:

.........

Finished in 0.002010s, 4477.5740 runs/s, 5472.5905 assertions/s.

9 runs, 11 assertions, 0 failures, 0 errors, 0 skips

Taking history into account

Next up is the interesting bit: handling previous events. Here's the next test case for making sure that we get a "todo marked as completed" event back when handling MarkTodoAsCompleted commands.

# test/rabotnik_test.rb
def test_rabotnik_marking_a_todo_as_completed_returns_event_if_todo_has_been_captured_before
  event_store = Rabotnik::InMemoryEventStore.new

  todo_id = "47b4851f-19d0-4f88-b8e5-e921bcd890c2"
  captured = Rabotnik::TodoCaptured.new(
    todo_id: todo_id,
    text: 'test',
  )
  event_store.append(captured)

  app = Rabotnik::App.new(event_store: event_store)

  mark_as_completed = Rabotnik::MarkTodoAsCompleted.new(todo_id: todo_id)

  result = app.handle_command(mark_as_completed)

  assert_nil result.errors

  completed = result.events.first
  assert_equal :todo_marked_as_completed, completed.event_name
  assert_equal todo_id, completed.todo_id
end

Nothing spectacular going on here: we're adding a TodoCaptured event to the event store used by the application and then check that sending a MarkTodoAsCompleted results in no error and an event.

This test of course results in a failure:

1) Failure:
RabotnikTest#test_rabotnik_marking_a_todo_as_completed_returns_event_if_todo_has_been_captured_before [/home/dhamidi/code/ruby/rabotnik/test/rabotnik_test.rb:54]:
Expected [:todo_not_found] to be nil.

But how do we fix it? The MarkTodoAsCompleted command needs access to the current state of a todo, based on the list of events for that todo so far. Instead of tracking the state for individual todos, we could also just look up todos in the Todos view. That saves us some time right now, but can cause problems down the line. If we want views to be discardable and flexible for display purposes, we cannot rely on them being there or having a certain shape for writing new data.

However, since this the application is small and nothing is keeping us from splitting things up later, we'll just go with what we have already and query data from the Todos view. After all, that's the same data we will be presenting to the user and thus the same data the user will base his or her decisions on.

With this decision made, let's look at how MarkTodoAsCompleted#execute would look like ideally:

# lib/rabotnik.rb
# class Rabotnik::MarkTodoAsCompleted
def execute
  todo = ? # get a todo from somewhere
  if todo
    Result.new(
        [TodoMarkedAsCompleted.new(todo_id: todo_id)],
        nil,
    )
  else
    Result.new(
        [],
        [:todo_not_found],
    )
  end
end

That's a straightforward solution to the problem, the thing is just that we don't have a way to find a todo yet (as indicated by the question mark). We can solve that by passing in the application instance that handles the command as the first argument to execute:

# lib/rabotnik.rb
# class Rabotnik::MarkTodoAsCompleted
def execute(application)
  todo = application.query(:todos).find_by_id(todo_id)
  # ...
end

Problem solved! Well, almost. The #find_by_id method is something we just made up, because it is convenient. Let's implement it:

# lib/rabotnik.rb
# class Rabotnik::Views::Todos
module Views
  class Todos
    Todo = Struct.new(:id, :text)

    attr_reader :all

    def initialize
      @all = []
    end

    def find_by_id(id)
      @all.find { |todo| todo.id == id }
    end

    def handle_event(event)
      if event.event_name == :todo_captured
        @all << Todo.new(event.todo_id, event.text)
      end
    end
  end
end

Several things changed:

Unfortunately our test is still failing:

1) Failure:
RabotnikTest#test_rabotnik_marking_a_todo_as_completed_returns_event_if_todo_has_been_captured_before [/home/dhamidi/code/ruby/rabotnik/test/rabotnik_test.rb:54]:
Expected [:todo_not_found] to be nil.

Why is that? The application ignores any events that already are in the event store, so when we construct a new application instance in the test, our Todos view stays empty, even though there are some events in the event store. An easy way to address this problem is to restore the current state from the event store when initializing the application:

# lib/rabotnik.rb
# class Rabotnik::App
class App
  def initialize(event_store: InMemoryEventStore.new)
    @todos = Views::Todos.new
    @event_store = event_store

    replay_history!
  end

  # ...

  private
  def replay_history!
    @event_store.each do |event|
      @todos.handle_event event
    end
  end
end

Lo and behold, that makes the test pass!

# Running:

..........

Finished in 0.001580s, 6330.9932 runs/s, 8863.3904 assertions/s.

10 runs, 14 assertions, 0 failures, 0 errors, 0 skips

Keeping the view in sync

Now on to the last step: marking todos as "completed" in the Todos view.

Here's the test for that behavior:

# test/rabotnik_test.rb
def test_rabotnik_query_todos_marks_completed_todos
  app = Rabotnik::App.new
  app.handle_command(Rabotnik::CaptureTodo.new(text: 'a todo'))
  app.handle_command(Rabotnik::CaptureTodo.new(text: 'another todo'))
  todos = app.query(:todos)
  to_complete = todos.all.first
  app.handle_command(Rabotnik::MarkTodoAsCompleted.new(todo_id: to_complete.id))

  assert todos.find_by_id(to_complete.id).completed?
end

We capture two todos, mark the first one as completed and then expect the first todo to respond with true to completed?.

Making this test pass is again a simple matter: when the Todos view receives a TodoMarkedAsCompleted event, we mark the corresponding todo as completed:

# lib/rabotnik.rb
# class Views::Todos
Todo = Struct.new(:id, :text, :state) do
  def completed?
    state == :completed
  end
end

def handle_event(event)
  case event.event_name
  when :todo_captured
    @all << Todo.new(event.todo_id, event.text)
  when :todo_marked_as_completed
    find_by_id(event.todo_id).state = :completed
  end
end

Conclusion

Making that final test case pass concludes this installment. We made the application handle a new command and had to think about how to handle current state. By being able to mark todos as completed, we've completed the implementation of the basic use cases defined in the first article

There still is no way to use the program from outside of a ruby script and no changes are persisted. In the next installment we will solve those problems by introducing an append-only log file for storing events and provide a command line frontend to the three use cases.