Code smellDispensables
Speculative Generality
Abstractions, hooks and parameters built for future needs that never arrived make code harder to read without serving any current use.
- Also known as
- YAGNI violation, Over-engineering
Signs
- An interface has exactly one implementation, and no test double uses it either.
- An abstract base class has one subclass.
- A function takes options or parameters that every caller passes with the same value, or never passes.
- A plugin system, registry or strategy hook exists with one plugin.
- Names are generic (
DataProcessor,handleEntity,GenericRepository<T>) for code that handles one concrete thing. - The only callers of some methods are their own tests.
Why it happens
Developers anticipate change and build the flexibility up front, often after being burned by a rigid design. The anticipated change rarely arrives in the shape that was predicted, so the abstraction ends up both unused and wrong. AI agents are prone to this smell too, because generating extra layers costs them nothing.
Example
A notification module was built for "any channel" but only ever sends email.
interface Channel<TPayload> {
send(payload: TPayload, options?: { priority?: number; retry?: boolean }): Promise<void>;
}
abstract class BaseChannel<T> implements Channel<T> {
abstract send(payload: T, options?: { priority?: number; retry?: boolean }): Promise<void>;
}
class EmailChannel extends BaseChannel<{ to: string; body: string }> {
async send(p: { to: string; body: string }) {
await smtp.send(p.to, p.body);
}
}
class ChannelRegistry {
private channels = new Map<string, Channel<any>>();
register(name: string, c: Channel<any>) { this.channels.set(name, c); }
get(name: string) { return this.channels.get(name)!; }
}
const registry = new ChannelRegistry();
registry.register("email", new EmailChannel());
await registry.get("email").send({ to: "a@example.com", body: "Your order shipped" });
declare const smtp: { send(to: string, body: string): Promise<void> };The priority and retry options are never read. Collapse to what is used:
export async function sendEmail(to: string, body: string): Promise<void> {
await smtp.send(to, body);
}
await sendEmail("a@example.com", "Your order shipped");
declare const smtp: { send(to: string, body: string): Promise<void> };When SMS arrives, you will know its real shape and can introduce an abstraction that fits both.
Treatment
- If an abstract class has one subclass, use Collapse Hierarchy.
- If a class only delegates or wraps without adding value, use Inline Class.
- If a method only forwards to another, use Inline Method.
- If a parameter is unused or always the same value, use Remove Parameter.
- If a method has an abstract, future-proof name, use Rename Method so it says what it does today.
- Delete tests that only exist to exercise the unused generality.
Payoff
- Code says what the system does, not what it might do.
- Fewer types and indirections to follow.
- When the real second case arrives, you design the abstraction from two concrete examples instead of guessing.
When to ignore
- You are building a framework or library whose users supply the other implementations.
- The interface is a test seam: tests substitute a fake, and that fake counts as a second implementation.
- The second implementation is committed work in the current iteration, not a "someday".
- The abstraction isolates a third-party dependency you are contractually or operationally likely to swap.
Agent checklist
- For each abstraction you removed, you confirmed there is no second implementation in tests, other packages or plugins.
- Parameters you removed were unused or constant at every call site.
- You did not delete an extension point that is part of a public API.
- The simplified code passes the same tests, minus tests that only covered the removed generality.
- You did not add new "for later" flexibility while simplifying.
Treatments
- Collapse HierarchyMerge a subclass into its superclass (or the reverse) when the two have become nearly identical and the split no longer earns its cost.
- Inline ClassFold a class that does too little into the class that uses it, moving its fields and methods across and deleting it.
- Inline MethodReplace calls to a function whose body is as clear as its name with the body itself, then delete the function.
- Remove ParameterDelete a parameter the method body no longer uses, updating every caller and override so the signature states only real dependencies.
- Rename MethodChange a method's name to say what it does, using a forwarding alias when callers cannot all be updated at once.
See also
- Lazy ClassA class does too little to justify the file, name and indirection it costs; fold it into its caller or delete it.
- Dead CodeVariables, parameters, branches, methods or classes that nothing uses still sit in the codebase, costing reading time and misleading readers.
- Middle ManA class mostly forwards calls to another object and adds nothing. Let clients talk to the real object or fold the wrapper away.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.