Events vs commands: the naming that unlocks your architecture
A year into building payment systems at one of our e-commerce platforms, we discovered something that seemed almost embarrassingly simple: the way you name a message tells you everything about how it should flow through your system. That insight came from a single production incident where a naming confusion cascaded into duplicate charges. This is the story of events vs commands, and why getting this right changes everything about how reliable and scalable your Kafka and WebSocket systems become.
Commands ask: ChargeCard
A command is a request. It has intention. It asks the system to do something. When you name something as a command, you're making a promise: exactly one handler owns this action, and that handler is responsible for success or failure.
In our payment flow, ChargeCard is a command. It arrives with a user ID, amount, and card token. It says: "Please process this payment now." The billing service picks it up, exactly one instance of that handler runs, and either the charge succeeds or it fails. There's no ambiguity about who does the work.
Here's what this looks like in practice:
// This is a COMMAND. One handler, one outcome.
interface ChargeCardCommand {
userId: string;
cardToken: string;
amountCents: number;
orderId: string;
idempotencyKey: string; // Critical for safety
}
// The handler is uniquely responsible
class PaymentCommandHandler {
async handle(cmd: ChargeCardCommand): Promise<void> {
// Exactly one instance processes this
const result = await this.paymentGateway.charge(
cmd.cardToken,
cmd.amountCents
);
if (result.success) {
// Emit an EVENT that something happened
await this.eventBus.publish(new CardChargedEvent({
userId: cmd.userId,
transactionId: result.id,
amountCents: cmd.amountCents,
orderId: cmd.orderId,
}));
} else {
throw new PaymentFailedError(result.reason);
}
}
}
Commands have one handler because they express intent that must be fulfilled exactly once. That idempotency key matters because if the network flickers and the command retries, you don't want to charge the card twice. The handler sees it's already processed that idempotency key and returns safely.
When building with Kafka, commands should route to a topic with a single consumer group and partition affinity by user or order ID. When building with WebSockets, a command typically originates from the client and awaits a response. It's synchronous in nature even if the underlying transport is async.
Events state facts: CardCharged
An event is a statement of truth. It happened. It's in the past tense. No one decides whether it happens—it's a fact that gets broadcast so anyone interested can react.
After the ChargeCard command succeeds, the system publishes CardCharged. This event states a fact: this card was charged for this amount at this moment. Now multiple services care about that fact. The order service wants to mark the order as paid. The analytics pipeline wants to record the transaction. The user's wallet service wants to update their balance. The notification engine wants to send a receipt email. None of them are told to do it; they simply listen for the event and react.
// This is an EVENT. Many subscribers, no coordination needed.
interface CardChargedEvent {
userId: string;
transactionId: string;
amountCents: number;
orderId: string;
chargedAt: ISO8601String;
gateway: 'stripe' | 'adyen';
}
// Multiple independent subscribers
class OrderServiceSubscriber {
async handle(event: CardChargedEvent): Promise<void> {
await this.orderRepository.updateStatus(event.orderId, 'PAID');
}
}
class AnalyticsSubscriber {
async handle(event: CardChargedEvent): Promise<void> {
await this.analyticsQueue.enqueue({
type: 'payment_completed',
userId: event.userId,
amount: event.amountCents,
timestamp: event.chargedAt,
});
}
}
class NotificationSubscriber {
async handle(event: CardChargedEvent): Promise<void> {
await this.emailService.sendReceipt(event.userId, event.transactionId);
}
}
// In your event bus setup:
eventBus.subscribe('card.charged', [
new OrderServiceSubscriber(),
new AnalyticsSubscriber(),
new NotificationSubscriber(),
// Add more later without changing the event
]);
Events are inherently multicast. In Kafka, an event publishes to a topic and multiple consumer groups subscribe independently. Each group processes at its own pace. If one subscriber is slow or fails, others aren't blocked. This is why events unlock scalability and resilience.
The past tense naming—CardCharged, not ChargeCard—is not just grammar. It's a contract. It tells every developer reading this code: "This already happened. Your job is to react, not to make it happen."
Commands have one handler
This constraint is intentional. A command represents a decision or state change that must happen exactly once and with a single point of accountability. If two handlers tried to process the same ChargeCard command, you'd have a disaster: two charges, two transaction IDs, confusion about which one succeeded.
In Kafka terms, this means your command topic should have partition count equal to your consumer group size, with each consumer owning exactly one partition. Or use a competing consumers pattern where only one instance claims the message. The infrastructure enforces single processing.
In WebSocket systems, the command pattern typically means a request-response flow: the client sends ChargeCard, the server handles it, and sends back success or failure. The client knows who they're asking and waits for the answer.
Single handler also means clear error paths. If the command fails, the sender knows immediately and can retry or inform the user. There's no ambiguity about who failed and why.
Events can have many subscribers
This is where asynchronous architectures gain their superpowers. Once CardCharged is published, the original command handler is done. It doesn't wait for the email to send or the analytics to record. The event is immutable and durable; subscribers consume it whenever they're ready.
This decoupling means you can add a new subscriber—say, a fraud-detection service—without touching the payment handler. The event already contains all the data the fraud service needs. It subscribes to the same topic and starts reacting. Your payment latency doesn't increase because a new subscriber was added.
// Later, you add fraud detection without changing payment logic
class FraudDetectionSubscriber {
async handle(event: CardChargedEvent): Promise<void> {
const risk = await this.fraudModel.assess({
userId: event.userId,
amount: event.amountCents,
gateway: event.gateway,
});
if (risk.score > 0.8) {
await this.alertService.notifySecurityTeam(event.transactionId, risk);
}
}
}
// Register alongside existing subscribers—zero impact on payment handler
eventBus.subscribe('card.charged', [...existingSubscribers, new FraudDetectionSubscriber()]);
Multiple subscribers also enable different teams to own different concerns. The order team owns order state. The billing team owns transaction records. The marketing team owns promotional triggers. All listening to the same events, all moving independently. This scales your organization, not just your code.
In a Kafka topology, each subscriber is typically its own consumer group. They can have different lag policies, different failure handling, even different deserialization strategies. They're loosely coupled by the event schema alone.
Common mistakes
Naming a command in past tense. If you call it CardCharged but implement it as a command handler, you've created confusion. Future developers will expect multiple handlers and asynchronous flows. Keep commands as imperatives: ChargeCard, SendEmail, ReserveInventory.
Publishing events before the command succeeds. The payment might fail halfway through. Don't emit CardCharged until you're certain the charge succeeded. Events are facts, not hopes. If you emit too early, subscribers react to something that never actually happened.
Expecting a command to fan out like an event. If you have ProcessOrder and five different services need to react, split it: a single ProcessOrder command runs, then it publishes an OrderProcessed event that the five services subscribe to. The event layer is for fan-out; commands are for singular responsibility.
Events without complete data. A subscriber shouldn't need to query upstream services to understand the event. CardChargedEvent should contain the user ID, amount, and transaction ID—everything the fraud detector or email service needs. Include context in the event itself.
Slow synchronous handlers on events. If a subscriber is I/O bound (database writes, API calls), queue it or run it async. Don't block the event bus. Process the event into a job and handle it separately. This keeps your event throughput high.
Key takeaways
- A command is a request—imperative mood, one handler, synchronous accountability. Name it as a verb:
ChargeCard. - An event is a fact—past tense, many subscribers, asynchronous and decoupled. Name it as an adjective/past participle:
CardCharged. - Commands have single handler guarantee; events have broadcast guarantee. This difference unlocks scalability and resilience.
- Commands handle decisions and state transitions; events publish results and enable reactions. Use both.
- Naming right makes the architecture follow naturally. Developers read the message name and immediately know whether it's a one-to-one request or one-to-many broadcast.
- Embed context in events so subscribers don't need upstream queries. Make events self-contained.
- Add new subscribers to events without touching existing logic. This is where your system grows without coupling.






Comments
Loading comments…