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

Jun 16, 2025 - 21:40
 0
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 < 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.