From Events to APIs: Building a REST Layer on Event Sourcing
"Your event store holds the truth—but your users just want a damn API." Event sourcing gives you temporal superpowers: replay history, audit trails, and bulletproof consistency. But when your frontend team asks for a simple REST endpoint? Suddenly you're translating events into JSON through 15 layers of abstraction. Let's bridge the gap without sacrificing event-sourcing benefits. 1. The Great Divide: Events vs. REST Expectations What Events Give You OrderPlaced.new( order_id: "ord_123", items: [{id: "prod_1", qty: 2}], timestamp: Time.utc(2023, 5, 10, 14, 30) ) What Your API Consumers Want GET /orders/ord_123 { "id": "ord_123", "status": "placed", "items": [{"id": "prod_1", "quantity": 2}], "created_at": "2023-05-10T14:30:00Z" } The challenge: Events ≠ current state. 2. The Blueprint: REST on Event Sourcing Step 1: Projections as Read Models # Projection class OrderProjection def initialize(events) @state = events.reduce({}) { |state, event| apply(event, state) } end def to_json @state.slice(:id, :status, :items, :created_at).to_json end private def apply(event, state) case event when OrderPlaced state.merge(status: :placed, created_at: event.timestamp) when OrderShipped state.merge(status: :shipped) end end end Step 2: Commands as POST Endpoints # API Controller post "/orders/:id/ship" do command = ShipOrder.new(order_id: params[:id]) events = command.call EventStore.publish(events) 204 # No Content end Step 3: Idempotency for Safety post "/orders" do idempotency_key = request.headers["Idempotency-Key"] return 422 unless IdempotencyStore.unique?(idempotency_key) # Process command... end 3. Solving the Hard Problems Concurrency Control Use ETags based on event stream version: GET /orders/ord_123 ETag: "stream_version_42" PUT /orders/ord_123 If-Match: "stream_version_42" Partial Updates Embrace PATCH with domain commands: PATCH /orders/ord_123 { "op": "add_discount", "code": "SUMMER23" } Translates to: ApplyDiscount.new(order_id: "ord_123", code: "SUMMER23") Versioning Never break projections: # API v2 adds 'discounts' field class OrderProjectionV2

"Your event store holds the truth—but your users just want a damn API."
Event sourcing gives you temporal superpowers: replay history, audit trails, and bulletproof consistency. But when your frontend team asks for a simple REST endpoint? Suddenly you're translating events into JSON through 15 layers of abstraction.
Let's bridge the gap without sacrificing event-sourcing benefits.
1. The Great Divide: Events vs. REST Expectations
What Events Give You
OrderPlaced.new(
order_id: "ord_123",
items: [{id: "prod_1", qty: 2}],
timestamp: Time.utc(2023, 5, 10, 14, 30)
)
What Your API Consumers Want
GET /orders/ord_123
{
"id": "ord_123",
"status": "placed",
"items": [{"id": "prod_1", "quantity": 2}],
"created_at": "2023-05-10T14:30:00Z"
}
The challenge: Events ≠ current state.
2. The Blueprint: REST on Event Sourcing
Step 1: Projections as Read Models
# Projection
class OrderProjection
def initialize(events)
@state = events.reduce({}) { |state, event| apply(event, state) }
end
def to_json
@state.slice(:id, :status, :items, :created_at).to_json
end
private
def apply(event, state)
case event
when OrderPlaced
state.merge(status: :placed, created_at: event.timestamp)
when OrderShipped
state.merge(status: :shipped)
end
end
end
Step 2: Commands as POST Endpoints
# API Controller
post "/orders/:id/ship" do
command = ShipOrder.new(order_id: params[:id])
events = command.call
EventStore.publish(events)
204 # No Content
end
Step 3: Idempotency for Safety
post "/orders" do
idempotency_key = request.headers["Idempotency-Key"]
return 422 unless IdempotencyStore.unique?(idempotency_key)
# Process command...
end
3. Solving the Hard Problems
Concurrency Control
Use ETags based on event stream version:
GET /orders/ord_123
ETag: "stream_version_42"
PUT /orders/ord_123
If-Match: "stream_version_42"
Partial Updates
Embrace PATCH with domain commands:
PATCH /orders/ord_123
{ "op": "add_discount", "code": "SUMMER23" }
Translates to: ApplyDiscount.new(order_id: "ord_123", code: "SUMMER23")
Versioning
Never break projections:
# API v2 adds 'discounts' field
class OrderProjectionV2 < OrderProjection
def apply(event, state)
case event
when DiscountApplied
state[:discounts] ||= []
state[:discounts] << event.code
else
super
end
end
end
4. Performance Optimizations
Caching Projections
Rails.cache.fetch("order_projection_#{id}", expires_in: 5.minutes) do
OrderProjection.new(EventStore.for(order_id: id)).to_json
end
Materialized Views
Pre-build common queries:
CREATE MATERIALIZED VIEW api_orders AS
SELECT id, status, created_at
FROM order_projections;
Event Sourcing Lite
For simple reads, bypass projections:
GET /orders/ord_123?fields=id,status
5. Tools That Help
- RailsEventStore HTTP API: Expose streams via REST
- GraphQL: Let clients query projections flexibly
- OpenAPI: Document command-driven endpoints
When This Fits (and When It Doesn’t)
✅ Internal APIs: Frontends consuming projections
✅ Partner integrations: Well-defined command interfaces
✅ Systems needing audit trails
❌ Public CRUD APIs: Where REST conventions are rigid
❌ High-frequency trading: Nanosecond latency demands
"But REST Is Stateless!"
So are events. Projections rebuild state from streams—REST just exposes snapshots.
Have you built APIs on event sourcing? Share your war stories below.