skip to content

Keeping a routine at a single level of abstraction means extracting its lower-level steps into named sub-routines. Where those sub-routines are allowed to live differs sharply by language. How does that change the decision, and what goes wrong when the only home is the type's own member list?

level: middleimportance: should knowfreq 45%

answer

  1. one level per routine — but where does the step live?
  2. where / internal define / nested procedure = zero surface
  3. Java private = hidden from others, not from the class
  4. Go: no nested funcs, package scope only
  5. C++ anonymous namespace = private non-member home

basics

~20 s

If a step's only home is a class member or a package-level function, every extraction widens a namespace and adds public-ish surface. Haskell where-bindings, Scheme internal defines, Pascal nested procedures and Kotlin local funs let a routine gain levels without gaining API.

solid answer

~50 s

Extracting a step costs whatever the language charges for naming it. - **Haskell, Scheme, Pascal, Kotlin**: `where` bindings, internal `define`, nested procedures and local `fun`s are invisible outside the enclosing routine, so splitting is nearly free and idiomatic code splits aggressively. Price: the step cannot be unit-tested directly, and closures capture more context than the signature shows. - **Java and C#**: `private` hides a helper from other classes but not from the class or its tests; twelve extracted steps become twelve members that every later reader must place. Kotlin sits in both camps because it also allows local functions. - **Go**: no nested named functions — only closures assigned to variables — and package scope is the alternative home, which is part of why idiomatic Go tolerates longer functions with inline error handling. - **C++**: anonymous-namespace free functions give a private, non-member home, so a class need not grow to gain levels.

code

haskell · 6 lines
haskell
report cust = header ++ body ++ footer
  where
    header = formatName cust
    body   = concatMap line (orders cust)
    footer = totalLine cust
-- header/body/footer are invisible outside report

go deeper

for a junior

Recognise a routine that mixes a business step with byte-level fiddling, and know the fix is to name the low-level fragment.

for a middle

Explain that the cost of extraction depends on the available scope — nested/local definitions versus class members versus package scope — and name a language in each camp.

for a senior

Judge when a step should stay local, become a member, or trigger a new type; and connect Go's flatter style and Haskell's aggressive splitting to their scoping rules rather than to taste.

for a principal

Set the house convention with its consequences: how member ordering, file-private helpers and small workflow types keep level structure legible in a language whose only namespace is the type.

## The rule and its hidden cost "One level of abstraction per routine" says a function should read as a sequence of steps that are all roughly the same size of idea: either policy ("charge the card, then send the receipt") or mechanics ("append the byte, advance the cursor"), never a mixture where one line orchestrates a workflow and the next fiddles with an index. The remedy is always the same shape: give the low-level fragment a name and call it. What the rule never says is where that name goes. That is a language question, and it decides how expensive the rule is to follow. ## Languages with a private home for a step Pascal, in which Wirth's stepwise refinement was taught, has nested procedures: a helper is declared inside its caller and does not exist outside. Scheme and Racket have internal `define`; Haskell has `where` and `let`; Python and JavaScript allow nested `def`/function declarations; Kotlin allows local `fun`s inside a function body. In all of these, adding a level of abstraction adds zero surface area. Nothing else can call the helper, nothing else has to be told the helper is not for them, and the file reads top-down. The price is genuine. A locally scoped helper is not directly testable, so teams that test at fine granularity end up promoting helpers outward anyway — which is the surface-area cost arriving late. Nested definitions also close over the enclosing scope, so a helper's real inputs are wider than its parameter list; Python's late-binding closures are the classic trap, where a nested function reads the loop variable's final value rather than the value at definition time. ## Languages where the type is the only home In Java and C# the natural home for an extracted step is a private method on the same class. `private` is real hiding from other classes, but not from this one: every later reader of the class sees twelve short methods and must reconstruct which are steps of one workflow and which are independent operations. This is the honest core of the "maze of tiny methods" complaint — the objection is rarely to short methods, it is to a flat member list that has lost the nesting the decomposition actually had. Conventions compensate: ordering members newspaper-style (caller above callee), or extracting the whole workflow into its own small class so the member list *is* the step list. C++ offers an escape the JVM-style languages lack: a free function in an anonymous namespace inside the .cpp file is private to the translation unit and not a member at all, so the class does not grow when the implementation gains levels. Go removes both escapes: there are no nested named functions, and there is no file-private scope — lowercase means package-private. So the alternative to a longer function is a package-level helper visible to the whole package. That cost is one reason idiomatic Go tolerates flatter functions with `if err != nil` repeated inline rather than factored away, and why the culture prefers a little duplication to an extra dependency. ## Smalltalk and Ruby: vocabulary on the object Kent Beck's composed method pattern pushes Smalltalk code toward many tiny methods, and Smalltalk methods are all part of the class protocol (Smalltalk-80 has no private modifier; visibility is convention plus method categories). The abstraction levels therefore live on the object and are browsable in the image — the tool, not the language, keeps the list legible. Ruby has `private`, and shares the same style. ## How to decide Ask what the extraction is. If the step is a *detail of this one routine*, prefer the most local home the language offers; you keep the level without paying surface area. If the step is a *concept in its own right* that other routines will want, its home is a member or a module function, and the fact that other code can call it is a feature. If your language forces every extraction into a shared namespace, the pressure valve is a new type: a small object whose methods are exactly the steps, so the level structure is visible instead of dissolved into a flat list. And notice when the rule is fighting you — four extractions that all need three of the same parameters is the code telling you the missing abstraction is a type, not four more functions.

  • A colleague objects that extracting steps into locally scoped helpers makes them untestable. How do you answer?
    It is true and it is the right default anyway: a local helper is an implementation detail of one routine, and the routine's own tests exercise it. If a helper genuinely deserves its own tests, that is evidence it is a concept rather than a step, and promoting it to a module function or its own type is the correct response — not testing private internals through reflection or visibility relaxation.
  • When does splitting a routine into levels make code harder rather than easier to read?
    When the extracted names restate the code rather than naming an idea (a helper called addOneToIndex), and when the steps share so much state that each needs four parameters or the class gains four fields to communicate between them. Both signal that the missing abstraction is a type that owns the state, not a set of functions over it.

saying these in an interview costs you the question

  • Treating a line-count threshold as the rule, rather than mixed levels within one routine
  • Claiming private methods are invisible — they are visible to the whole class and often to its tests
  • Believing extraction is always free, ignoring the namespace and reading-order cost when the type is the only home
  • Extracting four helpers that each take the same three parameters instead of introducing the type that owns them

context