Upgrade to Pro — share decks privately, control downloads, hide ads and more …

15 years of Rails with Domain Driven Design - l...

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.

15 years of Rails with Domain Driven Design - lessons learnt

How to use AI to transform your Rails app towards a DDD application. The resulting architecture is AI-native and allows non-technical people to create some of the new features - especially read models.

Avatar for Andrzej Krzywda

Andrzej Krzywda

May 30, 2026

More Decks by Andrzej Krzywda

Other Decks in Technology

Transcript

  1. AI Business Developers Code push slop use more AI push

    slop more complexity more tokens pay more
  2. AI Business Developers Code writes new features proud of code

    architect infra gets cleaner generates tests transforms the code makes NO MISTAKES
  3. Inventory Pricing CRM Catalog Shipments Taxes Invoicing x14 Domains Orders

    Customers Products ClientOrders ClientInbox PublicO ff er Read models x16 Reservation Shipment InvoiceGeneration Onboarding Process Managers x20 Modern DDD building blocks commands + events
  4. 80% of Rails app tra ff i c is reads

    typical Rails schema write schema as core reads separated
  5. create_table "customers", id: :uuid, force: :cascade do |t| t.string "name"

    t.boolean "vip", t.decimal "paid_orders_summary", t.uuid "account_id" t.uuid "store_id" end
  6. module Customers class Customer < ApplicationRecord self.table_name = "customers" end

    private_constant :Customer def self.customers_for_store(store_id) Customer.where(store_id: store_id) end def self.find_customer_in_store(customer_id, store_id) Customer.where(store_id: store_id).find(customer_id) end class Configuration def call(event_store) event_store.subscribe(RegisterCustomer.new, to: [Crm::CustomerRegistered]) event_store.subscribe(AssignStoreToCustomer.new, to: [Stores::CustomerRegistered]) event_store.subscribe(PromoteToVip.new, to: [Crm::CustomerPromotedToVip]) event_store.subscribe(UpdatePaidOrdersSummary.new, to: [Fulfillment::OrderConfirmed]) event_store.subscribe(ConnectAccount.new, to: [Authentication::AccountConnectedToClient]) end end end 80 LOC Read model
  7. module Customers class Configuration def call(event_store) event_store.subscribe(RegisterCustomer.new, to: [Crm::CustomerRegistered]) event_store.subscribe(AssignStoreToCustomer.new,

    to: [Stores::CustomerRegistered]) event_store.subscribe(PromoteToVip.new, to: [Crm::CustomerPromotedToVip]) event_store.subscribe(UpdatePaidOrdersSummary.new, to: [Fulfillment::OrderConfirmed]) end end end Read model
  8. module Customers class PromoteToVip def call(event) promote_to_vip(event) end private def

    promote_to_vip(event) find(event.data.fetch(:customer_id)).update(vip: true) end def find(customer_id) Customer.where(id: customer_id).first end end end Read model
  9. Inventory Pricing CRM Catalog Shipments Taxes Invoicing x14 Domains Orders

    Customers Products ClientOrders ClientInbox PublicO ff er Read models x16 Reservation Shipment InvoiceGeneration Onboarding Process Managers x20
  10. Domain module Inventory class StockLevelChanged < Infra::Event end end def

    supply(quantity) apply StockLevelChanged.new( data: { product_id: @product_id, quantity: quantity, stock_level: (@in_stock || 0) + quantity } ) apply_availability_changed end
  11. module Inventory class InventoryEntry include AggregateRoot InventoryNotAvailable = Class.new(StandardError) InventoryNotEvenReserved

    = Class.new(StandardError) def initialize(product_id) @product_id = product_id @reserved = 0 end def supply(quantity) apply StockLevelChanged.new( data: { product_id: @product_id, quantity: quantity, stock_level: (@in_stock || 0) + quantity } ) apply_availability_changed end Domain def apply_availability_changed apply AvailabilityChanged.new( data: { product_id: @product_id, available: availability } ) if stock_level_defined? end on StockLevelChanged do |event| @in_stock = event.data.fetch(:stock_level) end
  12. Inventory Pricing CRM Catalog Shipments Taxes Invoicing x14 Domains Orders

    Customers Products ClientOrders ClientInbox PublicO ff er Read models x16 Reservation Shipment InvoiceGeneration Onboarding Process Managers x20
  13. module Processes class ShipmentProcess include Infra::ProcessManager.with_state { ProcessState } subscribes_to(

    Shipping::ShippingAddressAddedToShipment, Fulfillment::OrderRegistered, Fulfillment::OrderConfirmed, Stores::OfferRegistered ) private def act case state in { shipment: :address_set, order: :placed } register_shipment submit_shipment in { shipment: :address_set, order: :confirmed } register_shipment submit_shipment authorize_shipment else end end 67 LOC def apply(event) case event when Shipping::ShippingAddressAddedToShipment state.with(shipment: :address_set) when Fulfillment::OrderRegistered state.with(order: :placed) when Fulfillment::OrderConfirmed state.with(order: :confirmed) when Stores::OfferRegistered state.with(store_id: event.data.fetch(:store_id)) end end def register_shipment return unless state.store_id command_bus.call( Stores::RegisterShipment.new( shipment_id: id, store_id: state.store_id ) ) end def submit_shipment command_bus.call(Shipping::SubmitShipment.new(order_id: id)) end def authorize_shipment command_bus.call(Shipping::AuthorizeShipment.new(order_id: id)) end def fetch_id(event) event.data.fetch(:order_id) end ProcessState = Data.define(:order, :shipment, :store_id) do def initialize(order: nil, shipment: nil, store_id: nil) = super end end end Process Managers
  14. module Processes class ShipmentProcess include Infra::ProcessManager.with_state { ProcessState } subscribes_to(

    Shipping::ShippingAddressAddedToShipment, Fulfillment::OrderRegistered, Fulfillment::OrderConfirmed, Stores::OfferRegistered )
  15. module Processes class ShipmentProcess include Infra::ProcessManager.with_state { ProcessState } subscribes_to(

    Shipping::ShippingAddressAddedToShipment, Fulfillment::OrderRegistered, Fulfillment::OrderConfirmed, Stores::OfferRegistered )
  16. module Processes class ShipmentProcess include Infra::ProcessManager.with_state { ProcessState } def

    act case state in { shipment: :address_set, order: :placed } register_shipment submit_shipment in { shipment: :address_set, order: :confirmed } register_shipment submit_shipment authorize_shipment else end end
  17. Inventory Pricing CRM Catalog Shipments Taxes Invoicing x14 Domains Orders

    Customers Products ClientOrders ClientInbox PublicO ff er Read models x16 Reservation Shipment InvoiceGeneration Onboarding Process Managers x20 Controller
  18. Inventory Pricing CRM Catalog Shipments Taxes Invoicing x14 Domains Orders

    Customers Products ClientOrders ClientInbox PublicO ff er Read models x16 Reservation Shipment InvoiceGeneration Onboarding Process Managers x20 class CustomersController < ApplicationController def index @customers = Customers.customers_for_store(current_store_id) end def create ActiveRecord::Base.transaction do create_customer(params[:customer_id], params[:name]) end rescue Crm::Customer::AlreadyRegistered flash[:notice] = "Customer was already registered" render "new" else redirect_to customers_path end def create_customer(customer_id, name) command_bus.(create_customer_cmd(customer_id, name)) command_bus.(register_customer_in_store_cmd(customer_id)) end def create_customer_cmd(customer_id, name) Crm::RegisterCustomer.new( customer_id: customer_id, name: name) end
  19. CRM Domains Customers Read models Onboarding Process Managers class CustomersController

    < ApplicationController def index @customers = Customers.customers_for_store end def create ActiveRecord::Base.transaction do create_customer(params[:customer_id], params[:name]) end rescue Crm::Customer::AlreadyRegistered flash[:notice] = "Customer was already registered" render "new" else redirect_to customers_path end 1 2 3 4 5 cmd event Communication cmd Inbox