Abstraction Failures Anti-Patterns — Exercises¶
Category: Design Anti-Patterns → Abstraction Failures — hands-on practice fitting the abstraction to the problem instead of forcing the problem into the abstraction. Covers (collectively): Golden Hammer · Inner-Platform Effect · Interface Bloat · Premature Abstraction
These are fix-it exercises, not recognition quizzes. Abstraction failures are the subtlest design mistakes because the offending code usually works — it compiles, it passes tests, it ships. The damage is in the wrong shape: a regex where a substring check belongs, a hand-rolled rule engine reimplementing if, an interface so wide no class can honestly implement it, a strategy pattern guarding a single concrete case. Each one trades clarity and performance for a generality nobody asked for.
How to use this file. Read the problem, attempt the fix in your editor before opening the solution, then compare. The "why it's better" note matters more than the diff — the goal is to internalize the judgment, not memorize a rewrite. The hardest exercise here (Exercise 11) is the one where the right answer is leave it alone: not every interface is bloated, not every shared tool is a Golden Hammer, and over-correcting is its own anti-pattern. Refer back to
junior.mdfor the shapes andmiddle.mdfor the countermoves.
Table of Contents¶
| # | Exercise | Anti-pattern(s) | Lang | Difficulty |
|---|---|---|---|---|
| 1 | Regex where a substring check belongs | Golden Hammer | Python | ★ easy |
| 2 | HashMap where an array indexes itself | Golden Hammer | Go | ★ easy |
| 3 | Inline the one-implementation interface | Premature Abstraction | Go | ★ easy |
| 4 | Collapse the speculative factory | Premature Abstraction | Java | ★★ medium |
| 5 | ORM in a hot path → targeted query | Golden Hammer | Python | ★★ medium |
| 6 | Segregate the bloated interface | Interface Bloat | Java | ★★ medium |
| 7 | Kill the UnsupportedOperationException implementer | Interface Bloat | Java | ★★ medium |
| 8 | Dismantle the in-house JSON rule engine | Inner-Platform Effect | Python | ★★★ hard |
| 9 | Replace the in-DB config interpreter with a plugin seam | Inner-Platform + Golden Hammer | Go | ★★★ hard |
| 10 | Wait for the rule of three | Premature Abstraction | Python | ★★ medium |
| 11 | Judgment: when to leave it alone | all four (judgment) | mixed | ★★★ hard |
| 12 | Mini-project: untangle the NotificationKit | all four | Python | ★★★★ project |
| 13 | Write an abstraction-failure review checklist | meta | — | ★★ medium |
Exercise 1 — Regex where a substring check belongs¶
Anti-pattern: Golden Hammer · Language: Python · Difficulty: ★ easy
Someone reaches for re reflexively. The task is a fixed-string membership test — no patterns, no groups, no alternation. The regex is slower, harder to read, and (here) subtly wrong.
import re
def is_image_upload(filename: str) -> bool:
# "validate" the extension with a regex
return bool(re.match(r".*\.(jpg|jpeg|png|gif)", filename))
def mentions_error(log_line: str) -> bool:
return bool(re.search(r"error", log_line))
Acceptance criteria - Neither function uses re. - is_image_upload matches only a real trailing extension (the regex above accepts evil.png.exe because it is unanchored). - Behavior is at least as correct as the original, and clearer.
Hint: str.endswith accepts a tuple of suffixes. in tests substring membership. A regex earns its place only when you need pattern matching — classes, quantifiers, anchors, captures — not literal text.
Solution
**Why it's better.** `endswith` and `in` say exactly what they mean — no parsing of a mini-language to figure out the intent. They are also faster (no regex compilation or backtracking) and, crucially, *more correct*: the original `re.match(r".*\.(jpg|jpeg|png|gif)", "evil.png.exe")` returns a match because the pattern is unanchored and `.*` is greedy, so the file `evil.png.exe` would be treated as an image. `endswith` cannot make that mistake. The regex was a Golden Hammer — a powerful tool applied to a problem that did not need it, and the extra power introduced a bug. Keep the regex for things that are genuinely patterns (e.g. `\d{4}-\d{2}-\d{2}`), not for literal suffix checks.Exercise 2 — HashMap where an array indexes itself¶
Anti-pattern: Golden Hammer · Language: Go · Difficulty: ★ easy
The keys are small, dense, contiguous integers (HTTP status codes grouped into classes, days of the week, board squares). A map is the author's default container, so a map is what they used — paying hashing, pointer chasing, and allocation for a lookup an array does in one instruction.
// Count how many requests fell into each HTTP status class (1xx..5xx).
func tallyByClass(statuses []int) map[int]int {
counts := map[int]int{}
for _, s := range statuses {
class := s / 100 // 1..5
counts[class]++
}
return counts
}
Acceptance criteria - The dense, bounded key space (classes 1–5) is stored in a fixed-size array, not a map. - The function returns the same per-class counts. - No hashing or heap allocation per lookup.
Hint: when keys are integers in a known small range, the array is the index. var counts [6]int covers classes 0–5; index by the class directly.
Solution
**Why it's better.** The key space is small, dense, and known at compile time, so the array index *is* the lookup — one bounds check and a direct memory offset, versus the map's hash, bucket walk, and pointer dereference. The array is stack-allocated and contiguous, so it is cache-friendly and triggers no GC pressure; a `map[int]int` allocates a header and buckets on the heap. The bounds guard (`class >= 1 && class <= 5`) also makes the "what about a garbage status like 999 or -1?" question explicit, where the map silently created a junk key. A map is the right tool for sparse, large, or unknown key sets — none of which describes five HTTP classes. Reach for the array when the keys are dense integers.Exercise 3 — Inline the one-implementation interface¶
Anti-pattern: Premature Abstraction · Language: Go · Difficulty: ★ easy
An interface was introduced "for flexibility." There is exactly one implementation, exactly one caller, and no test injects a fake. It is pure indirection: the reader has to jump through the interface to find the only behavior that exists.
// pricing.go
type TaxCalculator interface {
Calculate(amount float64) float64
}
type usTaxCalculator struct{ rate float64 }
func (c usTaxCalculator) Calculate(amount float64) float64 {
return amount * c.rate
}
func NewTaxCalculator() TaxCalculator {
return usTaxCalculator{rate: 0.08}
}
// the only call site:
func TotalWithTax(amount float64) float64 {
calc := NewTaxCalculator()
return amount + calc.Calculate(amount)
}
A grep shows: TaxCalculator has one implementer (usTaxCalculator), one constructor, and one caller (TotalWithTax). No test references the interface.
Acceptance criteria - The interface and constructor are gone unless a current need justifies them. - The single behavior is reachable directly, not through a factory. - You can state the condition under which you would keep the interface.
Hint: an interface with one implementation and no test seam is not a seam — it is a hop. Collapse it to a function or a concrete value. The abstraction can come back the day a second tax regime actually arrives.
Solution
The interface, the struct, and the factory are deleted. `TotalWithTax` now expresses the whole computation in one line. **When you would keep an abstraction instead:** the moment there is a *real* second case — say a `caTaxCalculator` with a different rate and a different rule for tax-exempt items — extract an interface (or, more idiomatically in Go, pass the rate or a small strategy function). Or, if a test genuinely needs to inject a fake calculator to simulate a downstream pricing failure, a one-method seam earns its keep. The deciding question: *does this abstraction serve a need that exists today?* A guessed interface for an imagined future tax regime does not; it just guesses the shape wrong and forces every reader to pay the indirection tax now. **Why it's better.** A single-implementation interface is a prediction about the future, and predictions made before the second case are almost always wrong — the real `caTaxCalculator` will need a parameter the guessed `Calculate(amount)` signature did not anticipate, so you would have changed the interface anyway. Until then, the interface only obscures: the reader follows `NewTaxCalculator` → `usTaxCalculator` → `Calculate` to find a single multiplication. Go's structural typing means the interface costs nothing to *re-add* later — extract it from the concrete code once the second implementation reveals the real shape. This is the **rule of three** in the negative: do not abstract on the first instance.Exercise 4 — Collapse the speculative factory¶
Anti-pattern: Premature Abstraction · Language: Java · Difficulty: ★★ medium
A Factory + Strategy hierarchy was built to "support multiple payment providers." Two years on there is still one provider. The architecture predicts a future that has not arrived, and every new developer must learn the ceremony to make one Stripe charge.
public interface PaymentGateway {
PaymentResult charge(Money amount, Card card);
}
public class StripeGateway implements PaymentGateway {
public PaymentResult charge(Money amount, Card card) {
// the only real implementation
return stripeClient.createCharge(amount, card);
}
}
public class PaymentGatewayFactory {
public PaymentGateway create(String provider) {
switch (provider) {
case "stripe": return new StripeGateway();
default: throw new IllegalArgumentException("unknown provider: " + provider);
}
}
}
// caller:
PaymentGateway gw = new PaymentGatewayFactory().create("stripe"); // "stripe" is hard-coded everywhere
PaymentResult r = gw.charge(amount, card);
There is no second provider on the roadmap, the string "stripe" is hard-coded at every call site, and the factory's switch has exactly one real case.
Acceptance criteria - The factory and the stringly-typed create("stripe") dispatch are removed. - The single real behavior (StripeGateway.charge) is reachable directly. - You preserve a test seam if — and only if — tests need to fake the gateway. - You can describe the cheap path back to a real abstraction when a second provider appears.
Solution
// One concrete class. No factory, no string dispatch.
public class StripeGateway {
private final StripeClient stripeClient;
public StripeGateway(StripeClient stripeClient) {
this.stripeClient = stripeClient;
}
public PaymentResult charge(Money amount, Card card) {
return stripeClient.createCharge(amount, card);
}
}
// caller — no factory, no magic string:
PaymentResult r = stripeGateway.charge(amount, card);
Exercise 5 — ORM in a hot path → targeted query¶
Anti-pattern: Golden Hammer · Language: Python · Difficulty: ★★ medium
The team's Golden Hammer is the ORM: every data access goes through it, including a per-request hot path that runs thousands of times a second. The ORM hydrates full objects and triggers an N+1 cascade where a single aggregate query would do. The ORM is fine almost everywhere — the mistake is using it uncritically in the one place it hurts.
# Called on every dashboard load; orders table has millions of rows.
def total_spend(customer_id: int) -> Decimal:
customer = Customer.objects.get(id=customer_id) # 1 query
total = Decimal("0")
for order in customer.orders.all(): # 1 query, hydrates every Order object
for line in order.lines.all(): # N queries — one per order (N+1)
total += line.unit_price * line.quantity
return total
For a customer with 400 orders this issues ~402 queries and instantiates thousands of ORM objects just to sum some numbers.
Acceptance criteria - The result is computed in one database round-trip. - No full-object hydration for a computation that only needs a sum. - The rest of the codebase keeps using the ORM — you change only the proven hot path, and you say why this one is special.
Hint: push the aggregation into the database. The ORM still issues it (aggregate/Sum), or drop to a parameterized raw query for the hottest path. Either way the work happens in the engine, not in a Python loop over hydrated objects.
Solution
Option A — stay in the ORM but stop hydrating; let the database aggregate:from django.db.models import Sum, F
def total_spend(customer_id: int) -> Decimal:
result = OrderLine.objects.filter(
order__customer_id=customer_id
).aggregate(
total=Sum(F("unit_price") * F("quantity"))
)
return result["total"] or Decimal("0")
def total_spend(customer_id: int) -> Decimal:
with connection.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(SUM(l.unit_price * l.quantity), 0)
FROM order_lines l
JOIN orders o ON o.id = l.order_id
WHERE o.customer_id = %s
""",
[customer_id], # parameterized — not string-formatted (no SQL injection)
)
(total,) = cur.fetchone()
return Decimal(total)
Exercise 6 — Segregate the bloated interface¶
Anti-pattern: Interface Bloat · Language: Java · Difficulty: ★★ medium
DataStore started small and accreted every method any consumer ever wanted. Now most implementers support only a slice of it, callers depend on far more than they use, and a change to the streaming methods recompiles the read-only cache. Split it into role interfaces.
public interface DataStore {
// basic CRUD
Record get(String id);
void put(String id, Record r);
void delete(String id);
// querying
List<Record> query(Query q);
long count(Query q);
// streaming / bulk
Stream<Record> scanAll();
void bulkLoad(Path file);
// admin
void compact();
Stats stats();
void backup(Path target);
}
Reality: the request handler only does get/put. The reporting job only does query/count. The ops tooling does compact/backup/stats. The read-through cache implements only get and throws UnsupportedOperationException for everything else.
Acceptance criteria - The fat interface is split into cohesive role interfaces that mirror how it is actually used. - Each consumer depends only on the role it needs. - An implementer can support one role without being forced to stub the rest.
Hint: group methods by the caller's role, not by your mental model of "a data store." Compose the roles for the implementer that genuinely does everything.
Solution
// Role interfaces — each is what some specific caller actually needs.
public interface KeyValueStore {
Record get(String id);
void put(String id, Record r);
void delete(String id);
}
public interface QueryableStore {
List<Record> query(Query q);
long count(Query q);
}
public interface BulkStore {
Stream<Record> scanAll();
void bulkLoad(Path file);
}
public interface MaintainableStore {
void compact();
Stats stats();
void backup(Path target);
}
// The one implementer that genuinely does everything composes the roles:
public class PostgresStore
implements KeyValueStore, QueryableStore, BulkStore, MaintainableStore {
// ... full implementation ...
}
// Each consumer now declares only the role it uses:
class RequestHandler {
private final KeyValueStore store; // get/put only
RequestHandler(KeyValueStore store) { this.store = store; }
}
class ReportJob {
private final QueryableStore store; // query/count only
ReportJob(QueryableStore store) { this.store = store; }
}
// The read-through cache honestly implements just the role it supports:
class ReadThroughCache implements KeyValueStore {
public Record get(String id) { /* real */ return null; }
public void put(String id, Record r) { /* real */ }
public void delete(String id) { /* real */ }
// no query/scan/compact methods to stub or throw from
}
Exercise 7 — Kill the UnsupportedOperationException implementer¶
Anti-pattern: Interface Bloat · Language: Java · Difficulty: ★★ medium
The smell of a bloated interface is an implementer that throws for half its methods. This ReadOnlyList claims to be a full List but rejects every mutator at runtime — the type system says "you can add," reality says "no you can't." Fix the lie.
public class ReadOnlyList<T> implements List<T> {
private final List<T> backing;
public ReadOnlyList(List<T> backing) { this.backing = backing; }
public T get(int i) { return backing.get(i); }
public int size() { return backing.size(); }
public Iterator<T> iterator() { return backing.iterator(); }
// ...and a dozen mutators that all do this:
public boolean add(T t) { throw new UnsupportedOperationException(); }
public T remove(int i) { throw new UnsupportedOperationException(); }
public void clear() { throw new UnsupportedOperationException(); }
public T set(int i, T t) { throw new UnsupportedOperationException(); }
// ... etc. for addAll, removeAll, retainAll, replaceAll, sort, ...
}
A caller holding a List<T> is told by the compiler that add is available, then crashes at runtime. The interface is too wide for what this implementer can honestly promise.
Acceptance criteria - A consumer that only reads cannot even call a mutator (the impossibility is enforced by the type, not by a runtime throw). - You do not hand-roll a dozen throwing stubs. - Where a truly immutable view is needed, prefer the standard tool over a custom class.
Hint: Java already has the narrow role you want. Expose the read role at the type level; use Collections.unmodifiableList or List.copyOf for a defensive view instead of a custom throwing class.
Solution
Define (or reuse) a narrow read-only role and expose *that*, so mutators are not part of the contract at all:// A role interface that promises only reading — no mutators exist to misuse.
public interface ReadOnlyList<T> {
T get(int i);
int size();
Iterator<T> iterator();
}
// Adapter over any List, exposing only the read role:
public final class ListView<T> implements ReadOnlyList<T> {
private final List<T> backing;
public ListView(List<T> backing) { this.backing = backing; }
public T get(int i) { return backing.get(i); }
public int size() { return backing.size(); }
public Iterator<T> iterator() { return backing.iterator(); }
}
List<T> view = Collections.unmodifiableList(backing); // throws on write, but at least it's the JDK's contract
List<T> snapshot = List.copyOf(backing); // truly immutable copy; clearest intent
Exercise 8 — Dismantle the in-house JSON rule engine¶
Anti-pattern: Inner-Platform Effect · Language: Python · Difficulty: ★★★ hard
To "let business users change discount rules without a deploy," someone built a rule engine that reads rules from JSON and interprets them. It has grown operators, nesting, and a tiny expression language. It is now a slower, buggier, untyped, untested reimplementation of Python's own if — the textbook Inner-Platform Effect. The rules have never actually changed at runtime; they are edited in a JSON file in the same repo and deployed like any other code.
# rules.json (lives in the repo, edited by engineers, deployed with the app)
RULES = [
{"if": {"field": "total", "op": ">", "value": 100},
"then": {"discount": 0.10}},
{"if": {"all": [
{"field": "is_member", "op": "==", "value": True},
{"field": "total", "op": ">", "value": 50}]},
"then": {"discount": 0.15}},
]
OPS = {
">": lambda a, b: a > b,
"<": lambda a, b: a < b,
"==": lambda a, b: a == b,
}
def evaluate(cond: dict, ctx: dict) -> bool:
if "all" in cond:
return all(evaluate(c, ctx) for c in cond["all"])
if "any" in cond:
return any(evaluate(c, ctx) for c in cond["any"])
# leaf
return OPS[cond["op"]](ctx[cond["field"]], cond["value"])
def discount_for(ctx: dict) -> float:
for rule in RULES:
if evaluate(rule["if"], ctx):
return rule["then"]["discount"]
return 0.0
This interpreter has no type checking (ctx["total"] could be a string), no IDE support, no unit tests on individual rules, opaque stack traces, and reimplements >, and, or badly. Worst of all, the "runtime configurability" it was built for is never used.
Acceptance criteria - The rules become plain, testable Python — using the language's own if, comparisons, and boolean operators. - Each rule is an independently unit-testable function. - You explicitly address: if genuine runtime extensibility were truly required, what is the right shape (not a homegrown DSL)?
Solution
The rules are code, edited by engineers, deployed with the app. So write them as code:from dataclasses import dataclass
@dataclass(frozen=True)
class Context:
total: float
is_member: bool
# Each rule is a named, typed, individually testable function.
# Order matters: first match wins, exactly like the original loop.
def _member_bonus(ctx: Context) -> float | None:
if ctx.is_member and ctx.total > 50:
return 0.15
return None
def _big_order(ctx: Context) -> float | None:
if ctx.total > 100:
return 0.10
return None
RULES = (_member_bonus, _big_order)
def discount_for(ctx: Context) -> float:
for rule in RULES:
result = rule(ctx)
if result is not None:
return result
return 0.0
Exercise 9 — Replace the in-DB config interpreter with a plugin seam¶
Anti-pattern: Inner-Platform Effect + Golden Hammer · Language: Go · Difficulty: ★★★ hard
A service stores tiny imperative "scripts" in a database column and interprets them at runtime to decide how to route messages. It is an inner platform (a homemade scripting language living in a TEXT column) and a Golden Hammer (the DB, the team's favorite tool, pressed into service as a code store). Convert the open-ended interpreter into a closed set of real Go handlers selected by configuration.
// routes table: pattern TEXT, script TEXT
// "orders.*" -> "set priority high; tag billing; forward queue=fast"
// "logs.*" -> "drop"
type Route struct {
Pattern string
Script string // a mini imperative language, parsed every message
}
// interp parses and executes the script for every single message — at message rate.
func interp(script string, msg *Message) {
for _, stmt := range strings.Split(script, ";") {
parts := strings.Fields(strings.TrimSpace(stmt))
switch parts[0] {
case "set":
msg.SetField(parts[1], strings.Join(parts[2:], " ")) // "set priority high"
case "tag":
msg.AddTag(parts[1])
case "forward":
kv := strings.SplitN(parts[1], "=", 2) // "forward queue=fast"
msg.Forward(kv[1])
case "drop":
msg.Drop()
}
}
}
The "language" has no validation (a typo like frward is silently ignored), is re-parsed on every message in a hot path, has no tests, and quietly does the wrong thing on malformed input. The behaviors it expresses are a small, closed set.
Acceptance criteria - The set of possible actions is closed and expressed as real, compiled, tested Go — not parsed text. - Routing config still selects which actions apply to which pattern, but config is data choosing among code, not code stored as data. - Parsing no longer happens per message in the hot path. - You note when a closed enum of actions is the right call versus when a real plugin system would be.
Solution
The actions are a small, closed set, so model them as typed config that selects compiled handlers — parsed *once* at load, not per message:// A closed set of real actions, validated at load time.
type Action struct {
Kind ActionKind
Field string // for SetField
Value string // for SetField / Forward / Tag
}
type ActionKind int
const (
ActSetField ActionKind = iota
ActTag
ActForward
ActDrop
)
type Route struct {
Pattern string
Actions []Action // parsed ONCE at config load, not per message
}
// apply is straight-line, compiled Go — no parsing in the hot path.
func (r Route) apply(msg *Message) {
for _, a := range r.Actions {
switch a.Kind {
case ActSetField:
msg.SetField(a.Field, a.Value)
case ActTag:
msg.AddTag(a.Value)
case ActForward:
msg.Forward(a.Value)
case ActDrop:
msg.Drop()
}
}
}
Exercise 10 — Wait for the rule of three¶
Anti-pattern: Premature Abstraction · Language: Python · Difficulty: ★★ medium
A developer sees two slightly-similar functions and immediately extracts a "flexible" generic exporter parameterized by callbacks. The abstraction is harder to read than the two functions it replaced, and it guessed the wrong axis of variation. This is the inverse skill of the others: recognizing that the right move is not yet to abstract — and knowing what to do when the third case finally arrives.
# The "DRY" extraction someone reached for after seeing TWO similar functions:
def export(items, *, header_fn, row_fn, footer_fn, joiner, encoder, post_process):
parts = [header_fn(items)]
for it in items:
parts.append(row_fn(it))
parts.append(footer_fn(items))
text = joiner.join(parts)
text = post_process(text)
return encoder(text)
# Calling it is now a puzzle:
csv_bytes = export(
rows,
header_fn=lambda _: "id,name",
row_fn=lambda r: f"{r.id},{r.name}",
footer_fn=lambda _: "",
joiner="\n",
encoder=lambda s: s.encode("utf-8"),
post_process=lambda s: s.strip(),
)
The two original functions were a simple to_csv and to_tsv that differed by one character (the delimiter). The generic export has six parameters to cover variation that does not exist yet.
Acceptance criteria - Replace the over-general abstraction with the two concrete functions it should have stayed as — or a single function parameterized only on the variation that actually differs. - Show what you would do when a genuine third format (with real structural differences, e.g. JSON) arrives. - Articulate the rule-of-three judgment.
Solution
The only real difference between the first two cases was the delimiter, so parameterize *that one thing* — nothing more:def to_delimited(items, sep: str) -> bytes:
lines = ["id" + sep + "name"]
lines += [f"{it.id}{sep}{it.name}" for it in items]
return "\n".join(lines).encode("utf-8")
def to_csv(items) -> bytes:
return to_delimited(items, ",")
def to_tsv(items) -> bytes:
return to_delimited(items, "\t")
from typing import Protocol
class Exporter(Protocol):
def export(self, items) -> bytes: ...
class DelimitedExporter:
def __init__(self, sep: str): self.sep = sep
def export(self, items) -> bytes:
return to_delimited(items, self.sep)
class JsonExporter:
def export(self, items) -> bytes:
import json
return json.dumps([{"id": it.id, "name": it.name} for it in items]).encode("utf-8")
Exercise 11 — Judgment: when to leave it alone¶
Anti-pattern: all four (judgment) · Difficulty: ★★★ hard · (judgment exercise — the right answer is sometimes "do nothing")
Over-correction is its own anti-pattern. An engineer who has just learned these four failures will start "fixing" abstractions that are not broken — splitting stable interfaces, replacing a sensibly standardized tool, re-introducing variety for its own sake. For each scenario below, decide whether to change it or leave it, and justify the call with the same judgment the cures rely on.
Scenario A — "Golden Hammer?" A backend team uses PostgreSQL for everything: relational data, a job queue (SELECT ... FOR UPDATE SKIP LOCKED), and a small key-value cache. A new hire proposes adding RabbitMQ for the queue and Redis for the cache "because using one tool for everything is a Golden Hammer." Current scale: 200 jobs/minute, cache hit on a few thousand keys. The team is four people.
Scenario B — "Interface Bloat?" A Repository interface has six methods: find, findAll, save, delete, existsById, count. Every implementer (there are three) supports all six honestly; every consumer uses three or four of them. Someone wants to split it into ReadRepository / WriteRepository / CountRepository "for Interface Segregation."
Scenario C — "Premature Abstraction?" There is a PaymentProvider interface with two real implementations (Stripe, Adyen) already in production, plus a tested FakeProvider used in the suite. A reviewer says "interfaces with few implementations are premature abstraction — inline it."
Task. For each scenario, give a clear change / leave decision and the evidence-based reasoning. Name the trap the over-correction would fall into.
Solution
**Scenario A — LEAVE IT (for now).** Standardizing on one well-understood tool is not automatically a Golden Hammer — it is a *legitimate* operational choice when the tool genuinely fits and the scale does not demand more. At 200 jobs/minute and a few thousand cache keys, Postgres handles the queue (`SKIP LOCKED` is a real, robust pattern) and the cache fine. Adding RabbitMQ and Redis would impose two more systems to deploy, monitor, back up, secure, and learn — on a four-person team — to solve a load problem that does not exist. **The over-correction's trap:** mistaking *consolidation* for the Golden Hammer. The Golden Hammer is using a tool *that does not fit*; using one tool that *does* fit, and that keeps operational surface small, is good engineering. *Revisit when evidence demands it* — when queue throughput or cache latency is measured to exceed what Postgres can serve, introduce the specialized tool against that real requirement. Decision driver: same as Exercise 5 — let the *measured* problem dictate the tool, in both directions. **Scenario B — LEAVE IT.** Interface Bloat is defined by implementers that *cannot honestly support* the interface (the `UnsupportedOperationException` tell from Exercise 7), or consumers coupled to methods that change for unrelated reasons. Here all three implementers support all six methods honestly, the methods are cohesive (they are the standard repository vocabulary), and no one is forced to throw. Splitting into three interfaces would triple the type count, complicate every implementer's declaration, and buy nothing — the consumers using "only four of six" are not harmed by the existence of two methods they ignore. **The over-correction's trap:** applying ISP mechanically by method count rather than by the principle's actual trigger ("clients forced to depend on methods they *cannot* use / that *change* for unrelated reasons"). A small, stable, cohesive interface that every implementer honors is not bloated. Six cohesive methods is fine; sixty incohesive ones throwing half the time is not. **Scenario C — LEAVE IT.** This is the [rule of three](../../../clean-code/15-pure-functions/) *satisfied*, not violated. There are **two real production implementations** plus a fake the test suite depends on — the abstraction is earning its keep right now: it lets two payment providers coexist and lets tests run without hitting a real gateway. The reviewer has over-rotated on "few implementations = premature," forgetting that *premature* means *before the second case exists*. Here the cases exist. **The over-correction's trap:** inlining a *justified, in-use* abstraction would force a choice between Stripe and Adyen at every call site and rip out the test seam — re-introducing exactly the coupling the interface correctly removes. The deciding question is unchanged from Exercise 3, and here the answer flips: *does this abstraction serve a need that exists today?* Yes — two providers and a fake. Keep it. **The unifying judgment.** Every cure in this file is gated by the *same* question: **does this abstraction (or this tool choice) earn its keep against a need that exists today?** Exercises 3, 4, and 10 answered "no — defer it." This exercise shows the identical question answering "yes — leave it." The skill is not "always split interfaces / always inline single-implementation types / always diversify tools" — that is just a new Golden Hammer pointed at your own codebase. The skill is reading the *evidence* (honest implementers? real second case? measured scale?) and acting on it in whichever direction it points.Exercise 12 — Mini-project: untangle the NotificationKit¶
Anti-pattern: all four, in one small realistic module · Language: Python · Difficulty: ★★★★ project
Below is a notification module that manages to combine every abstraction failure: a homemade template "engine" interpreting a custom syntax (Inner-Platform Effect), a regex doing a literal check and a dict where a tuple of channels would do (Golden Hammer), a fat Channel interface most channels can't honestly implement (Interface Bloat), and a ChannelFactory + strategy hierarchy guarding a single real channel (Premature Abstraction). Refactor it into clean, fitting units. Work in steps; do not try to fix it all at once.
import re
# --- Inner-Platform Effect: a homemade template language interpreted at runtime ---
def render(template: str, ctx: dict) -> str:
# supports {{name}} and {{#if premium}}...{{/if}} — a worse, buggier Jinja
out = template
for m in re.findall(r"\{\{(\w+)\}\}", template):
out = out.replace("{{%s}}" % m, str(ctx.get(m, "")))
if "{{#if premium}}" in out:
if ctx.get("premium"):
out = out.replace("{{#if premium}}", "").replace("{{/if}}", "")
else:
out = re.sub(r"\{\{#if premium\}\}.*?\{\{/if\}\}", "", out, flags=re.S)
return out
# --- Interface Bloat: every channel must implement all of this ---
class Channel:
def send(self, to, body): raise NotImplementedError
def send_bulk(self, recipients, body): raise NotImplementedError
def schedule(self, to, body, at): raise NotImplementedError
def supports_attachments(self): raise NotImplementedError
def max_length(self): raise NotImplementedError
class EmailChannel(Channel):
def send(self, to, body): ... # real
def send_bulk(self, r, b): raise NotImplementedError # not really supported
def schedule(self, t, b, at): raise NotImplementedError # not supported
def supports_attachments(self): return True
def max_length(self): raise NotImplementedError # n/a for email
# --- Premature Abstraction: a factory + strategy guarding ONE real channel ---
class ChannelFactory:
def create(self, kind: str) -> Channel:
if kind == "email":
return EmailChannel()
raise ValueError("unknown channel")
# --- Golden Hammer: regex for a literal check; dict for a fixed small set ---
def is_valid_recipient(addr: str) -> bool:
return bool(re.match(r".*@.*", addr)) # not even close to real validation
ENABLED = {"email": True} # a one-entry dict standing in for a constant
Acceptance criteria - Inner-Platform: the homemade template interpreter is replaced (use the standard library / a real template engine, or plain f-strings if the cases are few). - Golden Hammer: the literal regex check and the one-entry dict are replaced with the fitting tool. - Interface Bloat: the fat Channel interface is segregated so EmailChannel no longer throws NotImplementedError. - Premature Abstraction: the single-channel factory is collapsed; the abstraction returns only when a second channel does. - Each remaining unit is testable in isolation, and you note the cheap path back to each abstraction when a real need appears.
Solution
Work in four small steps — one anti-pattern at a time, keeping the module working after each. **Step 1 — replace the homemade template engine (Inner-Platform Effect).** The cases are few and known, so plain f-strings via small functions are clearest; if templates must be authored by non-engineers, use a *real* engine (Jinja2), never a homemade one.def welcome_body(name: str, premium: bool) -> str:
line = f"Hi {name}, welcome!"
if premium:
line += " Enjoy your premium benefits."
return line
# Substring/structural check, not a misleading regex. (Real email validation is
# a deep topic — at minimum, check structure honestly; ideally send a confirmation.)
def is_valid_recipient(addr: str) -> bool:
return "@" in addr and "." in addr.split("@")[-1]
# A one-entry dict was a constant in disguise:
EMAIL_ENABLED = True
from typing import Protocol
class Sender(Protocol):
def send(self, to: str, body: str) -> None: ...
class EmailChannel: # implements only what it can honor
def send(self, to: str, body: str) -> None:
... # real SMTP send
# A channel that genuinely supports bulk declares that role too:
class BulkSender(Protocol):
def send_bulk(self, recipients: list[str], body: str) -> None: ...
# EmailChannel does NOT implement BulkSender — and no longer lies by throwing.
Exercise 13 — Write an abstraction-failure review checklist¶
Anti-pattern: meta (prevention) · Difficulty: ★★ medium
Abstraction failures are cheapest to stop in review, before the wrong shape calcifies and grows callers. Write a concise, actionable reviewer checklist — questions a reviewer asks of a diff — that catches all four failures and guards against the over-correction from Exercise 11. Aim for questions with a clear "if yes, push back" trigger, not vague advice like "is this well-abstracted?"
Acceptance criteria - One or more concrete, answerable questions per anti-pattern. - Each question has a clear failure trigger and a suggested action. - At least one question that guards against over-correction (splitting/inlining when you shouldn't). - Short enough that a reviewer would actually run it on every PR.
Solution
**Abstraction-Failure PR review checklist** | # | Question | If the answer is… | Then | |---|---|---|---| | 1 | Does this PR add an interface/abstract class with only **one** implementation and no test seam? | "Yes" | Push back: inline it; abstract on the **second** real case. **Premature Abstraction**. | | 2 | Does this PR add a factory/strategy whose dispatch has exactly one real branch? | "Yes" | Push back: collapse to the concrete type; defer the factory. **Premature Abstraction**. | | 3 | Does any new implementer throw `UnsupportedOperationException` / `NotImplementedError` for interface methods? | "Yes" | Push back: split into role interfaces (ISP); the implementer should declare only what it honors. **Interface Bloat**. | | 4 | Is a new tool/pattern used because it's the author's default, where a simpler primitive fits (regex for a literal, map for dense ints, ORM in a measured hot path)? | "Yes" | Push back: use the fitting primitive; show the measurement if performance is the claim. **Golden Hammer**. | | 5 | Does this PR add a config/DB-stored mini-language, rule format, or interpreter? | "Yes" | Push back hard: is runtime extensibility *proven* needed? If not, write code. If yes, use a plugin seam or a real sandboxed engine, not a homemade DSL. **Inner-Platform Effect**. | | 6 | (Over-correction guard) Does this PR **split** an interface every implementer honors, **inline** an abstraction with ≥2 real implementations, or **add** a tool to replace one that fits at current scale? | "Yes" | Ask for the evidence: a dishonest implementer, a real second case, a *measured* scale problem. No evidence → leave it. | | 7 | Can each new/changed unit be tested in isolation with literals and a fake? | "No — needs the full engine/DB/network" | Ask whether the abstraction is fighting the problem. Hard-to-test shape is a smell across all four. | **Author's pre-flight (mirrored):** before requesting review, run questions 1–7 on your own diff. For every abstraction you add, write the *current* need it serves in the PR description — if you can only name a future one, defer it. **Why this is better than "review for good abstraction."** Each row has a *trigger* and an *action*, so two reviewers reach the same conclusion under time pressure. Row 6 is the load-bearing one: it stops the reviewer from becoming the new anti-pattern by demanding the *same* evidence (dishonest implementer, real second case, measured scale) that the cures themselves require — pointing the questions in both directions. The list catches wrong-shaped abstractions at their cheapest moment, a small diff, before they grow the callers and config that make them expensive to reshape later.Summary¶
- These exercises move you from recognizing abstraction failures to fixing them: swap a Golden Hammer for the primitive that fits (regex→substring, map→array, ORM→targeted query), dismantle an Inner-Platform interpreter back into plain tested code or a constrained plugin, segregate a bloated interface into honest role interfaces, and inline a premature abstraction until a real second case earns it back.
- The single deciding question runs through every fix: does this abstraction (or tool choice) serve a need that exists today? When the answer is "no, it's for the future," defer it (Exercises 3, 4, 10). When the answer is "yes, and the evidence proves it," keep it (Exercise 11).
- Over-correction is its own anti-pattern. Splitting stable interfaces, inlining in-use abstractions, or diversifying tools that fit is just a Golden Hammer pointed at your own codebase. Read the evidence — dishonest implementers, real second cases, measured scale — and act in whichever direction it points (Exercise 11).
- The rule of three is the through-line for abstraction. Extract the shape from concrete examples once the third reveals the true axis of variation; abstractions guessed from one or two cases almost always pick the wrong axis (Exercises 3, 4, 10).
- Inner-Platform Effect has a specific cure: when real runtime extensibility is proven necessary, reach for a constrained plugin seam (registered real functions) or a battle-tested sandboxed engine — never grow your own DSL one operator at a time (Exercises 8, 9).
- Prevention scales better than cure. The review checklist (Exercise 13) moves the fight upstream, catching the wrong shape while the diff is still small — and guarding against the reviewer's own urge to over-abstract.
Related Topics¶
junior.md— the four shapes and how to recognize them on sight.middle.md— the forces that create these shapes and the countermoves used here.find-bug.md— spot-the-anti-pattern snippets (critical reading practice).optimize.md— more flawed designs to redesign.interview.md— Q&A across all levels for job prep.- Clean Code → Classes — the Interface Segregation Principle, the cure for Interface Bloat.
- Clean Code → Pure Functions — DRY done right; deduplicate what you have, not what you imagine.
- Clean Code → Immutability — read-only views and copies over throwing wrappers.
- Design Patterns → Creational — Factory and Strategy done right, after the second case exists.
- Refactoring → Refactoring Techniques — Inline Class, Collapse Hierarchy, Replace Conditional with Polymorphism.
- OO Misuse — the sibling category; Constant Interface and Functional Decomposition rhyme with these failures.
In this topic