FR | EN
Parler de votre projet

Events vs Commands: Untangling Event-Driven Architecture in a Rails System

Two very different contracts hide behind the same "event-driven" label. Getting the naming right decides how the rest of the architecture behaves.

Publié le 13 octobre 2024 · 5 min de lecture

"Event-driven" gets used as a label for two architectures that behave very differently under load and under failure. Before naming a queue or a topic, it's worth being precise about which one you're actually building.

A command tells, an event informs

A command names an action you want performed and expects it to happen: CreateInvoice, CancelSubscription. It's addressed to exactly one recipient, who is allowed to refuse it. An event describes something that already happened, in the past tense: OrderPlaced, InvoicePaid. It's addressed to nobody in particular; the publisher doesn't know, and shouldn't care, who's listening.

COMMAND (IMPERATIVE, ONE TARGET) SERVICE A CreateInvoice SERVICE B EVENT (PAST TENSE, ANY NUMBER OF LISTENERS) SERVICE A OrderPlaced SERVICE B SERVICE C SERVICE D
Same infrastructure, two very different contracts

Where teams get burned

The most common mistake is publishing something named like an event but shaped like a command, "SendWelcomeEmail" for instance, then being surprised when someone else's service acts on it in a way that couples the two teams' release schedules together. If a consumer's behavior is required for your business process to be correct, you're issuing a command and should treat the delivery guarantee accordingly. If the process is correct with or without that consumer reacting, it's genuinely an event.

Rails, ActiveSupport::Notifications and where it stops

Inside a single Rails process, ActiveSupport::Notifications gives you the event pattern for free: publish once, let zero or more subscribers react, none of them block each other or the publisher.

ActiveSupport::Notifications.instrument("order.placed", order_id: order.id)

ActiveSupport::Notifications.subscribe("order.placed") do |*, payload|
  LoyaltyPoints.credit(payload[:order_id])
end

That pattern works beautifully in-process and falls apart the moment a subscriber needs to survive a process restart or live in a different service. That's the point where you reach for a durable transport, and where the outbox pattern earns its keep.

Naming is the design decision

In practice, the architecture review that matters most isn't "Kafka or RabbitMQ." It's making every team write down, per message, whether they're issuing a command or announcing a fact. Everything else, retries, ordering, replay, follows from that answer.