KKP BLOG

Personal space

View on GitHub
12 December 2020

DDD course notes for sales challenge

by kkp

Some articles to read before/after this:

Design Challenge

Given a certain business description, provide a design in form of code, drawing, tests or whatever.

Example Design challenge to implement:

Sales Processing:

Actors

Requirements

What ifs

My design of design challenge:

Base Files structure for just sale made looks like that: Domain Structure

And the base domain design for viewing at this link

Here is my explanation for this structure step by step:

Objects

Events

This is written as I go so some questions are still here, along with uncertainties. On to the code. This app’s code is available at GH this time it is fully flesh out mini app that works (with some mocks and assumptions, like 1 product for 1 sale but in many quantities, no discounts, simple relationships, but still).

Note

This uses nice helpers by Arkency as defined here I use almost the same (minus the types that I will not use, slight modification in Event) in my lib/ since they are nice and simple and obviously made to work with RES. I recommend checking the source code of them it is some clever stuff! I will skip showing snippets of migrations, application.rb config etc. cause that is not what is important in this lesson. This note is mostly about contracting the idea and design. I have included basic seeds to make the base example work.

#command

module Sales
  class MakeSale < Command
    attribute :sale_id, Types::ID
    attribute :product_id, Types::ID
    attribute :quantity, Types::Coercible::Integer
    alias :aggregate_id :sale_id
  end
end

#command handler

module Sales
  class OnSaleMake
    include CommandHandler

    def call(command)
      with_aggregate(SaleRoot, command.aggregate_id) do |sale|
        sale.make_sale(command.sale_id, command.quantity, command.product_id)
      end
    end
  end
end

#event

module Sales
  class SaleMade < Event
    attribute :sale_id, Types::ID
    attribute :product_id, Types::ID
    attribute :quantity, Types::Integer
  end
end

#Root

require 'aggregate_root'

  require 'aggregate_root'

module Sales
  class SaleRoot
    include AggregateRoot

    NotInStock = Class.new(StandardError)
    NoAgreementWithCompany = Class.new(StandardError)


    def initialize(id)
      @id = id
      @state = "in_progress"
    end

    def make_sale(sale_id, quantity, product_id)
      raise NotInStock if product_quantity_not_enough?(product_id,quantity)
      raise NoAgreementWithCompany if no_agreement?(product_id, sale_id)
      apply SaleMade.new(data: { sale_id: sale_id, quantity: quantity, product_id: product_id })
    end

    on SaleMade do |event|
      @state = "sold"
    end

    private

    def no_agreement?(product_id, sale_id)
      # bad i know, thats not the point here though
      Agreement.find_by(
        owner: SalesPerson.find(Sales::Sale.find(sale_id)),
        company_id: Products::Product.find(product_id).company.id
      )
    end

    def product_quantity_not_enough?(product_id, quantity)
      Products::Product.find(product_id).quantity - quantity < 0
    end
  end
end


#readmodels

module Sales
  class OnSaleMadeEvent
    def call(event)
      find_sale(event.data[:sale_id])
      find_product(event.data[:product_id])
      @sale.update(total: event.data[:quantity] * @product.price)
    end

    private

    def find_product(id)
      @product ||= Products::Product.find(id)
    end

    def find_sale(id)
      @sale ||= Sales::Sale.find(id)
    end
  end
end

module Products
  class OnSaleMadeEvent
    def call(event)
      product = Products::Product.find(event.data[:product_id])
      product.update(quantity: product.quantity - event.data[:quantity])
    end
  end
end

#config

# Subscribe event handlers below
  Rails.configuration.event_store.tap do |store|
    #I added Event part cause it was too similair to command handler (made -> make) at this stage I still need it to make it easier to different classes
    store.subscribe(Sales::OnSaleMadeEvent, to: [Sales::SaleMade])
    store.subscribe(Products::OnSaleMadeEvent, to: [Sales::SaleMade])
    #Note that two read models subscribe to the same event.s
    store.subscribe_to_all_events(->(event) { Rails.logger.info(event.event_type) })
  end

  Rails.configuration.command_bus.tap do |bus|
    # ADD COMMAND HANDLERs TO THE COMMANDs
    bus.register(Sales::MakeSale, Sales::OnSaleMake.new)
  end
