#DEV Community Skip to content (BUTTON) DEV Community ____________________ (BUTTON) Powered by Algolia Log in Create account DEV Community (BUTTON) (BUTTON) [heart-plus-active-9ea3b22f2bc311281db911d416166c5f430636e76b15cd5df6b3b841d830eefa.svg] Add reaction (BUTTON) [sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg] Like (BUTTON) [multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg] Unicorn (BUTTON) [exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg] Exploding Head (BUTTON) [raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg] Raised Hands (BUTTON) [fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg] Fire (BUTTON) Jump to Comments (BUTTON) Save (BUTTON) Boost (BUTTON) (BUTTON) Copy link Copied to Clipboard Share to X Share to LinkedIn Share to Facebook Share to Mastodon Share Post via... Report Abuse Cover image for You're Not Building Netflix: Stop Coding Like You Are Adam - The Developer �U9C� Adam - The Developer � Posted on Nov 23, 2025 (BUTTON) [sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg] (BUTTON) [multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg] (BUTTON) [exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg] (BUTTON) [raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg] (BUTTON) [fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg] You're Not Building Netflix: Stop Coding Like You Are #typescript #architecture #webdev #programming 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 + 1. External APIs That WILL Change + 2. Multiple ACTUAL Implementations + 3. Testing Seams + 4. Plugin Systems * 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): 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 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 crea…
arrow_backStory search
Story index / dev.to
You're Not Building Netflix: Stop Coding Like You Are - DEV Community
You know what's hilarious? Fresh bootcamp grads write code that's too simple. Six months later, after... Tagged with webdev, programming, architecture, typescript.
Netwrck