You're Not Building Netflix: Stop Coding Like You Are
TL;DR
Developers often over-engineer code with unnecessary abstractions like design patterns and interfaces, leading to complexity without real benefits. Focus on writing simple, readable code that solves actual problems instead of hypothetical future needs.
Key Takeaways
- •Avoid over-engineering by not adding abstractions like interfaces or factories for single implementations or stable code.
- •Write code based on current requirements rather than speculative future-proofing to reduce complexity and maintenance overhead.
- •Use simple, direct implementations for common tasks, as premature abstractions can make code harder to understand and refactor.
Tags
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