Event-Driven Architecture Pattern An Example
The producer hands each event to a bus that looks up exactly one channel by the event's type, and that channel drops the event onto its own in-memory queue so a background worker delivers it to subscribers — decoupling the producer's thread from the consumers'. Those two moves — type-based routing and asynchronous hand-off — are what make this code event-driven rather than a chain of method calls. We build a traffic-management example in Java: sensors produce VehicleEvents, a SimpleEventBus routes them, per-type channels queue them, and TrafficLight consumers react.
The event
An event is an immutable record of something that already happened. It carries a type (the routing key the bus keys on) and a payload.
public final class VehicleEvent {
private final String vehicleId;
private final String type; // "entering" or "leaving" — the routing key
public VehicleEvent(String vehicleId, String type) {
this.vehicleId = vehicleId;
this.type = type;
}
public String getVehicleId() { return vehicleId; }
public String getType() { return type; }
}The contracts and the producer
Three small interfaces pin down the roles. Note that the bus routes by type, and a channel is bound to one topic — this is what 064/065 called a Datatype Channel.
public interface EventBus {
void publish(VehicleEvent event);
void register(EventChannel channel);
}
public interface EventChannel {
String topic(); // the single event type this channel carries
void publish(VehicleEvent event); // enqueue for asynchronous delivery
void subscribe(EventConsumer consumer);
}
public interface EventConsumer {
void onEvent(VehicleEvent event);
}
// A sensor at a signal. It knows the bus, nothing about consumers.
public class Sensor {
private final EventBus bus;
public Sensor(EventBus bus) { this.bus = bus; }
public void vehicleDetected(String vehicleId, String action) {
bus.publish(new VehicleEvent(vehicleId, action));
}
}Why the naive version is wrong
The commonly-posted version of this example has two defects that quietly cancel the architecture it claims to demonstrate.
- It broadcasts to every channel. Its
publishdidfor (EventChannel c : channels) c.add(event);— every event lands in every channel. That erases the type→channel routing 064/065 taught: a "leaving"-only channel now also receives "entering" events, so every consumer must re-filter by type, re-coupling all consumers to all event types. Routing is the whole point of a channel; broadcasting removes it. - It is fully synchronous and in-process. Its channel's
addcalledconsumer.onEvent(event)directly, so the entire pipeline ran on the sensor's thread. There is no temporal decoupling — the defining property of EDA. A slow consumer blocks the sensor; a consumer that throws propagates straight back and can crash the producer. That is a plain method call wearing an EDA costume.
The versions below fix both: the bus resolves one channel by type, and the channel hands off through a queue to its own worker thread.
The bus: route by type, not broadcast
The bus is a lookup table from event type to channel. Publishing resolves exactly one channel (or none). Using a concurrent map lets multiple producer threads publish safely.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class SimpleEventBus implements EventBus {
private final Map<String, EventChannel> channels = new ConcurrentHashMap<>();
@Override
public void register(EventChannel channel) {
channels.put(channel.topic(), channel); // one channel per event type
}
@Override
public void publish(VehicleEvent event) {
EventChannel channel = channels.get(event.getType());
if (channel == null) {
// No subscriber for this type. In production: dead-letter it.
System.err.println("dropped unroutable event type=" + event.getType());
return;
}
channel.publish(event); // delivered to the matching channel ONLY
}
}The channel: hand off to a worker thread
This is where synchronous code becomes event-driven. publish only enqueues and returns immediately; a dedicated worker thread drains the queue and fans out to subscribers. The producer never waits on a consumer, and one failing consumer cannot kill the channel.
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
public class AsyncEventChannel implements EventChannel {
private final String topic;
private final List<EventConsumer> consumers = new CopyOnWriteArrayList<>();
private final BlockingQueue<VehicleEvent> queue = new LinkedBlockingQueue<>();
public AsyncEventChannel(String topic) {
this.topic = topic;
Thread worker = new Thread(this::drain, "channel-" + topic);
worker.setDaemon(true);
worker.start();
}
@Override public String topic() { return topic; }
@Override public void subscribe(EventConsumer c) { consumers.add(c); }
@Override
public void publish(VehicleEvent event) {
queue.offer(event); // returns at once — producer thread is freed here
}
private void drain() {
while (true) {
VehicleEvent event;
try {
event = queue.take(); // blocks until an event is available
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
for (EventConsumer c : consumers) {
try {
c.onEvent(event);
} catch (RuntimeException consumerError) {
// one bad consumer must not kill the worker or block the queue
System.err.println("consumer failed: " + consumerError);
}
}
}
}
}The consumer and the wiring
The consumer just reacts. Wiring registers one channel per type and subscribes the traffic lights.
public class TrafficLight implements EventConsumer {
@Override
public void onEvent(VehicleEvent event) {
if ("entering".equals(event.getType())) {
System.out.println("Vehicle " + event.getVehicleId()
+ " is entering. Lengthening green.");
} else if ("leaving".equals(event.getType())) {
System.out.println("Vehicle " + event.getVehicleId()
+ " has left. Resetting timings.");
}
}
}
// --- bootstrap ---
EventChannel entering = new AsyncEventChannel("entering");
EventChannel leaving = new AsyncEventChannel("leaving");
entering.subscribe(new TrafficLight());
leaving.subscribe(new TrafficLight());
SimpleEventBus bus = new SimpleEventBus();
bus.register(entering);
bus.register(leaving);
Sensor sensor = new Sensor(bus);
sensor.vehicleDetected("KA01-4321", "entering");
sensor.vehicleDetected("KA05-8890", "leaving");
sensor.vehicleDetected("KA09-1200", "exiting"); // unknown typeWorked trace
Three calls from the bootstrap above. Watch the Thread column: publish completes on main, but onEvent runs later on a channel worker — that gap is the decoupling. Watch routing: an event reaches exactly the channel whose topic() matches its type.
| # | Call | Thread | Effect |
|---|---|---|---|
| 1 | vehicleDetected("KA01-4321","entering") | main | builds VehicleEvent{KA01-4321, entering} |
| 2 | bus.publish(evt) | main | channels.get("entering") → ch#1 (ch#2 not touched) |
| 3 | ch1.publish(evt) | main | queue.offer(evt) returns immediately; main is free to continue |
| 4 | queue.take() → onEvent(evt) | channel-entering | prints "Vehicle KA01-4321 is entering. Lengthening green." |
| 5 | vehicleDetected("KA05-8890","leaving") | main | publish → channels.get("leaving") → ch#2 only |
| 6 | queue.take() → onEvent(evt) | channel-leaving | prints "Vehicle KA05-8890 has left. Resetting timings." |
| 7 | vehicleDetected("KA09-1200","exiting") | main | channels.get("exiting") → null → dropped (dead-letter candidate) |
Because steps 3 and 4 run on different threads, print order across channels is not guaranteed — events are ordered only within a single channel served by one worker. That non-determinism is a feature of async delivery, not a bug in the code.
Pitfalls
- Broadcast masquerading as routing. The original defect — publishing to every channel — means each consumer must re-filter by type, and adding an event type touches every consumer. Route by key at the bus so a channel only ever sees its own type.
- Synchronous dispatch is not EDA. If
publishcallsonEventon the caller's thread, a slow consumer throttles the producer and a thrown exception unwinds into it. The queue-plus-worker hand-off is non-negotiable for decoupling. - Unbounded queue → OutOfMemoryError.
LinkedBlockingQueuewith no capacity grows without limit; a burst of sensor events the consumers can't keep up with will exhaust the heap. Bound it and decide a policy: block the producer (backpressure) or shed load. - A consumer exception kills the worker. Without the per-consumer try/catch in
drain(), oneRuntimeExceptionends the thread and the channel goes silently deaf. Isolate consumer failures. - No durability. The queue lives in heap memory. If the process crashes, every queued-but-undelivered event is lost — there is no redelivery. This in-memory bus is a teaching model, not Kafka.
- Silent drops. An unroutable type (step 7) vanishes unless you dead-letter it. Log and capture it, or a config typo becomes an invisible data-loss bug.
When to use it, when not
Reach for an event bus / async pub-sub when the shape of the problem is one-producer-to-many-reactors and the producer must not wait for the reaction.
Concrete signals that point here: multiple, independent consumers react to the same fact; the set of consumers grows over time and you want to add them without editing the producer; the producer's latency budget can't absorb the consumers' work; load is spiky and a queue can buffer it; consumers can tolerate eventual consistency.
Trade-offs versus a direct synchronous call (request/response). A direct call is the natural alternative: sensor.trafficLight.adjust(vehicle). You gain immediate results, a stack trace that spans the whole operation, trivial transactions, and simple ordering. You lose extensibility (every new reactor edits the caller) and isolation (the caller waits and inherits every failure). The event bus flips both: you gain decoupling, buffering and easy fan-out; you pay in indirection (three interfaces plus threads to trace), harder debugging (the effect happens on another thread, later), no end-to-end transaction, and only per-channel ordering.
Versus a Mediator/orchestrator. The bus here is a Broker-style topology (064): dumb pipe, smart endpoints, maximum scalability, minimum central control. If you instead need to coordinate a multi-step workflow with conditional routing, aggregation, or compensation, a Mediator/Saga orchestrator earns its central bottleneck by giving you one place that owns the flow.
Versus a real broker (Kafka/RabbitMQ). This in-memory channel is fine inside one process for teaching or for cheap intra-service fan-out. The moment you need durability, redelivery, cross-service delivery, or consumer groups, replace AsyncEventChannel with a broker — the interfaces stay; the transport changes.
Choose the event bus when reactors are many/unknown and the producer must stay fast and ignorant of them. Prefer a direct synchronous call when you need the result back, there is exactly one collaborator, and strong consistency matters more than extensibility.
Takeaways
- Two properties make code event-driven: the bus routes each event to one channel by type, and the channel delivers asynchronously off the producer's thread. Drop either and you've written a disguised method call.
- A channel is a Datatype Channel — bound to a single event type — so consumers never re-filter and new types don't ripple through existing consumers.
- The queue-and-worker hand-off buys decoupling but forces you to own backpressure, consumer-failure isolation, durability, and ordering explicitly.
- Prefer a synchronous call when you need the result and consistency; prefer the bus when many independent reactors must not slow the producer down.
Re-authored and deepened for this guide. Draws on Gregor Hohpe & Bobby Woolf, Enterprise Integration Patterns (Publish-Subscribe Channel, Datatype Channel, Dead Letter Channel); Martin Fowler, "What do you mean by Event-Driven?" (martinfowler.com); Chris Richardson, Microservices Patterns (event-driven collaboration); and Sam Newman, Building Microservices. Java concurrency (BlockingQueue, CopyOnWriteArrayList, ConcurrentHashMap) follows the java.util.concurrent documentation. Corrects the original page's broadcast-to-all-channels routing bug and its synchronous, in-process dispatch, aligning the example with the type-routed, asynchronous model taught in sections 064 and 065.
🤖 Don't fully get this? Learn it with Claude
Stuck on Event-Driven Architecture Pattern An Example? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Event-Driven Architecture Pattern An Example** (System Design) and want to truly understand it. Explain Event-Driven Architecture Pattern An Example from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Event-Driven Architecture Pattern An Example** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Event-Driven Architecture Pattern An Example** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Event-Driven Architecture Pattern An Example** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.