When Not to Follow Engineering Principles or Patterns Strictly
SOLID, DRY, design patterns, clean architecture and many more are tools, not laws. Use them when they solve the problem, not for the sake of using them.
Contents
Open almost any codebase that has been around for a few years and you will find an interface with exactly one implementation. Next to it there is usually a factory that only ever builds that one implementation, and somewhere a comment or a commit message says it was done "so we can swap it later." Later never came.
Nobody wrote that code to be clever. It was written by someone doing what they were taught: program to an interface, depend on abstractions, keep classes small, don't repeat yourself, split the app into clean layers, reach for a pattern when one fits. Those are good instincts. They are still instincts, and an instinct applied without looking at the problem in front of you produces code that follows every rule and is still hard to work with.
This goes beyond OOP and SOLID. It applies to any principle, pattern, or architecture style that gets taught as the right way to build software. Each one is worth using when it saves time and solves a problem you actually have. When it is only there because the book said so, it tends to make things worse.
I want to write down when I think it is right to set the rules aside on purpose, and how that is different from being sloppy.
What the rules are for
SOLID, DRY, the classic design patterns, layered and clean architecture, and most of what gets taught as good practice are answers to one problem: code that has to change often while many people work on it. Dependency inversion lets you replace a database without rewriting the business logic. Open/closed lets you add a new payment method without editing the ones that already work. Single responsibility keeps one change from rippling through unrelated code. DRY means a bug fixed in one place is fixed everywhere.
Each of those is a bet that a particular kind of change is coming. When the bet pays off, the abstraction saves a lot of work. When it does not, you pay for the abstraction anyway, in extra files and extra indirection that the next reader has to follow.
So I treat the rules as defaults. They are where I start when I have no better information. Usually I do have better information, and the rules tend to backfire in four cases.
Case 1: Solving a problem you do not have
This is reaching for a pattern because it is there, not because the problem asks for it.
Say the product sends one kind of email, through one provider, from one place. A by-the-book version looks like this:
interface NotificationSender {
send(to: string, subject: string, body: string): Promise<void>;
}
class EmailSender implements NotificationSender {
async send(to: string, subject: string, body: string) {
await mailer.send({ to, subject, body });
}
}
class NotificationSenderFactory {
static create(): NotificationSender {
return new EmailSender();
}
}
await NotificationSenderFactory.create().send(user.email, "Welcome", body);Three names and two layers of indirection, all to call mailer.send. The version the problem actually needs:
export async function sendWelcomeEmail(user: User) {
await mailer.send({
to: user.email,
subject: "Welcome",
body: welcomeTemplate(user),
});
}If SMS shows up next quarter, adding an interface then takes a few minutes, and you get to design it around two real senders instead of one real sender and one imagined one. An abstraction written before the second case exists is usually shaped wrong, because you had to guess what the second case would look like.
A close cousin is forcing a pattern onto a problem it was never meant for, like a strategy pattern for something with exactly one strategy, or an event bus between two functions that always run one after the other in the same file. If you have to bend the problem to make the pattern fit, the pattern is the wrong tool, and a plainer solution is probably sitting right next to it.
The same thing happens at the scale of a whole system. A three-screen internal tool gets ports, adapters, use cases, and a domain layer because that is what clean architecture looks like. A team of two splits its app into six microservices and now spends its week on deploys and network calls instead of features. Those approaches solve real problems for large teams and large systems. A small app with a small team does not have those problems yet.
Case 2: Using the right idea the wrong way
Sometimes the principle does fit, but it gets applied in a way that leaves the code harder to follow than if nobody had tried.
Single responsibility gets this treatment a lot. It is often read as "each class should do one small thing," which leads to a sign-up flow split across UserValidator, UserNormalizer, UserRepository, WelcomeEmailNotifier, and a UserRegistrationService that calls the other four in order. Each file is tidy. To understand what happens when someone signs up, you have to open five of them and rebuild the sequence in your head.
That is not what the principle asks for. The usual definition is about reasons to change: code that changes for the same reason, usually because the same person or team asks for the change, belongs together. Validation and normalization for sign-up almost always change together, so they can live together. Splitting them made the code look more principled and made it harder to read.
Don't Repeat Yourself (DRY) gets the same treatment. Two functions that look alike get merged into one shared helper, even though they exist for different reasons. Then one caller needs a small change, so the helper grows a flag. Then another flag. A year later it takes four booleans, and nobody can change it without checking every caller. The two original functions were not duplication. They just looked similar for a while, and keeping them separate would have been the cheaper choice.
Inheritance fails in a similar way. A hierarchy that made sense for three subclasses starts to hurt at the fifth, when one subclass needs half of the parent's behavior and has to override the other half to switch it off.
A pattern you only half understand costs more than no pattern at all, because readers assume it is there for a reason and go looking for it.
Case 3: When the deadline is real
Some deadlines are soft and some are not. A launch date that has already been announced, or a production issue that costs money every hour it stays open, does not move because the code would be nicer with another layer. When the deadline is real, the time you would spend building the proper abstraction may simply not exist.
This is where people get nervous, because "we had a deadline" is also the excuse behind most of the bad code ever written. The difference I care about is between simple and sloppy.
Simple code under a deadline is direct and does one thing. A hardcoded branch for the one customer who needs a different invoice format is simple. You can read it, test it, and rip it out later.
Sloppy code is what you get when you skip the thinking along with the ceremony: copy-pasting a 200-line function and editing the copy, skipping the tests, swallowing an error so the demo works. None of that has anything to do with SOLID or patterns. Those are shortcuts on the work itself.
If I skip an abstraction for time, I want two things to be true. The code I write instead is small and easy to change, and the decision is written down somewhere the team will look, not only in my head. Then the missing abstraction is a known piece of debt with a clear place to pay it back, instead of a surprise for whoever touches the code next.
Technical debt is fine when you take it on deliberately and with a plan. The cost is the interest: every change near the shortcut takes a bit longer. I pay it back the next time I work in that code, not in a cleanup sprint that never gets scheduled. When the invoice branch gets a second customer, that is the time to build a proper format option. And if nobody ever touches that code again, the debt costs nothing.
Case 4: When every allocation counts
The last case is about the machine rather than the people. On embedded systems or in performance-critical code, the habits OOP encourages have a cost you rarely notice elsewhere. Each small object is an allocation and each interface call is an indirection, and on a device with very little memory, every extra layer is code that has to fit somewhere.
A simulation that updates thousands of particles per frame shows the tradeoff clearly. The object-oriented version is pleasant to write:
for (const particle of particles) {
particle.update(dt);
}The version that performs well tends to look less like OOP and more like plain arrays:
for (let i = 0; i < count; i++) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
}Here x, y, vx, and vy are Float32Arrays. There is no object per particle and no method call per update, and the numbers sit next to each other in memory, which is what the CPU cache wants.
I would not write the second version by default. Most code is not the hot path, and on a typical web backend an extra object is nowhere near the bottleneck. But once you have measured and the hot path is exactly here, the principles that make code easy to extend can work against code that has to be fast or small.
Bending a rule versus ignoring it
In all four cases above, setting the rule aside worked because the person knew what the rule was for.
When I skip an abstraction, I try to be able to answer three questions.
- What would the pattern give me here?
- Why do I not need that right now?
- What is the cost of adding it later?
If adding it later is cheap, which it usually is when the code is small and direct, then waiting is the better call. YAGNI, "you aren't gonna need it," is the name for this, and it is a principle too. It just gets forgotten because the others are louder. And like the others, it is a default, not a law: some things, like a security boundary or a public API, really are expensive to add later, and those are worth thinking through up front.
The rule of thumb I lean on: write it plainly the first time, notice the repetition the second time, and abstract on the third, when there are three real cases to design around instead of one real case and two guesses.
Ignoring a rule looks different. It means not knowing why the rule exists, or knowing and not caring. Code that ignores the rules tends to be hard to change. Code that bends them on purpose tends to be easy to change, which was the point of the rules in the first place.
What I optimize for instead
When the rules and the code in front of me disagree, I fall back on one question: how hard will this be for the next person to read and change? Usually the next person is me, a few months later, with no memory of why I did anything.
Sometimes the answer is an interface and a factory. More often it is a function with a good name, in a file that is easy to find, doing exactly what it says.
Principles and patterns are tools for problems you actually have. Reach for them when they save time and solve the problem, not for the sake of using them.
References
- 01YAGNI - Martin Fowler
- 02Technical Debt Quadrant - Martin Fowler