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

A full-stack programming model for Ruby and Eve...

A full-stack programming model for Ruby and Event Sourcing

Exploring a full stack programming model for Ruby and Event Sourcing, including a time-based approach to domain modelling, durable workflows, time-travelling and reactive server-rendered UIs.

Avatar for Ismael Celis

Ismael Celis

June 14, 2025

Other Decks in Programming

Transcript

  1. Add item [ business rules ] Item added Start order

    [ business rules ] Order started Update quantity [ business rules ] Quantity updated
  2. 1. Order started 2. Product added 3. Product added 4.

    Quantity updated Past events Your order (v4) Latte (2) Croissant (1) Current state
  3. Initial
 Travel budget
 Flight
 Hotel room
 Bus ticket Balance
 Events

    Current state €0.00
 + €1,000.00
 - €300.00
 - €100.00
 - €1.50 €598.50
  4. 1. Order started 2. Product added 3. Product added 4.

    Quantity updated Time "order-123" 1. Order started 2. Product added 3. Quantity updated "order-333" Your order (v4) Latte (2) Croissant (1) Your order (v3) Yoghurt (1)
  5. OrderStarted = Data.define ItemAdded = Data.define(:product_id, :name, :price) ItemQuantityUpdated =

    Data.define(:product_id, :quantity) events = [ OrderStarted.new, ItemAdded.new('latte', 'Latte', 300), ItemQuantityUpdated.new('milk', 2) ]
  6. order = events.each.with_object(initial_order.dup) do |order, event| case event when OrderStarted

    order[:status] = 'open' when ItemAdded product_id = event.product_id image = event.image price = event.price order[:items][product_id] = {name:, price:, quantity: 1} when QuantityUpdated # etc end end
  7. order = events.each.with_object(initial_order.dup) do |order, event| case event when OrderStarted

    order[:status] = 'open' when ItemAdded product_id = event.product_id image = event.image price = event.price order[:items][product_id] = {name:, price:, quantity: 1} when QuantityUpdated # etc end end
  8. order = events.each.with_object(initial_order.dup) do |order, event| case event when OrderStarted

    order[:status] = 'open' when ItemAdded product_id = event.product_id image = event.image price = event.price order[:items][product_id] = {name:, price:, quantity: 1} when QuantityUpdated # etc end end
  9. Current state Event store 1 2 3 4 5 6

    Validations Command fetch past events append new events
  10. Current state Event store 1 2 3 4 5 6

    Command Command model Query model UI Command Query Responsibility Segregation
  11. class OrderState LineItem = Struct.new(:product_id, :name, :price, :quantity) attr_reader :id,

    :status, :items def initialize(id) @id = id @status = 'new' @items = {} end def add_item(product_id, **rest) @items[product_id] = LineItem.new(product_id, **rest) end def total = items.sum { |it| it.price * it.quantity } end
  12. AddItem = Sourced::Command.define('orders.add_item') do attribute :product_id, String attribute :name, String

    attribute :price, Integer attribute :quantity, Integer end ItemAdded = Sourced::Event.define('orders.item_added') do attribute :product_id, String attribute :name, String attribute :price, Integer attribute :quantity, Integer end
  13. class OrderActor < Sourced::Actor state do |stream_id| OrderState.new(stream_id) end command

    AddItem do |order, cmd| # Validate business rules raise 'order is not open' unless order.status == 'open' raise 'order can have up to 5 products' if cart.items.size == 5 event ItemAdded, cmd.payload end event ItemAdded do |order, event| order.add_item(**event.payload) end end
  14. class OrderActor < Sourced::Actor state do |stream_id| OrderState.new(stream_id) end command

    AddItem do |order, cmd| # Validate business rules raise 'order is not open' unless order.status == 'open' raise 'order can have up to 5 products' if order.items.size == 5 event ItemAdded, cmd.payload end event ItemAdded do |order, event| order.add_item(**event.payload) end end
  15. class OrderActor < Sourced::Actor state do |stream_id| OrderState.new(stream_id) end command

    AddItem do |order, cmd| # Validate business rules raise 'order is not open' unless order.status == 'open' raise 'order can have up to 5 products' if cart.items.size == 5 event ItemAdded, cmd.payload end event ItemAdded do |order, event| order.add_item(**event.payload) end end
  16. command AddItem do |state, cmd| return unless state.open? event ItemAdded,

    cmd.payload end event ItemAdded do |state, event| state.add_item(**event.payload) end
  17. Add item Item added Open orders commands state events Time

    Update quantity Quantity updated Cashier Latte, Demi Latte, Demi quantity: 2 quantity: 2
  18. class OrderListings < Sourced::Projector::EventSourced state do |stream_id| { id: stream_id,

    status: nil, items: {} } end event Order::Placed do |listing, event| listing[:status] = 'placed' end # Evolve listing record from events event Order::ItemAdded do |listing, event| listing[:items][event.payload.product_id] = 'open' end # Sync listing record to a file sync do |listing, _, _| File.write("/listings/#{listing[:id]}.json", JSON.dump(listing)) end end
  19. class OrderListings < Sourced::Projector::EventSourced state do |stream_id| { id: stream_id,

    status: nil, items: {} } end event Order::Placed do |listing, event| listing[:status] = 'placed' end # Evolve listing record from events event Order::ItemAdded do |listing, event| listing[:items][event.payload.product_id] = 'open' end # Sync listing record to a file sync do |listing, _, _| File.write("/listings/#{listing[:id]}.json", JSON.dump(listing)) end end
  20. class OrderListings < Sourced::Projector::EventSourced state do |stream_id| { id: stream_id,

    status: nil, items: {} } end event Order::Placed do |listing, event| listing[:status] = 'placed' end # Evolve listing record from events event Order::ItemAdded do |listing, event| listing[:items][event.payload.product_id] = 'open' end # Sync listing record to a file sync do |listing, _, _| File.write("/listings/#{listing[:id]}.json", JSON.dump(listing)) end end
  21. class OrderListings < Sourced::Projector::EventSourced state do |stream_id| { id: stream_id,

    status: nil, items: {} } end event Order::Placed do |listing, event| listing[:status] = 'placed' end event Order::ItemAdded do |listing, event| listing[:items][event.payload.product_id] = 'open' end event Order::ItemFulfillmentStarted do |listing, event| listing[:items][event.payload.product_id] = 'started' end event Order::ItemFulfilled do |listing, event| listing[:items][event.payload.product_id] = ‘fulfilled' end # Sync listing record to a file
  22. listing[:status] = 'placed' end # Evolve listing record from events

    event Order::ItemAdded do |listing, event| listing[:items][event.payload.product_id] = 'open' end # Sync listing record to a file sync do |listing, _, _| File.write("/listings/#{listing[:id]}.json", JSON.dump(listing)) end end
  23. Start Payment Payment confirmed "order-1" "order-1-payment" Confirm payment Payment confirmed

    Time Payment started Start Payment Payments API Fulfil item Item fulfiled
  24. class OrderActor < Sourced::Actor # etc event PaymentStarted do |state,

    event| state.payment.status = :started state.payment.id = event.payload.payment_id end reaction PaymentStarted do |state, event| stream = stream_for(“#{state.id}-payment”) stream.command( Payment::Start, order_id: state.id, amount: state.total.cents ) end end
  25. class OrderActor < Sourced::Actor # etc event PaymentStarted do |state,

    event| state.payment.status = :started state.payment.id = event.payload.payment_id end reaction PaymentStarted do |state, event| stream = stream_for(state.payment.id) stream.command( Payment::Start, order_id: state.id, amount: state.total.cents ) end end
  26. class Payment < Sourced::Actor State = Struct.new(:id, :order_id, :amount, :status,

    :created_at) Start = Sourced::Command.define(...) Started = Sourced::Command.define(...) state do |id| State.new(id, nil, 0, :pending, nil) end command Start do |state, cmd| event :started, cmd.payload end event Started do |state, event| # update local state end # etc end
  27. class Payment < Sourced::Actor # etc... reaction Started do |state,

    event| response = PaymentsAPI.pay(event.payload) order_stream = stream_for(event) if response.ok? order_stream.command Order::ConfirmPayment else order_stream.command Order::FailPayment end end end
  28. Order::PaymentStarted Order::StartPayment id: A causation_id: A correlation_id: A id: B

    causation_id: A correlation_id: A Payment::Start id: C causation_id: B correlation_id: A Payment::Started id: D causation_id: C correlation_id: A
  29. Confirm payment Payment confirmed Order fulfilled Deliverable orders Deliver order

    Order delivered fulfilled ✓ paid ✓ delete Fulfil order Employee
  30. state do |id| { id:, fulfilled: false, paid: false, status:

    'pending' } end event Order::OrderFulfilled do |state, event| state[:fulfilled] = true check_deliverable(state) end event Order::PaymentConfirmed do |state, event| state[:paid] = true check_deliverable(state) end event Order::OrderDelivered do |state, event| state[:status] = 'delivered' end
  31. Confirm payment Payment confirmed Order fulfilled Deliverable orders Deliver order

    Order delivered fulfilled ✓ paid ✓ delete Fulfil order Automation
  32. event Order::OrderDelivered do |state, event| state[:status] = 'delivered' end reaction

    do |state, event| return unless state[:status] == 'ready' stream_for(event).command(Order::DeliverOrder) do |cmd| cmd.delay Time.now + 5 # Simulate a delay for delivery end end private def check_deliverable(state) state[:status] = 'ready' if state[:fulfilled] && state[:paid] end
  33. # GET /updates when OrderListings::System::Updated if sse.signals['page_key'] == 'Pages::CashierPage' sse.merge_fragments

    Components::OrdersTable.new( orders: OrderListings.all ) end end data-star.dev phlex.fun
  34. The future * Fix bugs! * State Charts - https://statecharts.dev

    * Durable execution * SQLite backend * Auto-generated diagrams and docs
  35. Command handlers Event store Command bus Reactions Projectors reactors Emails

    APIs Projections Caches schedule command schedule command
  36. command Place do |state, cmd| return unless state.open? event Placed,

    cmd.payload end event Placed do |state, event| state.status = :placed end