Skip to main content
Mohammad Shehadeh — home (MSH monogram, letter M filled with the Palestinian flag)

Deep Call Chains vs Orchestrator Pattern

Published on
4 min read

You open a file to trace a bug. You find handleSubmit, which calls validateForm, which calls sanitizeInput, which calls checkPermissions, which calls saveToDatabase. Five levels deep and you still don't know where the bug is.

This is the deep call chain problem, a common design smell in everyday code. The fix is simple: stop letting functions decide what runs next.

The Problem: Deep Call Chains

A deep call chain happens when function A calls function B, which calls C, which calls D. Each function owns its own logic and also triggers the next step.

deep-chain.ts
1const handleSubmit = (data: FormData) => {
2  validateForm(data);
3};
4
5const validateForm = (data: FormData) => {
6  const sanitized = sanitizeInput(data);
7  // validation logic happens after sanitize finishes
8};
9
10const sanitizeInput = (data: FormData) => {
11  checkPermissions(data);
12  // sanitization depends on permission check
13};
14
15const checkPermissions = (data: FormData) => {
16  saveToDatabase(data);
17  // saves only after permissions are checked
18};
19
20const saveToDatabase = (data: FormData) => {
21  // finally does the actual work
22  console.log("Saved:", data);
23};

At first glance, this looks organized. Each function has a "single responsibility." But look closer: serious problems hide in this structure.

Why This Hurts

  • You can't follow the flow. To understand what handleSubmit does, you open five functions across maybe five files. The execution path is buried inside nested calls.

  • You can't test in isolation. Want to test sanitizeInput? You can't, because it calls checkPermissions, which calls saveToDatabase. Every test for one function tests the whole chain.

  • You can't reuse anything. Need to sanitize input without checking permissions? Too bad, every function is coupled to the one it calls.

  • You can't change the order. What if a new requirement checks permissions before validation? You'd rewire the internals of multiple functions.

The Core Issue

When functions call each other in a chain, you create a hidden workflow scattered across your codebase. Nobody sees the full picture without tracing every call.

The Fix: Let One Function Own the Flow

Introduce a single function that knows the order. Each step becomes an independent function that does one thing and returns. One function at the top decides what runs, in what order.

orchestrator.ts
1const handleSubmit = (data: FormData) => {
2  checkPermissions(data);
3  const validated = validateForm(data);
4  const sanitized = sanitizeInput(validated);
5  saveToDatabase(sanitized);
6};
7
8const checkPermissions = (data: FormData) => {
9  // only checks permissions, nothing else
10};
11
12const validateForm = (data: FormData) => {
13  // only validates, returns validated data
14  return data;
15};
16
17const sanitizeInput = (data: FormData) => {
18  // only sanitizes, returns clean data
19  return data;
20};
21
22const saveToDatabase = (data: FormData) => {
23  console.log("Saved:", data);
24};

Now look at what changed:

  • The flow is visible. Read handleSubmit and you see the full workflow: check permissions, validate, sanitize, save.
  • Each function is independent. sanitizeInput knows nothing about permissions or databases. Test it with a simple input/output assertion.
  • Reordering is trivial. Need to validate before checking permissions? Swap two lines.
  • Reuse is free. Need sanitizeInput elsewhere? Just call it, it has no hidden dependencies.
The Mental Model

Functions should not decide what runs next. The orchestrator decides the order.

Key Takeaways

  • Functions should do work, not decide what comes next. One function holds the workflow; individual functions hold the logic.

  • Flat beats deep. A 10-line orchestrator calling 5 independent functions is easier to understand than 5 functions calling each other in a chain.

  • Testability follows naturally. When functions don't call each other, you test each one with simple inputs and outputs, no mocking required.

Next time you write a function that calls another function that calls another function, stop and ask: should this function know about the next step, or should someone else make that decision?

Related Articles

GET IN TOUCH

Let's work together

I build fast, accessible, and delightful digital experiences for the web. Whether you have a project in mind or just want to connect, I'd love to hear from you.

Get in touch

or reach out directly at hello@mohammadshehadeh.com