Loading content...
Every design decision involves a tradeoff between two worthy goals: flexibility (the ability to change) and simplicity (the ability to understand). Build too rigidly, and your system can't adapt to evolving requirements. Build too flexibly, and your system becomes an incomprehensible abstraction nightmare.
This tension is the central challenge of software design. Experienced engineers navigate it daily, making judgment calls about when to abstract and when to keep things concrete, when to plan for change and when to embrace YAGNI ('You Aren't Gonna Need It').
This page gives you the frameworks and heuristics to make these judgments wisely—to build systems that bend without breaking, that accommodate change without drowning in indirection.
By the end of this page, you will understand the costs of both over-engineering and under-engineering, learn to identify which parts of a system need flexibility, master the art of 'just enough' abstraction, and develop heuristics for making flexibility vs. simplicity tradeoffs.
Software designs can fail in two opposite ways. Understanding both helps you find the middle path.
Failure Mode 1: The Rigid System (Under-Flexibility)
Signs of rigid systems:
Example: A payment system hardcoded for Stripe. When the business needs to support PayPal, there's no abstraction—Stripe calls are woven through the codebase. Adding PayPal means rewriting half the checkout flow.
Failure Mode 2: The Over-Engineered System (Over-Flexibility)
Signs of over-engineered systems:
Example: A payment system with IPaymentGatewayFactory, IPaymentGatewayFactoryProvider, AbstractPaymentProcessor, PaymentProcessorAdapter, and PaymentProcessorDecorator—all for a system that only uses Stripe and always will.
The cost calculations:
| Aspect | Under-Flexibility Cost | Over-Flexibility Cost |
|---|---|---|
| Initial Development | Low—no abstractions | High—building unused flexibility |
| Understanding | Medium—concrete but tangled | High—layers of indirection |
| First Change | Very High—no extension points | Low—system is prepared |
| 10th Change (same area) | Extreme—still no structure | Low—structure absorbs changes |
| Change to Never-Used Flexibility | N/A—no flexibility | High—complexity without benefit |
| Debugging | Medium—concrete code | High—calls across many layers |
| Onboarding New Developers | Medium—must understand all details | High—must understand all abstractions |
There's no safe default. Rigid systems fail when requirements change. Over-engineered systems fail by being incomprehensible. The goal is finding where your system sits on these tradeoffs—which requires understanding where change is likely.
The key insight: not all parts of a system need equal flexibility. Some areas change constantly; others are stable for years. Investing flexibility where it won't be used is waste. Skipping flexibility where it will be needed is technical debt.
Areas That Typically Need Flexibility:
The Volatility Heuristic:
Ask yourself: 'How often has this part of the system changed in the last year? How often will it change in the next year?'
| Change Frequency | Recommended Approach |
|---|---|
| Daily/Weekly | High flexibility, configuration-driven, strategy patterns |
| Monthly | Moderate flexibility, clean interfaces, easy extension points |
| Yearly | Low flexibility, direct implementation, simple is better |
| Never | No flexibility, hardcode it, document the assumption |
Don't guess at volatility—ask. Product managers know which features are experimental. Business analysts know which rules change frequently. Finance knows which integrations are on shaky contracts. Use their knowledge to guide where you invest in flexibility.
Several design principles help you achieve flexibility without excessive complexity.
Principle 1: Make the Change Easy, Then Make the Easy Change
(Attributed to Kent Beck)
When facing a change, don't just add to existing spaghetti. First, refactor to make the change straightforward. Then make the change itself. This invests in flexibility exactly when it's needed—not before.
Before: Adding PayPal to a Stripe-hardcoded system Wrong: Hack PayPal logic next to Stripe logic Right: Refactor to abstract payment provider first, then add PayPal cleanly
Principle 2: Rule of Three
Don't abstract until you have three concrete examples. One instance might be unique. Two might be coincidence. Three reveals a pattern.
Application:
PaymentGateway interfaceThis prevents premature abstraction while still eventually capturing reusable patterns.
Principle 3: Open-Closed Principle
'Software entities should be open for extension but closed for modification.'
Design so that new behavior can be added without changing existing code. This requires strategic abstraction points.
Example: A notification system closed for modification but open for extension:
1234567891011121314151617181920212223242526272829303132333435363738394041
// OPEN-CLOSED: Add new notification channels without modifying dispatcherinterface NotificationChannel { canHandle(preference: UserPreference): boolean; send(user: User, message: Notification): Promise<void>;} // Core dispatcher never changesclass NotificationDispatcher { constructor(private channels: NotificationChannel[]) {} async dispatch(user: User, message: Notification): Promise<void> { const preference = user.notificationPreference; const eligibleChannels = this.channels.filter(c => c.canHandle(preference)); await Promise.all( eligibleChannels.map(c => c.send(user, message)) ); }} // Existing channelsclass EmailChannel implements NotificationChannel { /* ... */ }class SmsChannel implements NotificationChannel { /* ... */ } // NEW CHANNEL: Just implement the interface, no modification to dispatcherclass PushNotificationChannel implements NotificationChannel { canHandle(preference: UserPreference): boolean { return preference.pushEnabled && preference.deviceTokens.length > 0; } async send(user: User, message: Notification): Promise<void> { // Push notification logic }} // Registration is configuration, not modificationconst dispatcher = new NotificationDispatcher([ new EmailChannel(), new SmsChannel(), new PushNotificationChannel(), // Added without changing dispatcher]);Principle 4: Separate What Changes from What Stays the Same
Identify the volatile parts of your design. Encapsulate them behind stable interfaces. The stable parts depend on abstractions; the volatile parts are implementations.
Example: Tax calculation rules change often. Tax calculation interface doesn't.
[Stable]
┌────────────────────────────────────┐
│ CheckoutService │
│ depends on: TaxCalculator │
└───────────────┬────────────────────┘
│ (uses interface)
▼
┌──────────────┐
│ TaxCalculator│ ← Interface (Stable)
└──────┬───────┘
│
┌───────┴───────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ SimpleTax │ │ TaxJarAdapter │ ← Implementations (Volatile)
└───────────────┘ └───────────────┘
These principles converge on one idea: flexible systems have a few well-chosen abstraction points at the boundaries where change is likely. Everything else can be concrete and simple. The art is choosing those points correctly.
YAGNI is the most important defense against over-engineering. It states:
Don't build functionality until you need it.
This seems obvious but is constantly violated. Developers imagine future requirements and build for them today. The problem: imagined requirements are usually wrong.
Why Predictions Fail:
| Reason | Example |
|---|---|
| Business pivots | 'Multi-tenant' feature never needed—business stays single-tenant |
| Market changes | Elaborate OAuth integration unused because social login trend shifts |
| Requirements evolve | Flexible architecture for V1 requirements, but V2 needs something different |
| Overestimation | 'International support' for a product that never leaves English-speaking markets |
The True Cost of Speculative Features:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// YAGNI VIOLATION: Building for imagined requirementsclass UserRepository { // "We might need different databases someday" constructor(private db: IDatabaseAdapter) {} // "We might need soft-delete someday" async delete(id: string): Promise<void> { await this.db.update('users', id, { deletedAt: new Date() }); } // "We might need filtering by multiple criteria someday" async find(criteria: Record<string, unknown>): Promise<User[]> { const query = this.buildDynamicQuery(criteria); return this.db.raw(query); } // "We might need pagination with cursor-based navigation someday" async paginate(cursor?: string, limit = 20, order: 'asc' | 'desc' = 'desc'): Promise<Page<User>> { // 50 lines of complex cursor pagination logic }} // YAGNI COMPLIANT: Build what's needed, make it easy to extend laterclass UserRepository { // Direct database connection - we only use PostgreSQL constructor(private db: PostgresDatabase) {} // Hard delete - we actually delete users (privacy law requirement) async delete(id: string): Promise<void> { await this.db.query('DELETE FROM users WHERE id = $1', [id]); } // Find by ID - the only query we currently use async findById(id: string): Promise<User | null> { const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]); return result.rows[0] ?? null; } // Simple offset pagination - current UI doesn't need cursor-based async list(page: number, limit = 20): Promise<User[]> { return this.db.query( 'SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2', [limit, page * limit] ); }}// When new requirements come, refactor then. The code is simple enough to change.YAGNI doesn't mean ignoring what you know is coming. If the roadmap shows multi-region support in Q3 and you're starting in Q1, consider it in your design. The distinction is between known/planned requirements and speculative 'what-if' scenarios.
Kent Beck's four rules of simple design provide a practical test for balance:
1. Passes the Tests Code must work. This is baseline, not optional.
2. Reveals Intention Code should clearly communicate its purpose. Good naming, clear structure, obvious flow.
3. No Duplication (DRY) Knowledge should exist in one place. Duplicate logic is a sign of missing abstraction.
4. Fewest Elements Among designs that satisfy 1-3, prefer the one with the fewest classes, methods, and lines.
The rules are in priority order. Correctness beats clarity. Clarity beats DRY. DRY beats minimalism. Never sacrifice a higher-priority rule for a lower one.
Applying Fewest Elements:
This rule is the antidote to over-abstraction. Ask:
Every element has a cost: mental overhead to understand, code to maintain, tests to write. More elements must be justified by clear benefits.
Over-Engineered:
interface ILoggerFactory {
create(name: string): ILogger;
}
interface ILogger {
log(level: LogLevel, message: string): void;
}
class LoggerFactory implements ILoggerFactory {
create(name: string): ILogger {
return new Logger(name);
}
}
class Logger implements ILogger {
constructor(private name: string) {}
log(level: LogLevel, message: string): void {
console.log(`[${this.name}] ${level}: ${message}`);
}
}
// Usage:
const factory = new LoggerFactory();
const logger = factory.create('OrderService');
logger.log(LogLevel.INFO, 'Processing');
Simple:
function log(name: string, level: string, message: string) {
console.log(`[${name}] ${level}: ${message}`);
}
// Usage:
log('OrderService', 'INFO', 'Processing');
Same functionality. Dramatically less complexity. When you need the flexibility of injectable loggers, add the abstraction then.
Making something simple is harder than making it complex. Complex solutions come naturally—you keep adding until it works. Simple solutions require thought, restraint, and often multiple iterations. Simple code is the result of deep understanding, not shallow effort.
When flexibility is genuinely needed, certain patterns provide it efficiently—adding extension points without excessive complexity.
Strategy Pattern: Variable Algorithms
Use when: Multiple interchangeable algorithms, selection at runtime.
Cost: One interface + implementations. Worth it for genuine variability.
1234567891011121314151617181920212223242526
// Strategy: Interchangeable pricing strategiesinterface PricingStrategy { calculatePrice(basePrice: number, customer: Customer): number;} class StandardPricing implements PricingStrategy { calculatePrice(basePrice: number, customer: Customer): number { return basePrice; }} class VolumeDiscountPricing implements PricingStrategy { calculatePrice(basePrice: number, customer: Customer): number { const discount = customer.lifetimeOrders > 100 ? 0.15 : customer.lifetimeOrders > 50 ? 0.10 : customer.lifetimeOrders > 10 ? 0.05 : 0; return basePrice * (1 - discount); }} // Easy to add new strategies without touching existing codeclass HolidaySalePricing implements PricingStrategy { calculatePrice(basePrice: number, customer: Customer): number { return basePrice * 0.8; // 20% off everything }}Dependency Injection: Replaceable Dependencies
Use when: Dependencies should be swappable (for testing, different environments, or evolution).
Cost: Constructor parameters instead of direct instantiation. Often worth it for external dependencies.
12345678910111213141516171819202122232425262728
// Dependency Injection: Email service is swappableclass OrderConfirmationService { constructor( private emailSender: EmailSender, // Interface, not concrete class private orderRepo: OrderRepository ) {} async sendConfirmation(orderId: string): Promise<void> { const order = await this.orderRepo.find(orderId); await this.emailSender.send({ to: order.customerEmail, subject: 'Order Confirmed', body: this.buildConfirmationBody(order) }); }} // Production: Real email senderconst prodService = new OrderConfirmationService( new SendGridEmailSender(config.sendgridKey), new PostgresOrderRepository(db)); // Test: Mock email senderconst testService = new OrderConfirmationService( new MockEmailSender(), // Records calls but doesn't send new InMemoryOrderRepository());Plugin Architecture: Open Extension
Use when: Third parties or future you need to extend functionality without modifying core.
Cost: Plugin interface + loading mechanism. Worth it for extensible products.
Configuration-Driven Behavior
Use when: Behavior changes without code deployment.
Cost: Configuration schema + parsing + validation. Worth it for frequently-changed business rules.
Don't apply patterns because they exist. Apply them because you have the problem they solve. A Strategy pattern for a single, never-changing algorithm is over-engineering. A Strategy pattern for genuinely varying behavior is elegant design.
The best strategy: start simple, add flexibility when needed. But how do you add flexibility to existing code?
Step 1: Identify the Variation Point
What specifically is changing? Not 'the payment system' but 'the payment provider integration.' Be precise about what varies.
Step 2: Extract the Varying Part
Move the code that varies into its own class or method. Keep the stable part in place.
Step 3: Define the Interface
Create an abstraction that captures what the stable code needs from the varying part. Only what's needed—no speculative methods.
Step 4: Inject the Implementation
Pass the implementation to the stable code. Now they're decoupled.
Example: Adding a Second Payment Provider
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// BEFORE: Stripe hardcodedclass CheckoutService { async processPayment(order: Order, card: CardDetails): Promise<Receipt> { const stripe = new Stripe(process.env.STRIPE_KEY); const charge = await stripe.charges.create({ amount: order.total * 100, currency: 'usd', source: card.token, description: `Order ${order.id}` }); return { transactionId: charge.id, amount: order.total }; }} // STEP 1: Identify variation - the Stripe-specific code// STEP 2: Extract to its own class class StripePaymentGateway { constructor(private apiKey: string) {} async charge(amount: number, currency: string, token: string, description: string): Promise<string> { const stripe = new Stripe(this.apiKey); const charge = await stripe.charges.create({ amount: amount * 100, currency, source: token, description }); return charge.id; }} // STEP 3: Define interface based on what CheckoutService actually needs interface PaymentGateway { charge(params: ChargeParams): Promise<ChargeResult>;} interface ChargeParams { amount: number; currency: string; paymentToken: string; description: string;} interface ChargeResult { transactionId: string; gatewayRef: string;} // STEP 4: Inject and use class CheckoutService { constructor(private paymentGateway: PaymentGateway) {} async processPayment(order: Order, paymentToken: string): Promise<Receipt> { const result = await this.paymentGateway.charge({ amount: order.total, currency: 'usd', paymentToken, description: `Order ${order.id}` }); return { transactionId: result.transactionId, amount: order.total }; }} // Now adding PayPal is just a new implementationclass PayPalPaymentGateway implements PaymentGateway { async charge(params: ChargeParams): Promise<ChargeResult> { // PayPal-specific implementation }}This pattern—refactoring to abstraction when the second use case appears—gives you the benefits of flexibility with none of the speculative cost. The abstraction is shaped by real requirements, not imagined ones.
We've explored the delicate balance between building for change and building for understanding. Let's consolidate the key lessons:
The LLD Mindset Complete:
You've now learned the four pillars of the Low-Level Design mindset:
With these mental models, you're equipped to approach any design problem with the discipline and intuition of an experienced engineer.
Congratulations! You've completed the LLD Mindset module. You now have the mental framework for effective Low-Level Design—thinking in components, assigning responsibilities correctly, designing interactions thoughtfully, and balancing flexibility with simplicity. These principles will serve you throughout your career, from daily coding decisions to major architectural choices.