visit
Picture the scene: You have a critical bugfix to make in your payments system, and the stakeholder is nervously watching on from the corner of the room asking helpful things like “How close are we to the fix?”.
You start by looking at some code in get-payment-methods.ts
, the top-level entry-point of an API route.
// get-payment-methods.ts
const getPaymentMethods = async (req) => {
const paymentMethods = await loadPaymentMethodsForCustomer(req.customerId);
return res.send(paymentMethods);
}
You need have a look at what loadPaymentMethodsForCustomer()
does. Ok, let's go.
CMD-click.
// services/payment-methods.ts
const loadPaymentMethodsForCustomer = async (customerId) => {
try {
const traceId = uuid();
const customerPaymentMethods = getPaymentMethodsOrThrow(customerId, traceId);
return customerPaymentMethods.map(someConversionFunction);
} catch(err) {
return errorHandler(err);
}
}
Hmm. Now you need to look at getPaymentMethodsOrThrow()
.
CMD-click. CMD-click. CMD-click.
// repositories/stripe.ts
await stripe.paymentMethods.list({ customer: stripeCustomerId });
Typically with nested functions, something is happening to the data at almost every level in the tree. For example, one function might do a try/catch
. Another might convert the array to just return the id
field of every payment method. Another might supplement that information with a second API call. And even after you have followed the loop all the way to the bottom of the tree and back up again, there might be an entire second set of nested functions that comes afterwards.
if(paymentMethod.expiry < new Date()) {
throw new Error("Oh dang, try a new card");
}
npm test
Let’s say that Function A calls Function B, which calls Function C. Function X also calls Function B, which calls Function C. Function Y calls Function Z, which in turn calls Function C, too.
So when you changed Function C, you broke functions, A, B, X, Y, and Z.
const functionA = async () => {
// complicated things here
const data = await functionB();
// more complicated things here
}
Test Function A and Function B
The problem with Option 2 is that mocks are nasty and reduce confidence in your tests. They are a useful tool when absolutely necessary, but the moment you change functionB
, your tests for functionA
are now at risk of giving you false positives.
I like to use something that I call the Orchestrator/Actions Pattern.
An Action can be almost anything. For example, an action might:
The most important characteristic of an action is that it strictly follows the Single Responsibility Principle. In simple terms, it should only do one of the above. Either fetch data, or transform it. Not both.
An Orchestrator calls many actions in a sequence.
const exampleOrchestrator = async () => {
const initialData = await getInitialData();
const calculation = runCalculation(initialData);
const otherData = await getOtherData(calculation);
const combinedData = combine({ initialData, otherData });
return combinedData;
}
The most important characteristic of an orchestrator is that you can clearly see the sequence of events. Sure, you might have some simple logic such as if
statements to determine which actions to call, but as long as it is easy to read, you have done a good job.
It isn’t always possible in reality to write code without any nesting. Sometimes, nesting can be helpful. But if you can generally strive to write your code using the Orchestrator/Actions Pattern, your code will be: