There was a bug in our backend (or so I thought). A certain bus’s morning trip was not ending, the system showed it active even at 5PM (it’s trip ends at 10:50AM). The actual bug was not even a bug, it was a corrupted data. A single stop in the trip route had a wrong scheduled arrival time, causing the delay calculation logic to calculate a delay of 624 minutes. Then why am I talking about this bug?
Because of what it led me to: The Hexagonal Architecture. Also known as the Ports and Adapters pattern.
The human brain can keep only so much in its short term memory (6 plus or minus 2 is what the science tells us). In other words, the more stuff you have to keep track of, the harder it becomes to reason about or understand. So it follows that good code, code that is readable and easy to reason about, should require us to keep track of as little as possible at any given time.
And I’m sure you can guess where I’m heading with this; yes, our backend code was the exact opposite. It required one to keep track of a whole bunch of stuff and wade through all kinds of concerns, concerns that didn’t have anything to do with the core domain logic directly, to understand and reason about the logical and data flow. And a practical offshoot is that the code is untestable or at least it is untestable without mocking half the world.
The Code
Our main scheduler logic, which dealt with the starting and ending of trips:
// scheduler/worker.js (simplified)
worker.process(async (job) => {
const { tripId, action } = job.data;
if (action == "start") {
const trip = await Trip.findByPk(tripId); // Infrastructure (Read)
const redisData = await redisClient.get(key); // Infrastructure (Read)
// Bussiness logic
if (redisData.trip_id !== trip.id) {
console.log("Conflict!");
return;
}
// 3. Infrastructure (Write)
await redisClient.del(key);
} else if (action == "end") {
//...
}
});
The New Code
The logic was split into three layers, with each layer doing one and one thing well.
- Domain -> Decisions (the core business logic lives here, easily testable!)
- Application -> Execution
- Worker -> Coordination
The Domain: Pure Functions
We extracted all decision-making complexity into a single place. This file imports nothing related to DB or Redis. It takes plain objects and returns a Decision.
// scheduler/domain/tripDecisions.js
export const evaluateTripAction = ({ trip, redisBusState, currentIstDate }) => {
// Pure Logic: No side effects!
if (redisBusState && redisBusState.trip_id !== trip.id) {
return [{
type: "END_SKIP_MISMATCH",
reason: "Bus active on different trip"
}];
}
// Logic: Check time guard
if (isTooEarly(currentIstDate, trip.scheduled_end_time)) {
return [{ type: "END_DEFER" }];
}
return [{ type: "END_COMPLETE", payload: { ... } }];
};
We can now test every single edge case with simple unit tests. No mocks required.
// schduler/tests/tripDecisions.test.js
test('Should return END_SKIP_MISMATCH if Bus active on different Trip', () => {
const decision = evaluateTripAction({
trip: { id: 101 },
redisBusState: { trip_id: 999 }, // Plain object!
action: 'end'
});
expect(decision[0].type).toBe("END_SKIP_MISMATCH");
});
The Application: The Side-Effect Handler
We moved the actual “doing” part to application/tripExecutor.js. It doesn’t “think”; it just executes instructions.
// scheduler/application/tripExecutor.js
export const executeDecisions = async (decisions, { redisClient, logger }) => {
for (const decision of decisions) {
switch (decision.type) {
case "END_SKIP_MISMATCH":
logger.warn("Skipping end due to mismatch");
break;
case "END_COMPLETE":
await redisClient.del(activeKey); // Only here do we touch Redis
break;
}
}
};
The Worker: The Coordinator
The worker.js became a dumb coordinator.
// worker.js
const trip = await Trip.findByPk(1); // 1. Fetch State
const redisState = await redisClient.get(key);
const decisions = evaluateTripAction({ // 2. Decide (Pure)
trip: trip.toJSON(),
redisBusState: JSON.parse(redisState)
});
await executeDecisions(decisions, { redisClient }); // 3. Execute (Impure)