Loading learning content...
If over-abstraction is the disease of the intermediately skilled engineer, under-abstraction is frequently the disease of the pragmatic hacker—and sometimes of teams that overcorrected from previous over-abstraction. Under-abstraction occurs when code lacks the generalizations needed to prevent duplication, inconsistency, and rigidity.
The symptoms are familiar: copy-pasted logic scattered across files, subtle bugs where one copy was fixed but others weren't, and a growing sense that every small change requires hunting through the entire codebase.
By the end of this page, you will understand what under-abstraction is, why engineers avoid necessary abstraction, the concrete costs it imposes on software projects, and practical heuristics to identify when abstraction is genuinely needed.
Under-abstraction occurs when the absence of abstraction creates costs that would be eliminated by appropriate generalization. Unlike over-abstraction, which adds unnecessary complexity, under-abstraction fails to capture commonalities that exist in the problem domain.
Under-abstraction manifests in characteristic patterns:
Under-abstracted code often seems simpler initially—no layers to traverse, no patterns to learn. But this simplicity is illusory. The real complexity is distributed across duplications, each of which must be maintained separately. What looks simple is actually just scattered.
Understanding why under-abstraction occurs helps prevent it. Several forces push engineers away from necessary abstraction:
The "just ship it" trap:
In fast-moving environments, there's constant pressure to deliver quickly. Abstraction feels like gold-plating—unnecessary polish that slows delivery. But this perspective confuses premature abstraction with appropriate abstraction.
Premature abstraction is harmful because it guesses at patterns that may not exist. Appropriate abstraction extracts patterns that already exist and are already causing problems. The former is speculation; the latter is debt repayment.
Teams that never abstract because "we'll clean it up later" often never do. The debt accumulates until the codebase becomes a maintenance nightmare, and eventually a rewrite seems easier than rehabilitation.
The best time to abstract is immediately after you identify the pattern. The second duplication is the signal; don't wait for the third, fourth, or fifth. When you copy code and think "this is similar to...", that's the moment to stop and extract the abstraction.
Under-abstraction imposes severe costs on software projects, often worse than the abstractions it avoids would have cost:
| Cost Category | Impact | Example |
|---|---|---|
| Duplicated Bug Fixes | Every bug must be found and fixed in multiple locations | Security vulnerability patched in 2 of 5 copies; 3 remain exploitable |
| Inconsistent Behavior | Same concept behaves differently in different contexts | Order total calculation differs between cart and checkout |
| Change Amplification | Single logical change requires multiple code changes | Updating tax calculation requires changes in 12 files |
| Knowledge Loss | Logic purpose unclear when scattered and duplicated | "Why do we add 0.5 before rounding? No one remembers" |
| Test Burden | Same logic tested repeatedly, often inconsistently | Cart total has 50 tests; checkout total has 3 |
| Onboarding Confusion | New developers can't find 'the' implementation | "Which of these 4 validation functions should I use?" |
| Type Safety Erosion | Primitive types can't prevent semantic errors | Mixing up orderId and customerId (both strings) |
The shotgun surgery problem:
Under-abstraction's signature pain is shotgun surgery: a single logical change requires modifications in many different places. If you need to update how prices are formatted, and price formatting logic is copy-pasted in 15 places, you're performing shotgun surgery on every such change.
The risk isn't just the effort—it's the probability of missing one location. Every incomplete shotgun surgery creates an inconsistency. Over time, the codebase diverges from itself, with subtly different behaviors depending on which code path is executed.
Under-abstracted codebases require constant vigilance to maintain consistency. Every change must be cross-referenced against other locations. This vigilance is an invisible tax on all development—and taxes are often unpaid, leading to bugs.
Let's examine a concrete example of under-abstraction and its consequences. Consider a system that validates email addresses in multiple places:
123456789101112131415161718192021222324252627282930313233343536373839404142
// user-registration.tsfunction registerUser(email: string, password: string) { // Inline email validation if (!email || !email.includes('@') || email.length < 5) { throw new Error('Invalid email'); } // ... registration logic} // newsletter-signup.ts function subscribeToNewsletter(email: string) { // Similar but slightly different validation if (!email.includes('@') || !email.includes('.')) { throw new Error('Please enter a valid email'); } // ... subscription logic} // contact-form.tsfunction submitContactForm(email: string, message: string) { // Yet another variation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { throw new Error('Email format is invalid'); } // ... form submission logic} // password-reset.tsfunction requestPasswordReset(email: string) { // Copy-pasted with modifications if (email.indexOf('@') === -1) { throw new Error('Not a valid email address'); } // ... reset logic} // Problems:// 1. Four different validation rules for the same concept// 2. Different error messages cause inconsistent UX// 3. Bug in one (missing null check) doesn't affect others// 4. Updating validation requires finding and changing all copiesNow let's see the properly abstracted version:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// email.ts - Single source of truth for email concept /** * Email value object - represents a validated email address. * If an Email instance exists, it's guaranteed to be valid. */class Email { private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; private constructor(private readonly value: string) {} /** * Factory method that validates and creates Email. * Throws if email is invalid, ensuring all Email instances are valid. */ static create(input: string): Email { if (!input || typeof input !== 'string') { throw new InvalidEmailError('Email is required'); } const trimmed = input.trim().toLowerCase(); if (!Email.EMAIL_REGEX.test(trimmed)) { throw new InvalidEmailError(`'${input}' is not a valid email address`); } return new Email(trimmed); } static isValid(input: string): boolean { try { Email.create(input); return true; } catch { return false; } } toString(): string { return this.value; } equals(other: Email): boolean { return this.value === other.value; } getDomain(): string { return this.value.split('@')[1]; }} class InvalidEmailError extends Error { constructor(message: string) { super(message); this.name = 'InvalidEmailError'; }} // Now all usages are consistent: // user-registration.tsfunction registerUser(email: Email, password: string) { // Email is already validated by type system // ... registration logic} // newsletter-signup.ts function subscribeToNewsletter(email: Email) { // Guaranteed valid // ... subscription logic} // Usage at API boundary:const email = Email.create(request.body.email); // Validates onceregisterUser(email, password); // Benefits:// 1. Single validation rule, enforced everywhere// 2. Consistent error handling// 3. Type safety prevents mixing up strings// 4. Rich domain model (getDomain, equals, etc.)// 5. Changes in one place affect entire systemThe Email class is a value object—it represents a concept from the domain with specific semantics. Using value objects instead of primitives eliminates entire categories of bugs and creates self-documenting code. If a function takes an Email parameter, you know it requires a valid email without checking the implementation.
Identifying under-abstraction requires examining your codebase for patterns of duplication and scattered logic. Here are concrete signals:
Review recent pull requests. If any PR fixed a bug by changing similar code in multiple files, you've identified under-abstraction. Track these occurrences—they're a roadmap for where abstractions are needed.
Addressing under-abstraction requires both immediate refactoring and cultural changes to prevent recurrence:
The Boy Scout Rule:
"Always leave the code better than you found it." When you encounter duplication while working on a feature, take time to extract the abstraction. Don't create a separate "refactoring sprint"—integrate refactoring into daily work.
This incremental approach has several advantages:
When you see the second occurrence of similar code, extract the abstraction immediately. Don't wait for a third. The marginal cost of extracting on the second occurrence is minimal; the marginal cost of tracking down a fifth occurrence later is substantial.
Primitive Obsession deserves special attention because it's the most pervasive form of under-abstraction. It occurs when developers use built-in types (strings, numbers, booleans) to represent domain concepts that deserve their own types.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// Primitive Obsession - Using strings for everything function processOrder( orderId: string, // Could be any string customerId: string, // Easy to mix up with orderId productCode: string, // No validation quantity: number, // Could be negative priceInCents: number // Unit unclear, could be dollars): void { // What happens if we pass customerId where orderId is expected? // The compiler won't catch it - they're both strings} // Realistic bug: arguments swappedprocessOrder( customer.id, // Oops, should be orderId order.id, // Oops, should be customerId "ABC123", 5, 1999); // --- // Proper Domain Types - Self-documenting and type-safe class OrderId { private constructor(private readonly value: string) {} static create(value: string): OrderId { if (!value.match(/^ORD-\d{6}$/)) { throw new Error('Invalid order ID format'); } return new OrderId(value); } toString(): string { return this.value; }} class CustomerId { private constructor(private readonly value: string) {} static create(value: string): CustomerId { if (!value.match(/^CUS-\d{8}$/)) { throw new Error('Invalid customer ID format'); } return new CustomerId(value); } toString(): string { return this.value; }} class Money { private constructor( private readonly cents: number, private readonly currency: string ) {} static usd(dollars: number): Money { return new Money(Math.round(dollars * 100), 'USD'); } static cents(cents: number): Money { if (!Number.isInteger(cents)) { throw new Error('Cents must be integer'); } return new Money(cents, 'USD'); } add(other: Money): Money { if (this.currency !== other.currency) { throw new Error('Cannot add different currencies'); } return new Money(this.cents + other.cents, this.currency); } toDisplayString(): string { return `$${(this.cents / 100).toFixed(2)}`; }} class Quantity { private constructor(private readonly value: number) {} static create(value: number): Quantity { if (!Number.isInteger(value) || value < 1) { throw new Error('Quantity must be positive integer'); } return new Quantity(value); } getValue(): number { return this.value; }} // Now the function signature prevents bugsfunction processOrder( orderId: OrderId, // Can only be OrderId customerId: CustomerId, // Can only be CustomerId product: ProductCode, quantity: Quantity, price: Money): void { // Impossible to swap orderId and customerId // quantity cannot be negative // price has clear semantics} // Compiler catches the mistake!processOrder( customerId, // Error: CustomerId not assignable to OrderId orderId, // Error: OrderId not assignable to CustomerId productCode, quantity, price);Create a domain type when: (1) the concept has validation rules, (2) it appears in multiple places, (3) it has associated operations, or (4) mixing it with similar primitives would be a bug. Most domain concepts meet at least one of these criteria.
Under-abstraction may seem like simplicity, but it's actually complexity scattered across the codebase. Let's consolidate the essential insights:
What's Next:
We've explored over-abstraction and under-abstraction—the two extremes of the abstraction spectrum. Both cause harm, but a particularly dangerous form occurs when abstraction is created at the wrong time: premature abstraction. The next page examines this timing problem and develops heuristics for knowing when to abstract.
You now understand under-abstraction: its definition, causes, costs, recognition signals, and remediation strategies. The key insight is that avoiding abstraction isn't simplicity—it's deferred complexity that accumulates interest over time.