Events, extensibility and testability
Decouple parts with events, leave safe places to grow, and shape code so it can be tested cheaply.
What you will learn
- 01Three qualities that decide how long code lives
- 02Events, commands and messages
- 03Event driven design
- 04What the bus promises
- 05Costs of events
- 06Extensibility: plan where change will come
- 07Testability
- 08Making time and randomness controllable
- 09Warning signs
Three qualities that decide how long code lives
Correct code that cannot be changed or checked eventually gets replaced. This lesson covers the three habits that prevent that: reacting to things through events, leaving extension points where change is expected, and building in testability from the start. They are linked. Loosely coupled parts are easier to extend, and the same parts are easier to test in isolation.
Swipe sideways to pan · drag a box to move it
Events, commands and messages
A command tells a specific receiver to do something: AssignSlot. It can be refused, and the sender expects a result. An event states that something already happened: CarEntered. It is a fact in the past tense, so it cannot be refused, and the sender does not know or care who listens. A message is the general word for data sent between parts, and both of the above are messages.
This distinction shapes naming and responsibility. Commands are imperative and have one target. Events are past tense and have any number of listeners.
Event driven design
In an event driven design, parts communicate by publishing events instead of calling each other. A publisher emits an event, a bus or broker routes it, and subscribers react.
type Handler<E> = (e: E) => void | Promise<void>;
class EventBus {
private handlers = new Map<string, Handler<any>[]>();
on<E>(type: string, h: Handler<E>) {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), h]);
}
async emit<E>(type: string, e: E) {
for (const h of this.handlers.get(type) ?? []) await h(e);
}
}
bus.on("CarEntered", (e) => display.show(e.plate));
bus.on("CarEntered", (e) => billing.start(e.plate, e.at));
The gate never mentions the display or billing. Publish-subscribe, or pub-sub, adds three kinds of decoupling: in space (the publisher does not know the subscribers), in time (with a queue, subscribers may run later), and in synchronization (the publisher need not wait).
Swipe sideways to pan · drag a box to move it
What the bus promises
Once events cross a queue or a network, you must decide what delivery means.
- At most once: send and forget. Fast, but an event can be lost.
- At least once: retry until acknowledged. Nothing is lost, but an event can arrive twice.
- Exactly once: no loss and no duplicates. Hard in general, and in practice built from at-least-once delivery plus idempotent handlers, which tolerate repeats.
Ordering is a separate question. Order is often guaranteed only per key, such as per ticket, not across everything. Design handlers so that a late or repeated event does no harm, for example by storing the id of the last event applied.
Costs of events
Events trade clarity for flexibility. The flow of a use case is no longer readable in one place, because you must find every subscriber. Failures are quieter, since a broken subscriber does not break the publisher. State becomes eventually consistent, meaning other parts catch up a moment later. The shape of an event is a contract, so changing a field can break unseen listeners, and events need versioning like any API. Use events where decoupling is valuable, and plain calls where one caller needs one answer now.
Swipe sideways to pan · drag a box to move it
Extensibility: plan where change will come
Extensibility is not making everything flexible. It is choosing a few extension points at the places where change is likely, so new behavior is added instead of edited in. Common forms:
- Strategy for swappable algorithms, such as pricing.
- Registry or plugin for adding whole new kinds.
- Hooks and events for reacting at defined moments.
- Configuration for values and switches that change without code.
const rules = new Map<string, PricingRule>();
function register(kind: string, rule: PricingRule) { rules.set(kind, rule); }
register("car", new CarRule());
register("scooter", new ScooterRule()); // new kind, no existing edit
Judge each extension point by cost and likelihood. An unused point is dead weight (YAGNI), but a missing one at a place that changes every month costs far more. Keep public interfaces small and stable, and add rather than alter, since removal breaks users. Feature flags let you ship code dark and enable it gradually.
Testability
Code is testable when you can run a piece in isolation, control everything it depends on, and observe the result. It is a design property, and the earlier lessons already built it: pure functions, injected dependencies, and small interfaces.
Kinds of tests form a rough pyramid. Many fast unit tests check one class or function. Fewer integration tests check that parts work together, for example the repository against a real database. A handful of end to end tests drive the whole system. Lower levels are fast and precise, higher levels are slow and realistic.
Test doubles stand in for real dependencies:
- A dummy fills a parameter and is never used.
- A stub returns canned answers.
- A fake is a simple working version, such as an in-memory repository.
- A spy records how it was called.
- A mock is a spy that also asserts the calls it expects.
Prefer fakes and stubs for behavior, and use mocks sparingly, because tests tied to exact calls break whenever the internals change.
Making time and randomness controllable
Two hidden inputs ruin tests: the clock and randomness. Pass them in.
test("charges two hours for 90 minutes", () => {
const clock = { now: () => 1_000_000 };
const lot = new ParkingLot(new CarRule(), new InMemoryTickets(), clock);
const t = lot.enter("KA01", clock.now() - 90 * 60000);
expect(lot.leave(t.id)).toBe(40);
});
Structure each test as arrange (set up), act (do one thing), assert (check one behavior). Name tests by behavior, not method. Keep them independent and deterministic, so a failure means a real problem.
Warning signs
- A class that calls
newon its collaborators, so you cannot replace them. - Global or static state that survives between tests.
- Calls to
Date.now()orMath.random()buried in logic. - Methods that mix decisions with input and output.
- Tests that need a long setup, which usually means too many dependencies.
Coverage numbers show what ran, not what was checked, so treat them as a clue, not a goal. The better question is whether a change that breaks behavior would fail a test.