Shallow Abstraction.
An AI assistant wraps code in extra functions, classes, or interfaces that add a layer of indirection without hiding any complexity or enabling reuse โ abstractions that merely forward to a single call site.
##Signs and Symptoms
A reviewer recognizes Shallow Abstraction when a new function/class/interface adds a name and a layer but no leverage: its body restates its signature, it forwards straight through to one underlying call, it is invoked exactly once, or it hides no decision, invariant, or variation. The code looks layered and "enterprise-grade," but you have to read through every layer to understand anything โ the abstraction encapsulates nothing.
Tell-tale signs:
- Pass-through functions/methods whose body is a single delegating call with no added behavior.
- Wrapper classes (
FooServiceโFooRepositoryโdb) where each layer just calls the next. - Single-implementation interfaces (
IClockwith onlySystemClock) introduced speculatively "for testability" with no second impl or fake. utils/helpersfunctions used in exactly one place.- Redundant aliasing/renaming that forwards a value unchanged.
This is the classic Middle Man / Lazy Class / Speculative Generality smell, and the flip side of OX Security's Over-Specification finding ("hyper-specific, single-use solutions that can't be adapted for other purposes").
// AI added three "layers" that each forward to the next.
function getUserName(id: string) {
return fetchUserName(id); // adds nothing
}
function fetchUserName(id: string) {
return userRepository.getName(id); // adds nothing
}
class UserRepository {
getName(id: string) {
return db.query("SELECT name FROM users WHERE id = ?", [id]);
}
}
// getUserName is the only call site, used once.
// Two of the three layers add zero behavior โ pure Middle Man.
##Reasons for the Problem
Why models produce it
- Imitating the shape of good architecture. Training corpora are saturated with tutorials and enterprise scaffolding โ service/repository/factory layers, DI, one-impl interfaces. The model pattern-matches the ceremony of abstraction without the substance, because a named helper is locally plausible and "looks professional." Next-token generation optimizes for resembling well-structured code, not for whether a layer earns its keep.
- No repo context. Generating locally, the model can't see that the helper is called once, or that a canonical abstraction already exists elsewhere โ so it invents fresh shallow layers instead of reusing them. GitClear's 2025 analysis measured the matching trend: refactoring-associated ("moved") lines fell from 25% of changed lines in 2021 to under 10% in 2024, while copy/pasted lines rose from 8.3% to 12.3% โ what GitClear headlines as 4x growth in code clones. Models add structure but rarely consolidate it.
- By-the-book fixation + over-specification. The OX Security report on 300+ repositories lists By-The-Book Fixation โ code that "rigidly adheres to standard patterns even when more innovative solutions would be more effective" โ and Over-Specification โ "hyper-specific, single-use solutions that can't be adapted for other purposes" โ each in 80โ90% of AI-generated code, plus Avoidance of Refactors (also 80โ90%): AI "excels at generating new code but never goes back to improve architecture." Shallow wrappers are the visible residue.
- Prompt-literalism and sycophancy. Told to "add a service layer" or "make it clean/modular," the model adds layers literally even when YAGNI applies โ over-engineering on request rather than on need.
- Training-cutoff staleness. It will wrap a deprecated API in a thin adapter rather than migrate off it.
Why it hurts
- Maintainability & review load. Every change must thread through dead layers; readers have to step through each Middle Man to reach the real call. OX likens AI to "an army of talented but inexperienced junior developers" whose output outpaces review โ and code that looks structured but has no real seams is exactly what slips through.
- Correctness & security. Every wrapper and alias is another hop between the input and the call that matters: a reviewer has to follow it, and so does any data-flow or taint analysis. A pass-through that "adds nothing" can still swallow an error or hide a sink behind an innocuous name.
- Tech-debt accrual. Speculative one-impl interfaces ossify; and because each shallow abstraction is single-use, the next variation gets copy-pasted instead of parameterized โ directly feeding the clone growth GitClear measured. You pay for the indirection without ever collecting the reuse it promised.
##Treatment
Review & prompting tactics
- Apply YAGNI and the Rule of Three: "Don't add a function/class/interface for reuse until three call sites actually need it; below that, a new layer must hide a real decision or invariant. Inline single-use helpers."
- Force reuse: "Before adding a helper/service/interface, search the repo for an existing one and reuse it." Point the model at the canonical module.
- Make it justify each layer: "List every new function/class and state what variation or invariant it hides; delete any that just forward."
- Require it to run the linter (
no-useless-constructor,no-useless-rename) and a duplication scan (jscpd) and fix what they flag.
Refactorings (name them): Inline Method, Inline Class, Remove Middle Man for pass-throughs; Collapse Hierarchy / drop the interface for single-impl interfaces. When duplication is the real driver, apply Extract Method once into a genuinely shared, parameterized helper โ not a wrapper per call site.
Before โ Middle Man:
function getUserName(id: string) { return fetchUserName(id); }
function fetchUserName(id: string) { return userRepository.getName(id); }
After โ Inline Method / Remove Middle Man (one call site, no behavior added):
const name = userRepository.getName(id);
Before โ Speculative one-impl interface:
interface IClock { now(): number; }
class SystemClock implements IClock { now() { return Date.now(); } }
After โ Collapse Hierarchy (use the concrete type; reintroduce the interface only when a second impl, e.g. a test fake, actually exists):
class SystemClock { now() { return Date.now(); } }
##Detected by
- ESLint no-useless-constructor โ Pass-through constructor that only delegates to super or is empty
- ESLint no-useless-rename โ Redundant import/export/destructuring aliasing that forwards a name unchanged
- PMD (Java) UselessOverridingMethod โ Overriding method that merely calls super with the same arguments
- SonarSource (Sonar S4144) S4144 โ Flags duplicated single-use abstractions with identical bodies โ Functions/methods should not have identical implementations
- jscpd --threshold โ Detects the near-duplicate single-use helpers that shallow abstractions fail to consolidate โ duplication threshold