end

This is what the first version looks like, just a base scenario of making a sale by itself.

irb(main):004:0> Products::Product.find(7).quantity
  Products::Product Load (0.6ms)  SELECT "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 7], ["LIMIT", 1]]
=> 11

We have some Product7 in stock, lets go and register a sale Form

> Products::Product.find(7).quantity
Products::Product Load (0.6ms)  SELECT "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 7], ["LIMIT", 1]]
=> 4
> Sales::Sale.last.total.to_f
Sales::Sale Load (0.6ms)  SELECT "sales".* FROM "sales" ORDER BY "sales"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> 49.0

Stuff that we wanted to happened did happen. Awesome. To check up on the events


ap RailsEventStoreActiveRecord::Event.last
  RailsEventStoreActiveRecord::Event Load (0.3ms)  SELECT "event_store_events".* FROM "event_store_events" ORDER BY "event_store_events"."id" DESC LIMIT ?  [["LIMIT", 1]]
#<RailsEventStoreActiveRecord::Event:0x000055f99a8d6118> {
            :id => "e880e3bf-0df0-4cbc-bf98-f89dc217ec22",
    :event_type => "Sales::SaleMade",
      :metadata => "---\n:timestamp: 2020-12-09 12:01:24.355745249 Z\n:remote_ip: \"::1\"\n:request_id: c2a10488-3abc-4b50-a540-ad7630b2c040\n",
          :data => "---\n:sale_id: 2\n:product_id: 7\n:quantity: 1\n",
    :created_at => Wed, 09 Dec 2020 12:01:24 UTC +00:00
}

We have event data saved. Now we need to deal with commissions logic, and sales person getting correct commission from the company that owns the product he sold. We will also account for the edge cases (what ifs)

Saga Note

I think this process could also work as a saga, with read models for counting total money made from sale, subtracting products from inventory and calculating commissions. But for the sake of getting a better grasp on what has been mostly covered so far I continued making it that way.

I decided to create a new Bounded Context for commissions:

  1. They need to react to certain events, like change of agreement, sale made
  2. Those events come from different BC’s. Retracing back to older notes that I made, this point makes a lot more sense now
    Communication between BC happens through Domain Events, so when something happens in the code of BC, that can concern
    other BC, an event is being published. Other contexts receive this event, and does something with it.
    

So commissions code looks somewhat like that (below). Im skipping here displaying the commissions since that rather straightforward. All that we need to do is collect correct events. In case of agreement change that modifies the commissions in past sales that is also super easy thanks to event sourcing. When we change agreements, we fire up calculation for all sales of given sales person or platform depending on who changes the agreement. When we got those new calculations, the display would just need to take events for each sale that are the newest (specific events about calculating the commissions). Then display the correct value field from the event. I did not write that code since its rather easy and straightforward and the code below for base commissions already help me a lot in grasping the whole idea and design of the rest is enough for me here. Pretty happy with how it all is coming together.

Commissions


#read models

module Commissions
   class OnCommissionsApplied
      def call(event)
         # here we could continue with notyfing about commissionsn etc. but in general now displayt matters more but
         # that is outside the scope of this example app
      end
   end
end

# frozen_string_literal: true

module Commissions
   class OnSaleMadeEvent
      def call(event)
         Rails.configuration.command_bus.(Commissions::DistributeSaleCommissions.new(sale_id: event.data[:sale_id]))
      end
   end
end


#AR

require 'aggregate_root'

