open atlas
↑ Back to track
Code patterns & craft CP · 02 · 02

One level of abstraction

A function should operate at one level of abstraction — high-level policy reads as a narrative that calls the next level down (the stepdown rule), with low-level detail extracted behind names so the reader can skim the decision without context-switching every line.

CP Middle ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You open a 40-line function called chargeSubscription. You want to answer one question: under what conditions does it actually charge the card? But you can’t skim for the answer, because the policy — “if the plan is past due and the grace window has expired, charge” — is interleaved with Math.floor((now - last) / 86_400_000), a hand-built SQL string, and a padStart that zero-pads an invoice number. Every line yanks you down to byte-and-string level, then back up to business level, then down again. You read all forty lines to extract three sentences of logic.

The function isn’t wrong — it ships, it charges correctly. It’s unskimmable, and that is a specific, fixable defect. The cause has a name: it mixes levels of abstraction. The fix has a name too: make each function speak at one level, so the high-level one reads like the narrative of the decision and delegates every “how” to a named call below it.

Goal

After this lesson you can spot when a function mixes high-level policy with low-level detail; apply the stepdown rule — each function operates at one level and reads as a narrative that calls the next level down; extract details behind intention-revealing names so the policy stays skimmable; and recognise the failure mode where over-extraction shatters a function into a maze of one-line indirections that is harder to read, not easier.

1

A function mixes levels when business policy and machine detail share the same body. “High level” is what the system decides — charge, retry, reject, notify. “Low level” is how a machine does a mechanical thing — divide milliseconds to get days, concatenate a SQL string, zero-pad a number, format a date. These are different altitudes of thought. When they’re stacked in one function, the reader has no choice but to fly the whole altitude range on every line.

function chargeSubscription(sub: Subscription, now: number): void {
  // high level: the decision
  if (sub.status === "past_due") {
    // low level: byte/date math, inline, mid-sentence
    const daysLate = Math.floor((now - sub.lastPaidAt) / 86_400_000);
    if (daysLate > 7) {
      // low level: hand-built SQL
      db.exec(
        `UPDATE subs SET charged_at=${now} WHERE id='${sub.id}'`
      );
      // high level again: the action
      gateway.charge(sub.cardToken, sub.amountCents);
    }
  }
}

To learn the policy — past due, more than 7 days, then charge — you had to read and mentally discard the 86_400_000, the SQL, and their syntax. That discarding is the cost.

2

The stepdown rule: each function should read as a sequence of statements one level below it. Robert Martin’s formulation is that a program reads top-down, like prose: every function is followed by the functions one level lower, so you can descend one step of abstraction at a time. Inside a function, that means its statements should all sit at roughly the same altitude, each delegating the next “how” downward. The top function tells the story; the named calls hold the details.

function chargeSubscription(sub: Subscription, now: number): void {
  if (!isPastDue(sub)) return;
  if (daysOverdue(sub, now) <= GRACE_DAYS) return;
  markCharged(sub, now);
  gateway.charge(sub.cardToken, sub.amountCents);
}

Now the body is the policy and only the policy. daysOverdue and markCharged are promises — “there’s a how here, but it’s not the decision; descend if you care.” A reader skimming for when do we charge gets the answer in four lines and never sees a millisecond constant.

3

Extraction is the move, and the name is the payload. Pulling the millisecond math into daysOverdue only helps because the name says what the detail means at the level above. The reader of chargeSubscription trades Math.floor((now - sub.lastPaidAt) / 86_400_000) for the word daysOverdue — a phrase in the language of the policy. The detail still exists; it has just been moved one level down and given a label the caller can read at its altitude.

const MS_PER_DAY = 86_400_000;

function daysOverdue(sub: Subscription, now: number): number {
  return Math.floor((now - sub.lastPaidAt) / MS_PER_DAY);
}

function markCharged(sub: Subscription, now: number): void {
  // the SQL detail lives here, at the data-access level, not in the policy
  db.update("subs", { id: sub.id }, { charged_at: now });
}

A bad name defeats the whole exercise: helper1 or doStuff makes the caller descend anyway to find out what happened. The extraction buys skimmability only in proportion to how well the name describes the detail in the caller’s vocabulary.

4

The failure mode: over-applying shatters reading into a maze of one-line indirections. The stepdown rule is a reading aid, not a line-count target. If you extract until every two-line fragment is its own function, the reader who does want the detail must now hop through five files to reassemble one thought, and the policy function calls names so fine-grained they restate the code instead of summarising it.

// Over-extracted: each call hides almost nothing, so the reader
// must chase every name to learn anything — net reading cost UP.
function chargeSubscription(sub: Subscription, now: number): void {
  if (shouldSkipBecauseNotPastDue(sub)) return;
  if (isStillWithinGrace(sub, now)) return;
  performTheChargeBookkeeping(sub, now);
  invokeThePaymentGateway(sub);
}
function invokeThePaymentGateway(sub: Subscription): void {
  gateway.charge(sub.cardToken, sub.amountCents); // one line, wrapped for no gain
}

