DEV Community

Alex Aslam
Alex Aslam

Posted on

Event-Driven Architecture with Rails: Breaking Free from the Request-Response Trap

Your Rails app isn’t just a website—it’s a nervous system.

Every time you:

  • Call user.update!(...) and trigger five side effects...
  • Chain after_commit callbacks until they resemble spaghetti...
  • Add "just one more thing" to a service object...

...you’re building a tightly coupled time bomb.

Event-Driven Architecture (EDA) flips the script. Instead of:

def checkout_order  
  order.process_payment!  # Direct control  
  order.send_receipt!     # More direct control  
  order.update_inventory! # Who invited this guy?  
end  
Enter fullscreen mode Exit fullscreen mode

You broadcast:

event = OrderPaid.new(order_id: order.id)  
Rails.configuration.event_store.publish(event)  
Enter fullscreen mode Exit fullscreen mode

Now let’s implement this without overengineering it.


Why Rails Needs EDA

The Pain Points

  1. Callback Hell: after_save chains that break unpredictably.
  2. Testing Nightmares: Need to stub 10 objects to test one feature.
  3. Scaling Limits: HTTP request timeout? Say goodbye to async workflows.

The Fix

  • Decouple components using events ("OrderPaid", "UserBlocked").
  • Process async with subscribers (no more 30s request timeouts).
  • Replay events for debugging (impossible with HTTP calls).

Tool Showdown

1. Rails Event Store (RES)

Best for: Rails apps dipping toes into EDA.

# Define an event  
class OrderPaid < RailsEventStore::Event  
  def self.strict(data)  
    new(data.merge(metadata: { timestamp: Time.now }))  
  end  
end  

# Publish  
event = OrderPaid.strict(order_id: 123)  
Rails.configuration.event_store.publish(event)  

# Subscribe  
module Payments  
  class OnOrderPaid  
    def call(event)  
      # Charge credit card here  
    end  
  end  
end  

Rails.configuration.event_store.subscribe(Payments::OnOrderPaid.new, to: [OrderPaid])  
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Tight ActiveRecord integration
  • Built-in event sourcing

Cons:

  • Ruby-only (no cross-language events)

2. Karafka (Kafka Backbone)

Best for: Apps needing inter-service messaging.

# config/karafka.rb  
class KarafkaApp < Karafka::App  
  setup do |config|  
    config.kafka = { 'bootstrap.servers': 'kafka:9092' }  
  end  

  routes.draw do  
    topic :orders_paid do  
      consumer Payments::OrdersPaidConsumer  
    end  
  end  
end  

# app/consumers/payments_consumer.rb  
module Payments  
  class OrdersPaidConsumer < Karafka::BaseConsumer  
    def consume  
      params_batch.each do |order|  
        CreditCard.charge(order[:id])  
      end  
    end  
  end  
end  
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Kafka’s durability/scaling
  • Polyglot-friendly (Java services can listen)

Cons:

  • Operational complexity (Kafka clusters)

3. Dry-Events (Pure Ruby)

Best for: Lightweight decoupling.

# config/initializers/events.rb  
class AppEventBus  
  include Dry::Events::Publisher[:my_bus]  

  register_event("order.paid")  
end  

# Publish  
AppEventBus.broadcast("order.paid", order_id: 123)  

# Subscribe  
AppEventBus.subscribe("order.paid") do |event|  
  Payments::CreditCardProcessor.call(event[:order_id])  
end  
Enter fullscreen mode Exit fullscreen mode

Pros:

  • No database/Kafka dependency
  • Simple for small apps

Cons:

  • No persistence (lost events on crash)

Critical Patterns

1. Event Sourcing

Store all state changes as events:

# Rebuild an order’s state from events  
order_events = event_store.read.stream("Order$#{order.id}")  
order = OrderProjection.rebuild(order_events)  
Enter fullscreen mode Exit fullscreen mode

Use when:

  • Audit trails are critical (fintech, healthcare).
  • You need time-travel debugging.

2. Idempotent Handlers

def call(event)  
  return if ProcessedEvent.exists?(event_id: event.event_id)  

  CreditCard.charge(event.data[:order_id])  
  ProcessedEvent.create!(event_id: event.event_id)  
end  
Enter fullscreen mode Exit fullscreen mode

Prevents: Double charges when retrying failed events.

3. Schema Enforcement

Validate event shapes with JSON Schema:

class OrderPaid < RailsEventStore::Event  
  SCHEMA = {  
    "type": "object",  
    "required": ["order_id"],  
    "properties": {  
      "order_id": { "type": "string" }  
    }  
  }  

  def self.strict(data)  
    JSON::Validator.validate!(SCHEMA, data)  
    new(data)  
  end  
end  
Enter fullscreen mode Exit fullscreen mode

When to Avoid EDA

🚫 Simple CRUD apps: Overkill for basic forms.

🚫 Low-latency needs: Event processing adds milliseconds.

🚫 No ops team: Kafka isn’t "just run it in Heroku".


Adoption Roadmap

  1. Start small: Replace one callback chain with events.
  2. Add RES: For event persistence.
  3. Scale out: Introduce Kafka when crossing service boundaries.

Pro tip: Use rails-event_store’s TestClient in CI:

Rails.configuration.event_store = RailsEventStore::Client.new(repository: RubyEventStore::InMemoryRepository.new)  
Enter fullscreen mode Exit fullscreen mode

"But Rails is request/response!"

So were PHP apps—until they weren’t.

EDA isn’t about chasing trends. It’s about writing systems that don’t snap when you add the 100th feature.

Have you tried EDA in Rails? Share your wins (or horror stories) below.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.