CMDx as a Pragmatic Alternative to Event Sourcing¶
Event Sourcing is one of those ideas that sounds perfect in a conference talk and then bankrupts your sprint when you try to implement it. You need an event store, projections, snapshot strategies, a way to replay history, and a team that understands why you can't just UPDATE a row anymore. For some domains—banking, audit-heavy compliance, truly distributed systems—it's worth the cost. For the rest of us, it's a complexity tax we can't afford.
But the benefits of Event Sourcing are real. An immutable record of what happened. The ability to understand why the system is in its current state. Traceability across complex workflows. I wanted those benefits without the infrastructure.
That's when I realized CMDx already gives you most of it for free.
The Complexity Tax¶
Let me paint a picture. You're building an e-commerce platform in Ruby. Someone suggests Event Sourcing for order management. Suddenly your architecture looks like this:
- Event Store — A specialized database (or Postgres with an append-only pattern) for immutable events
- Aggregates — Objects that reconstruct their state by replaying events
- Projections — Read models built by consuming event streams
- Snapshots — Performance optimization for aggregates with long histories
- Event Bus — Infrastructure for publishing and subscribing to events
For a team of three building a Rails app, this is a non-starter. You need order tracking and auditability, not a distributed systems thesis.
Log-Based Event Sourcing¶
Here's the insight: if every state change in your system goes through a CMDx task, and every task execution is automatically logged with its inputs, outputs, and outcome—you already have an event log.
Let me show you what I mean.
class Orders::Process < CMDx::Task
required :order_id, type: :integer
required :user_id, type: :integer
def work
order = Order.find(order_id)
order.process!
context.order = order
context.processed_at = Time.current
end
end
result = Orders::Process.execute(order_id: 42, user_id: 7)
CMDx automatically logs:
{
"index": 0,
"chain_id": "018c2b95-b764-7615-a924-cc5b910ed1e5",
"class": "Orders::Process",
"state": "complete",
"status": "success",
"metadata": { "runtime": 23 }
}
That log entry is, functionally, an event. It tells you:
- What happened:
Orders::Processexecuted - When: Timestamp
- The intent: The inputs (order_id, user_id) that caused the action
- The outcome: Success, with runtime metrics
- Correlation: A
chain_idthat links this to related operations
Now imagine every business operation in your system flows through CMDx tasks. Your logs become a complete, chronological ledger of system behavior.
Building the Audit Trail¶
Let's build a real inventory management system that demonstrates this pattern.
The Tasks¶
class Inventory::ReceiveStock < CMDx::Task
required :sku, presence: true
required :quantity, type: :integer, numeric: { min: 1 }
required :warehouse_id, type: :integer
returns :stock_record
def work
product = Product.find_by!(sku: sku)
stock = product.stock_records.create!(
warehouse_id: warehouse_id,
quantity: quantity,
direction: :inbound
)
product.increment!(:available_quantity, quantity)
context.stock_record = stock
context.new_quantity = product.reload.available_quantity
logger.info "Received #{quantity} units of #{sku} at warehouse #{warehouse_id}"
end
end
class Inventory::ReserveStock < CMDx::Task
required :sku, presence: true
required :quantity, type: :integer, numeric: { min: 1 }
required :order_id, type: :integer
def work
product = Product.find_by!(sku: sku)
if product.available_quantity < quantity
fail!("Insufficient stock", code: :out_of_stock,
available: product.available_quantity, requested: quantity)
end
product.decrement!(:available_quantity, quantity)
product.increment!(:reserved_quantity, quantity)
context.reserved_at = Time.current
logger.info "Reserved #{quantity} units of #{sku} for order #{order_id}"
end
def rollback
product = Product.find_by!(sku: sku)
product.increment!(:available_quantity, quantity)
product.decrement!(:reserved_quantity, quantity)
logger.info "Released reservation of #{quantity} units of #{sku}"
end
end
class Inventory::FulfillStock < CMDx::Task
required :sku, presence: true
required :quantity, type: :integer
required :order_id, type: :integer
required :warehouse_id, type: :integer
def work
product = Product.find_by!(sku: sku)
if product.reserved_quantity < quantity
fail!("Reservation mismatch", code: :reservation_error)
end
product.decrement!(:reserved_quantity, quantity)
product.stock_records.create!(
warehouse_id: warehouse_id,
quantity: quantity,
direction: :outbound,
order_id: order_id
)
context.fulfilled_at = Time.current
end
end
Inbound: Standalone Tasks¶
ReceiveStock runs independently—when a shipment arrives, a warehouse worker or webhook triggers it:
That single execution produces a log entry. No workflow, no ceremony—just one task, one event in the ledger.
Outbound: Task-in-Task Composition¶
You don't need a workflow to get chain correlation. When one task calls another, they automatically share the same chain_id:
class Inventory::ProcessOrder < CMDx::Task
required :sku, presence: true
required :quantity, type: :integer, numeric: { min: 1 }
required :order_id, type: :integer
required :warehouse_id, type: :integer
def work
Inventory::ReserveStock.execute(sku: sku, quantity: quantity, order_id: order_id)
Inventory::FulfillStock.execute(
sku: sku, quantity: quantity, order_id: order_id, warehouse_id: warehouse_id
)
end
end
No include CMDx::Workflow, no task declarations—just plain calls inside work. The logs still correlate:
{"index":1,"chain_id":"abc123","class":"Inventory::ReserveStock","status":"success","metadata":{"runtime":12}}
{"index":2,"chain_id":"abc123","class":"Inventory::FulfillStock","status":"success","metadata":{"runtime":8}}
{"index":0,"chain_id":"abc123","class":"Inventory::ProcessOrder","status":"success","metadata":{"runtime":24}}
Every subtask joins the parent's chain automatically. The event log is identical to what a workflow would produce.
Outbound: Workflow Composition¶
If you prefer declarative orchestration with breakpoints and conditional steps, a workflow gives you the same chain correlation with less boilerplate:
class Inventory::ProcessOrder < CMDx::Task
include CMDx::Workflow
settings workflow_breakpoints: ["failed"]
task Inventory::ReserveStock
task Inventory::FulfillStock
end
Either approach produces the same event stream. The key insight is that chain correlation isn't a workflow feature—it's a CMDx feature. Any task calling another task inherits the chain.
Filter by chain_id and you see the complete lifecycle of that inventory movement. Filter by class name and you see every stock reservation across your entire system. Filter by status and you find every failure.
This is your event stream—without an event store.
Reconstructability¶
Traditional Event Sourcing lets you replay events to reconstruct state. CMDx gives you something similar: because each task encapsulates all inputs needed for an action, you can reconstruct what happened by inspecting the command history.
Consider a support ticket: "Why does product SKU-1234 show 0 available?"
With CMDx logs shipped to your log aggregator, you query:
And get back:
10:00 Inventory::ReceiveStock status:success quantity:100 warehouse:1
10:15 Inventory::ReserveStock status:success quantity:50 order:201
10:16 Inventory::ReserveStock status:success quantity:30 order:202
10:17 Inventory::ReserveStock status:success quantity:20 order:203
10:30 Inventory::FulfillStock status:success quantity:50 order:201
10:45 Inventory::ReserveStock status:failed quantity:10 reason:"Insufficient stock"
You can trace the exact sequence of events that led to the current state. No event store, no replay mechanism—just structured logs from tasks that were going to run anyway.
The CQRS Angle¶
CQRS (Command Query Responsibility Segregation) splits your system into a write model and a read model. With CMDx, this maps naturally:
- Write Model: CMDx tasks that perform state changes, logged automatically
- Read Model: Your standard ActiveRecord models and database queries
Your relational database serves current-state queries efficiently (the read side), while your CMDx logs provide the historical record of how that state was reached (the write side). You get CQRS-like benefits without maintaining separate projections.
# Write side — all mutations go through tasks
class Accounts::Credit < CMDx::Task
required :account_id, type: :integer
required :amount, type: :big_decimal, numeric: { min: 0.01 }
required :reason, presence: true
returns :transaction
def work
account = Account.find(account_id)
context.transaction = account.transactions.create!(
amount: amount,
direction: :credit,
reason: reason,
balance_after: account.balance + amount
)
account.increment!(:balance, amount)
logger.info "Credited #{amount} to account #{account_id}: #{reason}"
end
end
# Read side — standard queries
account = Account.find(42)
account.balance # Current state
account.transactions.where(direction: :credit) # Historical data
The task logs add another dimension: they capture intent and outcome that the database alone can't express. A failed credit attempt doesn't create a transaction record, but the CMDx log captures that it was attempted, why it failed, and what data was involved.
When to Use This vs. Real Event Sourcing¶
This approach works great when you need:
- Audit trails without infrastructure overhead
- Debugging complex workflows in production
- Compliance reporting on who did what and when
- Incident analysis tracing the sequence of actions that led to a state
Stick with real Event Sourcing when you need:
- Event replay to rebuild state from scratch
- Temporal queries ("What was the account balance on March 1st?")
- Event-driven microservices where events are the integration contract
- Bi-temporal modeling with correction semantics
For most Ruby applications, the pragmatic approach covers 80% of the use cases at 10% of the complexity.
Key Takeaways¶
-
Every CMDx task execution is an event. Structured logs with chain correlation, timing, and metadata give you an immutable record of system behavior.
-
Tasks encapsulate intent. The inputs to a task capture why an action was taken, not just the resulting state change.
-
Chain IDs are correlation IDs. Filter by
chain_idand reconstruct the full lifecycle of any business process. -
Your database is the read model. Query it for current state. Query your logs for history and intent.
-
Start simple. Route all state changes through CMDx tasks. The audit trail builds itself.
You don't need an event store to think in events. You just need discipline about where state changes happen—and a framework that makes that discipline effortless.
Happy coding!