Stop Over-Engineering: You're Not Building Netflix

We've all seen it. A pull request that turns a simple string concatenation into 200 lines of Strategy/Factory pattern code. An interface with a single implementation "just in case." A BaseService<T> that every service extends but immediately overrides.
This article is inspired by Adam's viral piece "You're Not Building Netflix: Stop Coding Like You Are", which perfectly captures a problem that plagues teams everywhere: over-engineering.
What You'll Learn
✅ Why developers over-engineer and the psychological traps behind it
✅ Six red flags that signal unnecessary complexity
✅ When abstraction genuinely helps vs. when it hurts
✅ A practical decision framework for when to abstract
✅ How to write simpler code without sacrificing quality
The 200-Line Name Formatter
Let's start with the example that inspired Adam's article. Someone wrote this to format a user's full name:
// The "enterprise" approach
interface NameFormattingStrategy {
format(first: string, last: string): string;
}
class FirstLastStrategy implements NameFormattingStrategy {
format(first: string, last: string): string {
return `${first} ${last}`;
}
}
class LastFirstStrategy implements NameFormattingStrategy {
format(first: string, last: string): string {
return `${last}, ${first}`;
}
}
class NameFormatterFactory {
static create(type: 'firstLast' | 'lastFirst'): NameFormattingStrategy {
switch (type) {
case 'firstLast':
return new FirstLastStrategy();
case 'lastFirst':
return new LastFirstStrategy();
default:
throw new Error(`Unknown format: ${type}`);
}
}
}
// Usage
const formatter = NameFormatterFactory.create('firstLast');
const fullName = formatter.format('John', 'Doe');Now here's what that code actually needs to be:
// What it should be
const fullName = `${firstName} ${lastName}`;One line. No patterns. No factories. No strategies. Just the thing it's supposed to do.
This isn't a contrived example — variations of this exist in real production codebases everywhere.
Six Red Flags of Over-Engineering
Red Flag #1: "Future-Proofing" That Never Pays Off
"We should abstract the payment gateway now, so when we add PayPal someday..."
This is the most common justification for premature abstraction. The problem? You can't predict the future.
Three years later, when PayPal requirements finally arrive:
- Your requirements have completely changed
- The abstraction doesn't fit the new use case
- You've been maintaining unused complexity for years
- The developer who wrote it has left the company
// What you built "just in case"
interface PaymentGateway {
charge(amount: number, currency: string): Promise<PaymentResult>;
refund(transactionId: string): Promise<RefundResult>;
getStatus(transactionId: string): Promise<TransactionStatus>;
}
class StripeGateway implements PaymentGateway {
// Only implementation for 3 years...
}
// What you actually needed when PayPal arrived
// - Different authentication model
// - Webhook-based status instead of polling
// - Subscription handling (Stripe didn't need this originally)
// - Currency conversion requirements
// The abstraction doesn't fit. You rewrite it anyway.The YAGNI principle (You Aren't Gonna Need It) exists for exactly this reason. Build for today's requirements. Refactor when tomorrow's requirements actually arrive.
Red Flag #2: Interfaces with Single Implementations
// This adds zero value
interface UserRepository {
findById(id: string): Promise<User>;
save(user: User): Promise<void>;
}
class UserRepositoryImpl implements UserRepository {
findById(id: string): Promise<User> { /* ... */ }
save(user: User): Promise<void> { /* ... */ }
}What did you gain?
- Made your IDE "jump to definition" take two clicks instead of one
- Added a file that must stay in sync with the implementation
- Created the illusion of flexibility with none of the benefit
If you only have one implementation, you don't need an interface. When a second implementation actually shows up, that's when you extract the interface. It takes five minutes — not five months of carrying dead weight.
Red Flag #3: Generic "One-Size-Fits-All" Base Classes
abstract class BaseService<T> {
abstract findAll(): Promise<T[]>;
abstract findById(id: string): Promise<T>;
abstract create(data: Partial<T>): Promise<T>;
abstract update(id: string, data: Partial<T>): Promise<T>;
abstract delete(id: string): Promise<void>;
}
class UserService extends BaseService<User> {
// Override everything because users have special auth logic
async findAll() { /* completely different */ }
async create(data: Partial<User>) { /* needs email verification */ }
async delete(id: string) { /* soft delete, not hard delete */ }
}
class ProductService extends BaseService<Product> {
// Override everything because products have inventory logic
async create(data: Partial<Product>) { /* needs inventory check */ }
async update(id: string, data: Partial<Product>) { /* needs price history */ }
}When every subclass overrides most of the base class methods, the base class isn't providing value — it's providing false structure. You're spending more time working around the abstraction than benefiting from it.
Red Flag #4: Abstracting the Wrong Things
This is a subtle but critical mistake: developers often abstract stable code while tightly coupling volatile code.
// ❌ Wrong: Abstracting stable math (this never changes)
interface CalculationStrategy {
calculate(price: number, taxRate: number): number;
}
class StandardCalculation implements CalculationStrategy {
calculate(price: number, taxRate: number): number {
return price * (1 + taxRate); // This formula won't change
}
}
// ❌ Wrong: Tightly coupling volatile external API (this WILL change)
class OrderService {
async createOrder(items: CartItem[]) {
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { 'Authorization': `Bearer ${STRIPE_KEY}` },
body: JSON.stringify({ /* Stripe-specific payload */ })
});
// Stripe-specific response handling baked into business logic
}
}The tax calculation will never change — price * (1 + taxRate) is math. But the Stripe API? That changes with every version bump, and it's deeply embedded in your business logic.
Abstract what's likely to change. Leave stable code simple.
Red Flag #5: Solving Problems You Don't Have
// Building a plugin system for your team's internal tool
interface Plugin {
name: string;
version: string;
initialize(): Promise<void>;
execute(context: PluginContext): Promise<PluginResult>;
teardown(): Promise<void>;
}
class PluginRegistry {
private plugins: Map<string, Plugin> = new Map();
register(plugin: Plugin) { /* ... */ }
unregister(name: string) { /* ... */ }
executeAll(context: PluginContext) { /* ... */ }
}
// Total plugins ever written: 1
// Total plugins planned: "maybe someday"If there's only one plugin, there's no plugin system — there's just code with extra steps.
Red Flag #6: Using Patterns as Resume Decorators
Be honest: have you ever used a design pattern because it felt impressive rather than because the problem demanded it?
- Using Observer pattern for a single event between two components
- Implementing Command pattern for basic CRUD operations
- Building a State Machine for a boolean toggle
- Adding a Mediator for two services that talk directly to each other
Design patterns are solutions to recurring problems, not decorations for your codebase. If the problem doesn't recur, the pattern is overhead.
When Abstraction Actually Makes Sense
This isn't an anti-pattern manifesto. Abstraction is one of the most powerful tools in software engineering — when used at the right time.
1. External APIs That Will Change
// ✅ Good: Wrapping a third-party service you might swap
class EmailService {
private provider: SendGrid; // Could become SES, Mailgun, etc.
async send(to: string, subject: string, body: string) {
return this.provider.send({ to, subject, body });
}
}Third-party services change pricing, deprecate features, or go out of business. A thin wrapper creates a useful seam.
2. Multiple Implementations in Production Today
// ✅ Good: You actually have multiple notification channels RIGHT NOW
interface NotificationChannel {
send(user: User, message: string): Promise<void>;
}
class EmailNotification implements NotificationChannel { /* ... */ }
class SlackNotification implements NotificationChannel { /* ... */ }
class SMSNotification implements NotificationChannel { /* ... */ }The key word is today. Not "someday." Not "in Q3." Today, in production, with real traffic.
3. Testing Seams
// ✅ Good: Abstraction that makes testing possible
class OrderProcessor {
constructor(private clock: () => Date = () => new Date()) {}
isOrderExpired(order: Order): boolean {
const now = this.clock();
return order.createdAt < new Date(now.getTime() - 24 * 60 * 60 * 1000);
}
}
// In tests: inject a fixed clock
const processor = new OrderProcessor(() => new Date('2026-01-15'));If an abstraction makes your code testable where it wasn't before, that's genuine value.
4. Repeated Patterns Across 3+ Locations
The Rule of Three: don't abstract until you see the same pattern at least three times. At that point, you have real evidence of duplication — not a hypothesis.
The Decision Framework
Before adding an abstraction, ask yourself:
If you can't answer "yes" to at least one of these, you probably don't need the abstraction yet.
The Real Cost of Over-Engineering
Over-engineering isn't just an aesthetic problem. It has real consequences:
| Cost | Impact |
|---|---|
| Onboarding time | New developers spend days understanding unnecessary patterns |
| Maintenance burden | More files, more interfaces, more things to keep in sync |
| Debugging difficulty | Following a simple operation through 7 layers of abstraction |
| False confidence | "We have a clean architecture" while actual problems go unsolved |
| Refactoring resistance | The more abstraction exists, the harder it is to change direction |
Every line of code is a liability. Every abstraction layer is a tax on future developers. Make sure the value exceeds the cost.
What Simple Code Looks Like
Here's the mindset shift. Instead of asking "what pattern should I use?", ask "what's the simplest thing that works?"
// ❌ Over-engineered
class UserNameBuilder {
private parts: string[] = [];
addFirstName(name: string): UserNameBuilder {
this.parts.push(name);
return this;
}
addLastName(name: string): UserNameBuilder {
this.parts.push(name);
return this;
}
build(): string {
return this.parts.join(' ');
}
}
const name = new UserNameBuilder()
.addFirstName('John')
.addLastName('Doe')
.build();// ✅ Simple
const name = `${firstName} ${lastName}`;// ❌ Over-engineered
const config = ConfigurationFactory
.getInstance()
.getConfigurationProvider('database')
.getConfiguration();
// ✅ Simple
const config = loadConfig('database');Three lines of straightforward code is always better than a premature abstraction. You can read it, debug it, and change it without needing a class diagram.
A Practical Philosophy
Adam's article lands on a philosophy I deeply agree with:
Write code that solves today's problem. When tomorrow's problem arrives — if it arrives — refactor with real requirements, not hypothetical ones you dreamed up at 2 AM.
This doesn't mean "never abstract." It means:
- Start simple. You can always add complexity later.
- Refactor when the need is real. The second implementation is when you extract the interface.
- Delete unused abstractions. If nobody's using it, it's not "future-proof" — it's dead code.
- Value readability over cleverness. The next developer reading your code will thank you.
The best code isn't the most architecturally sophisticated. It's the code that solves the problem clearly, is easy to change, and doesn't make the next person's job harder.
Key Takeaways
✅ A Strategy/Factory pattern to concatenate strings is a real thing that exists in codebases
✅ "Future-proofing" almost never works — requirements change unpredictably
✅ Interfaces with single implementations add complexity without value
✅ Abstract what changes (external APIs), leave stable code simple (math, business rules)
✅ Use the Rule of Three: don't abstract until you see the pattern 3+ times
✅ Every abstraction is a tax — make sure the value exceeds the cost
You're not building Netflix. You're not building Google. And even if you were — they didn't start with microservices and 47 design patterns. They started simple, and scaled when they needed to.
Start simple. Stay simple. Add complexity only when reality demands it.
References
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.