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

AI Summary8 min read

TL;DR

Developers often over-engineer code with unnecessary abstractions like excessive design patterns, 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 abstractions only when there are multiple actual implementations, such as for external APIs or testing seams.
  • Prioritize simplicity and readability over generic solutions that often require overrides and add unnecessary layers.

Tags

programmingtypescriptwebdevarchitecture

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

Visit Website