You know what's hilarious? Fresh bootcamp grads write code that's too simple. Six months later, after discovering design patterns, they write code that requires a PhD to understand. The journey of a developer is basically: "Wait, I can use classes?" → "EVERYTHING MUST BE A FACTORY STRATEGY OBSERVER SINGLETON."
Let me tell you about the time I inherited a codebase where someone had "architected" the display of a user's full name.
Table of Contents
- The War Crime
- Red Flag #1: The "Future-Proofing" Fallacy
- Red Flag #2: The Interface with One Implementation
- Red Flag #3: The Generic Solution Nobody Asked For
- Red Flag #4: Abstracting Stable Code, Coupling Volatile Code
- Red Flag #5: The "Enterprise" Mindset
- Red Flag #6: The Premature Abstraction
- When Abstraction Actually Makes Sense
- The Checklist: Should You Abstract This?
- The Recovery: Deleting Bad Abstractions
- The Truth About "Scalable" Code
- The Philosophy
- Conclusion
The War Crime
// user-name-display-strategy.interface.ts
export interface IUserNameDisplayStrategy {
formatName(context: UserNameContext): string;
supports(type: DisplayType): boolean;
}
// user-name-context.interface.ts
export interface UserNameContext {
firstName: string;
lastName: string;
locale: string;
preferences: UserDisplayPreferences;
culturalNamingConvention: CulturalNamingConvention;
titlePrefix?: string;
suffixes?: string[];
}
// user-name-display-strategy.factory.ts
@Injectable()
export class UserNameDisplayStrategyFactory {
constructor(
@Inject("DISPLAY_STRATEGIES")
private readonly strategies: IUserNameDisplayStrategy[]
) {}
create(type: DisplayType): IUserNameDisplayStrategy {
const strategy = this.strategies.find((s) => s.supports(type));
if (!strategy) {
throw new UnsupportedDisplayTypeException(type);
}
return strategy;
}
}
// standard-user-name-display.strategy.ts
@Injectable()
export class StandardUserNameDisplayStrategy
implements IUserNameDisplayStrategy
{
supports(type: DisplayType): boolean {
return type === DisplayType.STANDARD;
}
formatName(context: UserNameContext): string {
return `${context.firstName} ${context.lastName}`;
}
}
// The module that ties this beautiful architecture together
@Module({
providers: [
UserNameDisplayStrategyFactory,
StandardUserNameDisplayStrategy,
FormalUserNameDisplayStrategy,
InformalUserNameDisplayStrategy,
{
provide: "DISPLAY_STRATEGIES",
useFactory: (...strategies) => strategies,
inject: [
StandardUserNameDisplayStrategy,
FormalUserNameDisplayStrategy,
InformalUserNameDisplayStrategy,
],
},
],
exports: [UserNameDisplayStrategyFactory],
})
export class UserNameDisplayModule {}
// Usage (deep breath):
const context: UserNameContext = {
firstName: user.firstName,
lastName: user.lastName,
locale: "en-US",
preferences: userPreferences,
culturalNamingConvention: CulturalNamingConvention.WESTERN,
};
const strategy = this.strategyFactory.create(DisplayType.STANDARD);
const displayName = strategy.formatName(context);
What this actually does:
`${user.firstName} ${user.lastName}`;
I'm not even joking. 200+ lines of "architecture" to concatenate two strings with a space. The developer who wrote this probably had "Design Patterns" by the Gang of Four tattooed on their lower back.
Red Flag #1: The "Future-Proofing" Fallacy
Let me tell you a secret: You can't predict the future, and you're terrible at it.
// "We might need multiple payment providers someday!"
export interface IPaymentGateway {
processPayment(request: PaymentRequest): Promise<PaymentResult>;
refund(transactionId: string): Promise<RefundResult>;
validateCard(card: CardDetails): Promise<boolean>;
}
export interface IPaymentGatewayFactory {
create(provider: PaymentProvider): IPaymentGateway;
}
@Injectable()
export class StripePaymentGateway implements IPaymentGateway {
// The only implementation for the past 3 years
// Will probably be the only one for the next 3 years
// But hey, we're "ready" for PayPal!
}
@Injectable()
export class PaymentGatewayFactory implements IPaymentGatewayFactory {
create(provider: PaymentProvider): IPaymentGateway {
switch (provider) {
case PaymentProvider.STRIPE:
return new StripePaymentGateway();
default:
throw new Error("Unsupported payment provider");
}
}
}
Three years later, when you finally add PayPal:
- Your requirements have completely changed
- Stripe's API has evolved
- The abstraction doesn't fit the new use case
- You refactor everything anyway
What you should have written:
@Injectable()
export class PaymentService {
constructor(private stripe: Stripe) {}
async charge(amount: number, token: string): Promise<string> {
const charge = await this.stripe.charges.create({
amount,
currency: "usd",
source: token,
});
return charge.id;
}
}
Done. When PayPal shows up (IF it shows up), you'll refactor with actual requirements. Not hypothetical ones you dreamed up at 2 AM.
Red Flag #2: The Interface with One Implementation
This is my favorite. It's like bringing an umbrella to the desert "just in case."
export interface IUserService {
findById(id: string): Promise<User>;
create(dto: CreateUserDto): Promise<User>;
update(id: string, dto: UpdateUserDto): Promise<User>;
}
@Injectable()
export class UserService implements IUserService {
// The one and only implementation
// Will be the one and only implementation until the heat death of the universe
async findById(id: string): Promise<User> {
return this.userRepository.findOne({ where: { id } });
}
}
Congratulations, you've achieved:
- ✅ Made your IDE jump to definition take two clicks instead of one
- ✅ Added the suffix "Impl" to your class name like it's 2005
- ✅ Created confusion: "Wait, why is there an interface?"
- ✅ Made future refactoring harder (now you have two things to update)
- ✅ Zero actual benefits
Just write the damn service:
@Injectable()
export class UserService {
constructor(private userRepository: UserRepository) {}
async findById(id: string): Promise<User> {
return this.userRepository.findOne({ where: { id } });
}
}
"But what about testing?" Dude, TypeScript has jest.mock(). You don't need an interface to mock things.
When interfaces ARE useful:
// YES: Multiple implementations you're ACTUALLY using
export interface NotificationChannel {
send(notification: Notification): Promise<void>;
}
@Injectable()
export class EmailChannel implements NotificationChannel {
// Actually used in production
}
@Injectable()
export class SlackChannel implements NotificationChannel {
// Also actually used in production
}
@Injectable()
export class SmsChannel implements NotificationChannel {
// You guessed it - actually used!
}
The key word here? ACTUALLY. Not "might," not "could," not "future-proof." Actually. Right now. In production.
Red Flag #3: The Generic Solution Nobody Asked For
// "This will save SO much time!"
export abstract class BaseService<T, ID = string> {
constructor(protected repository: Repository<T>) {}
async findById(id: ID): Promise<T> {
const entity = await this.repository.findOne({ where: { id } });
if (!entity) {
throw new NotFoundException(`${this.getEntityName()} not found`);
}
return entity;
}
async findAll(query?: QueryParams): Promise<T[]> {
return this.repository.find(this.buildQuery(query));
}
async create(dto: DeepPartial<T>): Promise<T> {
this.validate(dto);
return this.repository.save(dto);
}
async update(id: ID, dto: DeepPartial<T>): Promise<T> {
const entity = await this.findById(id);
this.validate(dto);
return this.repository.save({ ...entity, ...dto });
}
async delete(id: ID): Promise<void> {
await this.repository.delete(id);
}
protected abstract getEntityName(): string;
protected abstract validate(dto: DeepPartial<T>): void;
protected buildQuery(query?: QueryParams): any {
// 50 lines of "reusable" query building logic
}
}
@Injectable()
export class UserService extends BaseService<User> {
constructor(userRepository: UserRepository) {
super(userRepository);
}
protected getEntityName(): string {
return "User";
}
protected validate(dto: DeepPartial<User>): void {
// Wait, users need special validation
if (!dto.email?.includes("@")) {
throw new BadRequestException("Invalid email");
}
// And password hashing
// And email verification
// And... this doesn't fit the pattern anymore
}
// Now you need to override half the base methods
async create(dto: CreateUserDto): Promise<User> {
// Can't use super.create() because users are special
// So you rewrite it here
// Defeating the entire purpose of the base class
}
}
Plot twist: Every entity ends up being "special" and you override everything. The base class becomes a 500-line monument to wasted time.
What you should have done:
@Injectable()
export class UserService {
constructor(
private userRepository: UserRepository,
private passwordService: PasswordService
) {}
async create(dto: CreateUserDto): Promise<User> {
if (await this.emailExists(dto.email)) {
throw new ConflictException("Email already exists");
}
const hashedPassword = await this.passwordService.hash(dto.password);
return this.userRepository.save({
...dto,
password: hashedPassword,
});
}
// Just the methods users actually need
}
Boring? Yes. Readable? Also yes. Maintainable? Extremely yes.
Red Flag #4: Abstracting Stable Code, Coupling Volatile Code
This is my personal favorite mistake because it's so backwards.
// Developer: "Let me abstract this calculation!"
export interface IDiscountCalculator {
calculate(context: DiscountContext): number;
}
@Injectable()
export class PercentageDiscountCalculator implements IDiscountCalculator {
calculate(context: DiscountContext): number {
return context.price * (context.percentage / 100);
}
}
@Injectable()
export class FixedDiscountCalculator implements IDiscountCalculator {
calculate(context: DiscountContext): number {
return context.price - context.fixedAmount;
}
}
// Factory, strategy pattern, the whole nine yards
// For... basic math that hasn't changed since ancient Babylon
Meanwhile, in the same codebase:
@Injectable()
export class OrderService {
async processPayment(order: Order): Promise<void> {
// Hardcoded Stripe API call
const charge = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.STRIPE_KEY}`,
},
body: JSON.stringify({
amount: order.total,
currency: "usd",
source: order.paymentToken,
}),
});
// Parsing Stripe's specific response format
const result = await charge.json();
order.stripeChargeId = result.id;
}
}
Let me get this straight:
- Basic arithmetic (never changes): Heavy abstraction ✅
- External API calls (change constantly): Tightly coupled ✅
- Career choices: Questionable ✅
Do the opposite:
// Math is math, keep it simple
export class DiscountCalculator {
calculatePercentage(price: number, percent: number): number {
return price * (percent / 100);
}
calculateFixed(price: number, amount: number): number {
return Math.max(0, price - amount);
}
}
// External dependencies need abstraction
export interface PaymentProcessor {
charge(amount: number, token: string): Promise<PaymentResult>;
}
@Injectable()
export class StripeProcessor implements PaymentProcessor {
async charge(amount: number, token: string): Promise<PaymentResult> {
// Stripe-specific stuff isolated here
}
}
The principle: Abstract what changes. Don't abstract what's stable.
Red Flag #5: The "Enterprise" Mindset
I once saw code that required eleven files to save a user's preferences. Not complex preferences. Just dark mode on/off.
// preference-persistence-strategy.interface.ts
export interface IPreferencePersistenceStrategy {
persist(context: PreferencePersistenceContext): Promise<void>;
}
// preference-persistence-context-builder.interface.ts
export interface IPreferencePersistenceContextBuilder {
build(params: PreferencePersistenceParameters): PreferencePersistenceContext;
}
// preference-persistence-orchestrator.service.ts
@Injectable()
export class PreferencePersistenceOrchestrator {
constructor(
private contextBuilder: IPreferencePersistenceContextBuilder,
private strategyFactory: IPreferencePersistenceStrategyFactory,
private validator: IPreferencePersistenceValidator
) {}
async orchestrate(params: PreferencePersistenceParameters): Promise<void> {
const context = await this.contextBuilder.build(params);
const validationResult = await this.validator.validate(context);
if (!validationResult.isValid) {
throw new ValidationException(validationResult.errors);
}
const strategy = this.strategyFactory.create(context.persistenceType);
await strategy.persist(context);
}
}
What this does:
await this.userRepository.update(userId, { darkMode: true });
I'm convinced the person who wrote this was being paid by the line.
The disease: Reading too many "enterprise architecture" books and thinking more files = better code.
The cure: Ask yourself, "Am I solving a real problem or am I playing Software Engineer LARP?"
Red Flag #6: The Premature Abstraction
The Rule of Three (which everyone ignores):
- Write it
- Write it again
- See a pattern? NOW abstract it
What actually happens:
- Write it once
- "I MIGHT need this again, let me abstract!"
- Create a framework
- Second use case is completely different
- Fight the abstraction for 6 months
- Rewrite everything
// First API endpoint
@Controller("users")
export class UserController {
@Get(":id")
async getUser(@Param("id") id: string) {
return this.userService.findById(id);
}
}
// Developer brain: "I should make a base controller for all resources!"
@Controller()
export abstract class BaseResourceController<T, CreateDto, UpdateDto> {
constructor(protected service: BaseService<T>) {}
@Get(":id")
async get(@Param("id") id: string): Promise<T> {
return this.service.findById(id);
}
@Post()
async create(@Body() dto: CreateDto): Promise<T> {
return this.service.create(dto);
}
@Put(":id")
async update(@Param("id") id: string, @Body() dto: UpdateDto): Promise<T> {
return this.service.update(id, dto);
}
@Delete(":id")
async delete(@Param("id") id: string): Promise<void> {
return this.service.delete(id);
}
}
// Now every controller that doesn't fit this pattern is a special case
// Users need password reset endpoint
// Products need image upload
// Orders need status transitions
// Everything is fighting the abstraction
The smart move:
// Write the first one
@Controller("users")
export class UserController {
// Full implementation
}
// Write the second one
@Controller("products")
export class ProductController {
// Copy-paste, modify as needed
}
// On the third one, IF there's a clear pattern:
// Extract only the truly common parts
Wisdom: Duplication is cheaper than the wrong abstraction. You can always DRY up later. Premature abstraction is like premature optimization—it's the root of all evil, but less fun to joke about.
When Abstraction Actually Makes Sense
Look, I'm not anti-abstraction. I'm anti-stupid-abstraction. Here's when it's actually smart:
1. External APIs That WILL Change
// You're literally switching from Stripe to PayPal next quarter
export interface PaymentProvider {
charge(amount: number): Promise<string>;
}
// This abstraction will save your ass
2. Multiple ACTUAL Implementations
// You have all of these in production RIGHT NOW
export interface StorageProvider {
upload(file: Buffer): Promise<string>;
}
@Injectable()
export class S3Storage implements StorageProvider {
// Used for production files
}
@Injectable()
export class LocalStorage implements StorageProvider {
// Used in development
}
@Injectable()
export class CloudinaryStorage implements StorageProvider {
// Used for images
}
3. Testing Seams
// Makes mocking way easier
export interface TimeProvider {
now(): Date;
}
// Test with frozen time, run in prod with real time
4. Plugin Systems
// Designed for third-party extensions
export interface WebhookHandler {
handle(payload: unknown): Promise<void>;
supports(event: string): boolean;
}
// Developers can add Slack, Discord, custom handlers
The Checklist: Should You Abstract This?
Before creating an abstraction, ask yourself:
🚨 STOP if you answer "no" to these:
- Do I have 2+ ACTUAL use cases right now?
- Does this isolate something that changes frequently?
- Would a new developer understand why this exists?
- Is this solving a real problem I have TODAY?
🛑 DEFINITELY STOP if these are true:
- "We might need this someday"
- "It's more professional"
- "I read about this pattern"
- "It's more scalable"
- "Enterprise applications do it this way"
✅ GREEN LIGHT if:
- Multiple implementations exist RIGHT NOW
- External dependency that's actually changing
- Makes testing significantly easier
- Eliminates significant duplication
The Recovery: Deleting Bad Abstractions
The bravest thing you can do is delete code. Especially "architecture."
Before:
// 6 files, 300 lines
export interface IUserValidator {}
export class UserValidationStrategy {}
export class UserValidationFactory {}
export class UserValidationOrchestrator {}
// ...
After:
// 1 file, 20 lines
@Injectable()
export class UserService {
async create(dto: CreateUserDto): Promise<User> {
if (!dto.email.includes("@")) {
throw new BadRequestException("Invalid email");
}
return this.userRepository.save(dto);
}
}
Your team: "This is so much better!"
Your ego: "But... my architecture..."
Your future self: "Thank god I deleted that."
The Truth About "Scalable" Code
Here's a secret: Simple code scales better than "scalable" code.
Netflix doesn't use your BaseAbstractFactoryStrategyManagerProvider pattern. They use boring, straightforward code that solves actual problems.
The most "scalable" code I've ever seen:
- Was easy to read
- Had clear responsibilities
- Used abstractions sparingly
- Could be understood by new developers in minutes
The least scalable code:
- Required a PhD to understand
- Had 47 levels of indirection
- "Enterprise patterns" everywhere
- Made simple changes take weeks
The Philosophy
Novices: Copy-paste everything
Intermediates: Abstract everything
Experts: Know when to do neither
The goal isn't clean code or scalable architecture. The goal is solving problems with the minimum viable complexity.
Your job isn't to impress other developers with your knowledge of design patterns. It's to write code that:
- Works
- Is easy to understand
- Can be changed easily
- Doesn't make people want to quit
Conclusion
The next time you're about to create an interface with one implementation, or build a factory for two use cases, or create a base class "just in case," I want you to stop and ask:
"Am I solving a problem or creating one?"
Most abstractions are created because:
- We read about them in a book
- They seem "more professional"
- We're bored and want a challenge
- We're afraid of looking unsophisticated
But here's the thing: The most sophisticated code is code that doesn't exist.
Write boring code. Copy-paste when it's simpler than abstracting. Wait for the third use case. Delete aggressive abstractions.
Your future self, your coworkers, and anyone who has to maintain your code will thank you.
Now go delete some interfaces.
P.S. If you're the person who wrote the user name display strategy factory, I'm sorry. But also, please get help.
Architecture is debt. Spend it wisely. Most systems don’t need a mortgage.”
Top comments (90)
I could build Netflix I just don't wanna
Ohh, we all know you've already built one, Ben 😆
It's probably hidden in a private Github repo somewhere.
Actually, I'm building Netflix 😆
But I agree with your posts
no wayyyyy, i'd never thought an actual engineer from Netflix would see this blog but this is the best validation of my post yet! 😆
Really solid post. I’ve learned this lesson the hard way: if I’m building something and I keep running into roadblocks or the design keeps getting more complicated than it should be, that’s usually my signal to step back. Nine times out of ten the problem isn’t the code — it’s that I’m over-engineering the entire thing.
Simplifying the approach almost always gets me unstuck.
I’m not building Netflix, I’m building something that needs to work, be maintainable, and survive reality. This post put that mindset into words perfectly.
Thanks and yeah, most of the “complexity” we run into is self-inflicted. When the design starts fighting you, it’s usually because you’re solving problems you don’t even have. Strip the extra layers and the whole thing suddenly makes sense again.
Interesting take, but it only reveals one big truth: Unless you know all this, you don't know you're overdoing things.
It is an unescapable situation: When you're a junior, you desire knowledge and practice. Acquiring said goals turn you into a senior capable of backtracking your steps and realizing this. In other words, this is inevitable.
Unless you have your own almighty senior dev carrying you all your life telling you when to and when not to, you'll need to:
If nobody holds your hand on this, you need to go through the process yourself.
So while your article is insightful, it won't stop people from doing all of it, and that's OK.
Couldn’t agree more. Every dev, including me, went through the over-engineering phase. this article’s more like a signpost along the road, not a shortcut. Thanks for the perspective!
Classic! Funny thing is, when I was a young dev, I thought something was wrong with me for not grasping the “genius” of ten layers of abstraction 😄
same haha, I used think something is wrong with my code if it doesn't involve me writing a class and something corporate-ish
You're being flippant and over-generalizing. And frankly, your article has the tone of just the type of person who would write code like this, or perhaps the tone of someone who only recently realized he doesn't always have to.
The truth is there is a time and place for designing for the future. That's what architecture is by definition. Only with experience, though, do you learn how to discern what is likely to change from what is truly unknown.
You're absolutely right that I recently learned this lesson – that's exactly the point. I spent years over-engineering and recently realized I didn't have to. This article is me processing that realization.
And yes, there's absolutely a time and place for designing for the future. The entire "When Abstraction Actually Makes Sense" section covers exactly that. The issue isn't abstraction itself but rather the premature abstraction based on hypothetical futures instead of real requirements.
The flippant tone is intentional. After maintaining codebases with 11-file preference systems, I think a little irreverence is warranted. If the style isn't for you, that's fair. But the underlying message: wait for actual requirements before abstracting is pretty standard advice (Rule of Three, YAGNI, KISS, ...etc).
Well, I disagree that it's strictly based on waiting for actual requirements. It's based on how you anticipate the requirements to change, which can come from direct past experience, industry best practices, logical intuition, and general professional foresight. A very simple example is the common architectural choice to separate the presentation and business layers--even before there is no official requirement to build for more than one client.
I feel somewhat similar and worry about the lack if long term planning from the "Today" or nothing approach, but it can be argued that requirements that are around the corner should be explainable. When a snowball hits me in the face I could have seen it coming.
Maybe "in the air already traveling to my face" is a better rule :)
A lot of apple pie here, sure not to draw any disagreement. The experienced engineer will find balance — forethought and experience go a long way towards a durable codebase that can grow instead of needing to be rewritten (all too common) and it takes very little extra effort.
I think there is an in-between. Don't waste time abstracting or implementing but write your code in a clean way where an interface can be thrown in, in the future keep public interfaces generic so you can replace them. You don't waste time or add any bloat and when the time comes you just throw up an interface and abstract later. Designing for the future is more about not shooting yourself in the foot and thinking about the long term then actually implementing it.
I thought this post will be about JAVA, the fu** TS does here??? It is not like Netflix prefer NestJs over Spring Boot
OMG, Just why you validate in service??? Never heard about Validation Pipes with Class Validator???
Abstract classes like
BaseServicemay not seem appealing at first, but in a large application they save a lot of time by centralizing common logic such asfindOneById. You avoid duplicating the same methods across multiple services, and if a specific service needs different behavior, you can simply override the base implementation.My post isn’t about Java vs TS but it’s more about over-engineering patterns that appear across all languages. I used Ts/Nest because that’s where I see it the most in my day-to-day, but the same issues show up in Spring ( a 2nd topic i've always liked to write about ) and everything else too.
As for validation: Pipes + class-validator are great, but they don’t replace domain-level validation. Request validation ≠ business validation.
Regarding base services: they help in monolithic patterns, but in many cases they introduce tighter coupling and reduce clarity. Whether they’re worth it depends heavily on the architecture and team.
Regarding base services: that is usually moved to common-lib package to be shared across microses
Amen brother! Thanks for writing this. This should be required reading for all junior Devs.
See it so often with juniors (yes, I did it) that they think they are proving their creds by using the most advanced language features/patterns they can think to apply to the problem.
No! Only use that sh1t if it really makes the solution simpler overall!
Always remember the next Dev is quite likely to be less smart/experienced than you - If you are a beginner on the project, chances are they will put even a MORE fresh beginner on the project in the future. Be kind to your future colleagues. Don't add unnecessary complexity.
Simplicity always delivers significant benefits in my experience.
But 'copy-paste' 10 times may not be simple - not if there is a better approach that encapsulates the similarities - and is simpler overall.
Thanks for this post again. I really loved it. Spot on.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.