module Commissions
   class CommissionRoot
      include AggregateRoot

      def initialize(id)
         @id = id
      end

      def distribute_commissions(sale_id)
         total = calculate_sale_total(sale_id)
         apply CommissionsApplied.new(
                 data: {
                         sale_id: sale_id,
                         commission_sales_person: calculate_commission(sales_person_agreement.percentage, total),
                         commission_platform: calculate_commission(platform_agreement.percentage, total)
                 }
         )
      end

      on CommissionsApplied do |event|
         puts "handle"
      end

      private

      def calculate_commission(percentage, total)
         total.to_f * percentage / 100.to_f
      end

      def sales_person_agreement
         Agreement.find_by(owner: @sale.sales_person, company: company)
      end

      def platform_agreement
         Agreement.find_by(owner: platform, company: company)
      end

      def company
         @company ||= Company.find(@sale.company_id)
      end

      def platform
         #doing more for platform in terms of model and architecture seemed like an overkill for this lesson
         @platform ||= company.platform
      end

      def calculate_sale_total(sale_id)
         # Not getting total from model in case of race issue between events. This is why saga might have been a good idea.
         # A different solution is passing more data (not sale ID but sale info, so price of product and quantity, to avoid
         # the query). But this is the simplest in terms of implementation and fastest, the least code, and this challenge
         # is mostly about design and getting a better grip on the ideas themselves.
         sale(sale_id).quantity * Products::Product.find(@sale.product_id).price.to_f
      end

      def sale(sale_id)
         @sale ||= Sales::Sale.find(sale_id)
      end
   end
end

#event
module Commissions
   class CommissionsApplied < Event
      attribute :sale_id, Types::ID
      attribute :commission_sales_person, Types::Float
      attribute :commission_platform, Types::Float
   end
end

#command

module Commissions
   class DistributeSaleCommissions < Command
      attribute :sale_id, Types::ID
      alias :aggregate_id :sale_id
   end
end

#CH

module Commissions
   class OnDistributeSaleCommissions
      include CommandHandler
      def call(command)
         with_aggregate(CommissionRoot, command.aggregate_id) do |commission|
            commission.distribute_commissions(command.sale_id)
         end
      end
   end
end


#updated config

require 'rails_event_store'
require 'aggregate_root'
require 'arkency/command_bus'

Rails.configuration.to_prepare do
   Rails.configuration.event_store = RailsEventStore::Client.new
   Rails.configuration.command_bus = Arkency::CommandBus.new

   AggregateRoot.configure do |config|
      config.default_event_store = Rails.configuration.event_store
   end

   # Subscribe event handlers below
   Rails.configuration.event_store.tap do |store|
      # store.subscribe(Movies::OnMovieAddedToRepertoire, to: [Movies::MovieAddedToRepertoire]) #READ MODEL SUBSCRIBES TO EVENT
      #I added Event part cause it was too similair to command handler (made -> make) at this stage I still need it to make it easier to different classes
      store.subscribe(Sales::OnSaleMadeEvent, to: [Sales::SaleMade])
      store.subscribe(Products::OnSaleMadeEvent, to: [Sales::SaleMade])
      store.subscribe(Commissions::OnSaleMadeEvent, to: [Sales::SaleMade])
      store.subscribe(Commissions::OnCommissionsApplied, to: [Commissions::CommissionsApplied])
      #Note that more than 1 read models subscribe to the same event
      store.subscribe_to_all_events(->(event) { Rails.logger.info(event.event_type) })
   end

   Rails.configuration.command_bus.tap do |bus|
      # ADD COMMAND HANDLERs TO THE COMMANDs
      # bus.register(Movies::AddMovieToRepertoire, Movies::OnMovieAddToRepertoire.new(imdb_adapter: OpenStruct.new(fetch_number: "Mocked")))
      bus.register(Sales::MakeSale, Sales::OnSaleMake.new)
      bus.register(Commissions::DistributeSaleCommissions, Commissions::OnDistributeSaleCommissions.new)
   end
end

I have simplified errors to the cases where there is no agreement or products in stock. There are obviously other, better more complex solutions to it, but writing all this in code here would be overkill for a design challenge. So a better solution would be something like:

tags: