DEV Community

Cover image for You're Not Building Netflix: Stop Coding Like You Are
Adam - The Developer
Adam - The Developer

Posted on

You're Not Building Netflix: Stop Coding Like You Are

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

// 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);
Enter fullscreen mode Exit fullscreen mode

What this actually does:

`${user.firstName} ${user.lastName}`;
Enter fullscreen mode Exit fullscreen mode

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");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 } });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 } });
  }
}
Enter fullscreen mode Exit fullscreen mode

"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!
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

What this does:

await this.userRepository.update(userId, { darkMode: true });
Enter fullscreen mode Exit fullscreen mode

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):

  1. Write it
  2. Write it again
  3. See a pattern? NOW abstract it

What actually happens:

  1. Write it once
  2. "I MIGHT need this again, let me abstract!"
  3. Create a framework
  4. Second use case is completely different
  5. Fight the abstraction for 6 months
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

3. Testing Seams

// Makes mocking way easier
export interface TimeProvider {
  now(): Date;
}

// Test with frozen time, run in prod with real time
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
// ...
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 (0)