invokeThePaymentGateway adds a hop without adding a concept — the original gateway.charge(...) was already at policy level and already self-describing. The test isn’t “is this function short” but “does each extraction name a concept the caller genuinely wants to think in?” Extract a detail when its mechanism is noise at the level above; inline it when the name would just echo the single call it wraps. The stepdown should aid reading, not fragment it.

Worked example

Take a real mixed-level function down to one level. Before — the policy “send a past-due reminder once per day” is buried under date formatting, a manual SQL upsert, and string assembly:

function sendOverdueReminder(sub: Subscription, now: number): void {
  // policy: only past due
  if (sub.status !== "past_due") return;

  // low level: did we already remind today? (ms math + date string)
  const today = new Date(now).toISOString().slice(0, 10);          // "2026-06-23"
  const lastDay = new Date(sub.lastReminderAt).toISOString().slice(0, 10);
  if (today === lastDay) return;

  // low level: hand-built body + manual SQL
  const amount = "$" + (sub.amountCents / 100).toFixed(2);
  const body = "Your payment of " + amount + " is overdue.";
  db.exec(
    `UPDATE subs SET last_reminder_at=${now} WHERE id='${sub.id}'`
  );

  // policy: the action
  mailer.send(sub.email, "Payment overdue", body);
}

To find the rule — “past due, and not already reminded today, then email” — you read past three different low-level techniques. After — the body is the rule, every “how” named and stepped down:

function sendOverdueReminder(sub: Subscription, now: number): void {
  if (sub.status !== "past_due") return;
  if (alreadyRemindedToday(sub, now)) return;

  mailer.send(sub.email, "Payment overdue", overdueEmailBody(sub));
  recordReminderSent(sub, now);
}

// one step down: each detail at its own level, named in the caller's vocabulary
function alreadyRemindedToday(sub: Subscription, now: number): boolean {
  return sameCalendarDay(sub.lastReminderAt, now);
}
function overdueEmailBody(sub: Subscription): string {
  return `Your payment of ${formatUsd(sub.amountCents)} is overdue.`;
}
function recordReminderSent(sub: Subscription, now: number): void {
  db.update("subs", { id: sub.id }, { last_reminder_at: now });
}

The top function now reads as four sentences of policy. Notice the restraint: mailer.send(...) stayed inline because it was already at policy altitude and self-describing — wrapping it would add a hop for nothing. We extracted the date math, the string building, and the SQL — the three things that were genuinely a level below the decision — and stopped there.

Why this works

Why does mixed abstraction specifically hurt skimming, when the code runs identically either way? Because reading is a context-switch budget. Each time a line drops you from “what we decide” to “how milliseconds become days,” your working memory has to swap the relevant mental model in and out. A function at one level lets you hold a single model — “the charge policy” — for its whole length; you only swap models when you deliberately step down into a named detail. The stepdown rule isn’t about line count or even reuse (these details may be called once). It’s about letting the reader choose their altitude instead of forcing the full range on every line — which is exactly what makes a 40-line function answerable in a 4-line skim.

Common mistake

The common overcorrection: treating “extract until short” as the rule and ending up with chargeSubscription calling validateThePreconditions calling checkPastDueStatus calling getStatusField. Now a reader who wants the actual condition chases four hops to find sub.status === "past_due" — the indirection costs more than the inline check ever did. Two smells flag this: a wrapper whose name merely restates its one call (invokeThePaymentGateway around gateway.charge), and a function whose body is only calls to other one-line functions you wrote. Extract a detail because its mechanism is noise at the level above — not to hit a length number. When the name would just echo the single line it wraps, inline it.

Check yourself
Quiz

A reviewer says a 35-line order function is 'hard to skim.' It interleaves the policy (when to ship) with inline tax math, a hand-built SQL string, and date formatting. What is the most precise fix, and what bounds it?

Recap

A function is unskimmable when it mixes levels of abstraction — when business policy (charge, remind, ship) is interleaved with machine detail (millisecond math, hand-built SQL, string formatting), forcing the reader to context-switch altitudes on every line. The fix is the stepdown rule: keep each function at one level so it reads as the narrative of its decision, and extract every “how” behind an intention-revealing name one step down — the name is the payload, because it lets the caller read the detail in its own vocabulary. The skill is judgment, not mechanics: extract a detail when its mechanism is noise at the level above, but stop before over-extraction shatters the function into a maze of one-line wrappers that merely restate the code — the stepdown should let the reader choose their altitude, not fragment the one they wanted.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.