"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.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.