Abstractions added before there's a pattern to abstract create more work than they save. Three strikes before abstracting, concrete examples from a 2M-line PHP monolith, and how to tell when you're building a cache of pain instead of a clean interface.
The most expensive code in a long-lived codebase isn’t the code that’s wrong. It’s the abstraction that was right for the first two use cases and wrong for the third. By the time the third use case arrives, the abstraction is embedded in the codebase, the tests assume it, and changing it affects fifteen files.
The rule is simple: three concrete implementations before an abstraction. Not two. Three.
An abstraction is a cache. You’ve seen the same pattern three times, you encode it in a class or interface, and the next developer writes less code. Good abstractions are a cache of understanding. Bad abstractions are a cache of assumptions that happened to be true for the first two cases.
A real example from the SOMS codebase:
The team needed to send notifications. There were two channels: email and SMS. Someone built a NotificationInterface with a send(Notifiable $recipient, string $message) method. Abstract. Clean. Ready for the next channel.
The next channel was a WebSocket push. It didn’t have a send-and-forget semantic. It had connection state, delivery acknowledgments, and reconnection logic. The interface that worked for email and SMS was wrong for WebSocket. The team spent three days retrofitting the abstraction instead of one day building the channel.
The abstraction was added at two occurrences. The third broke it. If they’d waited for three, they would have seen the pattern — or realized there was no pattern and written three independent implementations that were each simple.
Interfaces are promises. NotificationInterface promises that every implementation has a send method that takes a Notifiable and a string. But not all notifications work that way. The promise is misleading, and misleading abstractions are worse than no abstraction because they train developers to read the interface and stop reading the implementation.
The developer who calls $notifier->send($user, "Your report is ready") assumes it works the same as the email implementation. When the Slack channel returns an error because the message exceeds 40,000 characters (Slack’s soft limit), the developer is surprised. The interface didn’t document the constraint. The Slack implementation didn’t document the exception. The abstraction lied by omission.
If there were no abstraction, the developer would write $slackNotifier->send($user, "Your report is ready") and think “Slack has limits, I should read the implementation.” The abstraction removes that friction — and that’s the problem.
The implementation pattern:
TODO: refactor when a third pattern emerges comment is correct.This rule prevents false abstraction. By the third occurrence, you know:
Changing an abstraction late costs more than not having it early, because abstractions accumulate dependencies:
The interface becomes an implicit contract that code, tests, config, and documentation all assume is stable. Changing it requires updating everything that touches it — even if the actual implementations are simple.
This is the sunk-cost fallacy of abstraction: “we already built the interface, we can’t rip it out now.” Yes you can. If the abstraction is wrong, deleting it and writing three straightforward implementations is cheaper than maintaining an incorrect abstraction for three more years.
Repository pattern applied too early: You have one UserRepository. You don’t need a UserRepositoryInterface until you have a second implementation. The interface plus the implementation is a 50% increase in files for zero benefit.
Strategy pattern before the variation exists: $processor->process($order, 'express') with one strategy class and a switch inside it is not a strategy pattern. It’s a function with a parameter. The strategy pattern is justified when you have genuinely different algorithms — not different configuration values.
Factory before the construction is complex: UserFactory::create($data) that calls new User($data) is an indirection tax, not a factory. A factory is justified when construction involves assembly of multiple objects, conditional logic, or dependency resolution. If it’s new User(...), just call new User(...).
The one place where early abstraction is justified is serialization. JSON encoding, database persistence, and API input parsing touch every part of the codebase. A consistent serialization layer pays for itself even with one consumer, because every change in how data enters or leaves the system is already centralized.
Normalizers, transformers, and DTO mappers are worth building at first use. Everything else: wait for three.
Related: This three-strikes rule is the same approach Calibre uses for knowledge base patterns — a pattern doesn’t enter the playbook until it’s been seen across at least three PRs. Premature abstraction in the playbook is just as costly as premature abstraction in code.