KKP BLOG

Personal space

View on GitHub
25 November 2020

DDD course notes for design and a challenge!

by kkp

Design Notes

Vocabulary and reminders

AR - Aggregate Root
BC - Bounded Context
Authorization - what a person can do
Authentication - who a person is

Some General Rules

Example Design challenge to implement:

Workshops:

Scenario

My solution to design challenge:

My thought process went as follow (along with the code): At first I thought that the easiest thing to identify is all the events and commands to trigger the events. Commands I came up with are obviously scheduling the edition, registering a participant for it, booking a venue, and happy and unhappy paths described, so confirming it or cancellation. This gives me 5 commands (put into 1 module file for clarity, they are small since its just design, not a real implementation):

module Workshops
  module Commands
    class ScheduleEdition < Command
      attribute :edition_id, Types::UUID
      attribute :date, Types::DateTime
    end

    class RegisterForEdition < Command
      attribute :edition_id, Types::UUID
      attribute :user_id, Types::UUID # assuming user is the participant here
    end

    class BookVenue < Command
      attribute :edition_id, Types::UUID
      attribute :venue_id, Types::UUID
    end

    class ConfirmEdition < Command
      attribute :edition_id, Types::UUID
    end

    class CancelEdition < Command
      attribute :edition_id, Types::UUID
    end
  end
end

So each and every of those commands would have their CommandHandler hooked into CommandBus. Command gets called, CommandHandler gets fired to do what needs to be done so uses AggregateRoot to publish an event. Here are those events:

module Workshops
  module Events

    # Assuming existence of just some base event class
    class ScheduledEdition < Event
      attribute :edition_id, Types::UUID
      attribute :date, Types::DateTime
    end

    class UserRegisteredForEdition < Event
      attribute :edition_id, Types::UUID
      attribute :user_id, Types::UUID
    end

    class BookedVenue < Event
      attribute :edition_id, Types::UUID
      attribute :venue_id, Types::UUID
    end

    class ConfirmedEdition < Event
      attribute :edition_id, Types::UUID
    end

    class CanceledEdition < Event
      attribute :edition_id, Types::UUID
    end
  end
end

What really sticks out so far for me in DDD (or maybe just this approach) is how similar events and commands structure is. I know this probably comes down to examples being really simple, but events like this are identical to commands, and with little differences in naming, it can be confusing at time. But this might come from the simplicity of the examples. It probably is not a problem later on, with a good files structure, careful naming and general better understanding of the subject, implementation and domain.

Anyway, next step has to be aggregate root that will get used by command handlers to publish events.

#lets go with arkency aggregate root formula (or rather my understanding of it)
require 'aggregate_root'

module Workshop
  module Edition
    include AggregateRoot

    def initialize(id)
      @id = id
      @status = workshop.status #we start each edition as a draft, and then change the status based on events
      @users_registered_size ||= users_count
      @start_date = workshop.start_date
    end

    def schedule_edition(date:)
      apply ScheduledEdition.new(data: {edition_id: @id, date: date})
    end

    on ScheduledEdition do |event|
      @status = :scheduled
      @start_date = event.data[:date]
    end

    def register_for_edition(user_id:)
      apply UserRegisteredForEdition.new(data: {edition_id: @id, user_id: user_id})
    end

    on UserRegisteredForEdition do |event|
      @users_registered_size += 1
    end

    def book_venue(venue_id:)
      apply BookedVenue.new(data: {edition_id: @id, venue_id: venue_id})
    end

    on BookedVenue do |event|
      @status = :booked
    end

    def confirm_edition
      apply ConfirmedEdition.new(data: {edition_id: @id}) if can_confirm?
    end

    on ConfirmedEdition do |event|
      @status = :confirmed
    end

    def cancel_edition
      apply CanceledEdition.new(data: {edition_id: @id}) if can_cancel?
    end

    on CanceledEdition do |event|
      @status = :cancelled
    end

    private

    def users_count
      workshop.users.size
    end

    def workshop
      @workshop ||= WorkshopEdition.find(@id)
    end

    def can_confirm?
      @users_registered_size >= 20 && @status = :booked &&
        @start_date.beginning_of_day >= (Time.now + 14.days).beginning_of_day
    end


    def can_cancel?
      @users_registered_size < 20 && @status >= :booked &&
        @start_date.beginning_of_day == (Time.now + 14.days).beginning_od_day
    end
  end
end

In the aggregate root what i am most uncertain of is if setting the state here is okey, or should it be determined by event - so commands would set the state, and events register them? It was hard to determine this, since I had no idea how useful any of those options would be.

With base AR somewhat ready I could add command handlers. That handle issuing events to AR. Those are simple handlers, they are even initialized as empty, with just command passed to their calls.

