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