DEV Community

Alex Aslam
Alex Aslam

Posted on

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)
)
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 3: Idempotency for Safety

post "/orders" do
  idempotency_key = request.headers["Idempotency-Key"]
  return 422 unless IdempotencyStore.unique?(idempotency_key)

  # Process command...
end
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Partial Updates

Embrace PATCH with domain commands:

PATCH /orders/ord_123
{ "op": "add_discount", "code": "SUMMER23" }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Materialized Views

Pre-build common queries:

CREATE MATERIALIZED VIEW api_orders AS
  SELECT id, status, created_at
  FROM order_projections;
Enter fullscreen mode Exit fullscreen mode

Event Sourcing Lite

For simple reads, bypass projections:

GET /orders/ord_123?fields=id,status
Enter fullscreen mode Exit fullscreen mode

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.