# We assumme each handler has access to aggregate root through `with_aggregate` just like in arkency RES gem implementation
module Workshops
  module Handlers
     class OnEditionSchedule < CommandHandler
       def call(command)
         with_aggregate(Edition, command.aggregate_id) do |edition|
           edition.schedule_edition(date: command.date)
         end
       end
     end

     class OnEditionRegistration < CommandHandler
       def call(command)
         with_aggregate(Edition, command.aggregate_id) do |edition|
           edition.register_for_edition(user_id: command.user_id)
         end
       end
     end

     class OnVenueBooking < CommandHandler
       def call(command)
         with_aggregate(Edition, command.aggregate_id) do |edition|
           edition.book_venue(venue_id: command.venue_id)
         end
       end
     end

     class OnEditionConfirm < CommandHandler
       def call(command)
         with_aggregate(Edition, command.aggregate_id) do |edition|
           edition.confirm_edition
         end
       end
     end

     class OnEditionCancel < CommandHandler
       def call(command)
         with_aggregate(Edition, command.aggregate_id) do |edition|
           edition.cancel_edition
         end
       end
     end
  end
end

# CONFIG
Rails.configuration.command_bus.tap do |bus|
  bus.register(Workshops::Commands::ScheduleEdition, Workshops::Handlers::OnEditionSchedule.new)
  bus.register(Workshops::Commands::RegisterForEdition, Workshops::Handlers::OnEditionRegistration.new)
  bus.register(Workshops::Commands::BookVenue, Workshops::Handlers::OnVenueBooking.new)
  bus.register(Workshops::Commands::ConfirmEdition, Workshops::Handlers::OnEditionConfirm.new)
  bus.register(Workshops::Commands::CancelEdition, Workshops::Handlers::OnEditionCancel.new)
end

And then of course ReadModels and their config


module Workshops
  class ReadModels
    class ScheduledEdition
      def call(event)
        WorkshopEdition.find(event.data[:edition_id]).update(date: event.data[:date])
      end
    end

    class OnUserRegistered
      def call(event)
        workshop = WorkshopEdition.find(event.data[:edition_id])
        workshop.users << User.find(event.data[:user_id])
      end
    end

    class OnBookedVenue
      def call(event)
        WorkshopEdition.find(event.data[:edition_id]).update(venue_id: event.data[:venue_id])
      end
    end

    class OnConfirmedEdition
      def call(event)
        WorkshopEdition.find(event.data[:edition_id]).confirm
      end
    end

    class OnCanceledEdition
      def call(event)
        WorkshopEdition.find(event.data[:edition_id]).cancel
      end
    end
  end
end
Rails.configuration.event_store.tap do |store|
  store.subscribe(Workshops::ReadModels::OnEditionSchedule, to: [Workshops::Events::ScheduledEdition])
  store.subscribe(Workshops::ReadModels::OnUserRegistered, to: [Workshops::Events::UserRegisteredForEdition])
  store.subscribe(Workshops::ReadModels::OnBookedVenue, to: [Workshops::Events::BookedVenue])
  store.subscribe(Workshops::ReadModels::OnConfirmedEdition, to: [Workshops::Events::ConfirmedEdition])
  store.subscribe(Workshops::ReadModels::OnCanceledEdition, to: [Workshops::Events::CanceledEdition])
end

I am not happy about some of the stuff here but I was not sure about many of them, so I left it as it is to compare with the actual design solution to look for better solutions for those problems. My notes of my problems along with solutions and how it should be done after reviewing the solution below along with pointing up the stuff that was actually good it seems.

Notes after reviewing the walkthrough to: design challenge:


module Workshops
  module CancellationPolicy
    def initlize(command_bus)
      @command_bus = command_bus
    end

    def call(event)
      edition_id = event.data.fetch(:edition_id)
      @events[edition_id] << event
      return confirm_edition if can_be_confirmed?(edition)
      cancel_edition if can_be_cancelled?(edition)
    end

    private

    def can_be_confirmed?(edition_id)
      ...
    end

    def can_be_cancelled?(edition_id)
      ...
    end

    def enough_people_registered?(edition_id)
      ...
    end

    def confirm(edition_id)
      @command_bus.(ConfirmEdition.new(edition_id))
    end

    def cancel(edition_id)
      @command_bus.(CancelEdition.new(edition_id))
    end
  end
end

-Thought: there was a nice way of scheduling the event into the future presented, which I liked. So instead of classical cron job we could do something like this:

module Calendar
  class TwoWeeksBeforeEditionReached < RailsEventStore::Event
  end

  class Deadline
    include Sidekiq::Worker
    def perform(edition__id)
      Rails.configuration.event_store.publish(TwoWeeksBeforeEditionReached.new(data: {
        edition_id: edition_id
      }))
    end
  end
end

deadline = -> (edition_scheduled) do
  Calendar::Deadline.perform_at(
    edition_scheduled.data.fetch(:start_date) - 2.weeks,
    edition_scheduled.data.fetch(:edition_id)
  )
end

Rails.configuration.event_store.subscrive(deadline, to: [EditionScheduled])

I will try to make the next challenge more as an small app based on this experience and I think it will be better.

tags: