Loading content...
Like any principle taken to extremes, DRY can become harmful. The pursuit of "zero duplication" can lead to tangled abstractions, inappropriate coupling, and code that's harder to understand than the original duplication would have been.
The software industry has learned this lesson through painful experience. Sandi Metz's famous observation—"duplication is far cheaper than the wrong abstraction"—captures a hard-won truth. A codebase with some duplication is often healthier than one over-engineered to eliminate every repeated line.
This page explores the pathologies of over-applied DRY and develops the judgment to know when not to DRY.
By the end of this page, you will recognize the warning signs of over-applied DRY, understand the trade-offs between duplication and abstraction, and develop the judgment to apply DRY appropriately. You'll learn when some duplication is the healthier choice.
The "wrong abstraction" is the most damaging consequence of over-applied DRY. It occurs when developers create shared code to eliminate duplication, but the shared code doesn't accurately model the underlying concepts. The result is worse than the original duplication.
How the Wrong Abstraction Forms:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// THE WRONG ABSTRACTION IN ACTION // Stage 1: Two similar functionsfunction formatUserDisplayName(user: User): string { return `${user.firstName} ${user.lastName}`;} function formatCustomerDisplayName(customer: Customer): string { return `${customer.firstName} ${customer.lastName}`;} // Stage 2: Developer applies DRY (too early)function formatPersonName(person: { firstName: string; lastName: string }): string { return `${person.firstName} ${person.lastName}`;} // Stage 3: Customer display needs to show company for B2Bfunction formatPersonName( person: { firstName: string; lastName: string; company?: string }, showCompany = false): string { const name = `${person.firstName} ${person.lastName}`; return showCompany && person.company ? `${name} (${person.company})` : name;} // Stage 4: Users need title for formal contextsfunction formatPersonName( person: { firstName: string; lastName: string; company?: string; title?: string }, options: { showCompany?: boolean; showTitle?: boolean; formal?: boolean } = {}): string { let name = ''; if (options.showTitle && person.title && options.formal) { name = `${person.title} `; } name += `${person.firstName} ${person.lastName}`; if (options.showCompany && person.company) { name += ` (${person.company})`; } return name;} // Stage 5: Some users are organizations, not peoplefunction formatPersonName( person: { firstName?: string; lastName?: string; company?: string; title?: string; isOrganization?: boolean; organizationName?: string; }, options: { showCompany?: boolean; showTitle?: boolean; formal?: boolean; useOrganizationName?: boolean; } = {}): string { if (options.useOrganizationName && person.isOrganization && person.organizationName) { return person.organizationName; } let name = ''; if (options.showTitle && person.title && options.formal) { name = `${person.title} `; } name += `${person.firstName ?? ''} ${person.lastName ?? ''}`.trim(); if(options.showCompany && person.company) { name += ` (${person.company})`; } return name || 'Unknown';} // RESULT: A tangled mess that:// - Is harder to understand than two separate functions// - Requires reading all code to understand any single use case// - Makes changes risky (might break unrelated use cases)// - Will continue accumulating complexity// - Is the "wrong abstraction"Once a shared abstraction exists, developers feel obligated to use it. The more code that depends on it, the harder it is to undo. This sunk-cost mentality leads to ever-more-complex abstractions. The courage to refactor back to duplication—to "pay down" the wrong abstraction—is rare but valuable.
Every time you extract shared code, you create a dependency. Modules that use the shared code are now coupled to each other—indirectly but significantly. This coupling may be appropriate (the concepts genuinely share knowledge) or inappropriate (the similarity was coincidental).
Inappropriate coupling causes:
| Shared Code | Appropriate If | Inappropriate If |
|---|---|---|
| Utility library | Genuinely general-purpose, stable | Contains domain-specific assumptions |
| Domain model | Represents single bounded context | Spans multiple bounded contexts |
| Validation rules | Same business rule everywhere | Similar rules for different reasons |
| Configuration | Truly global settings | Values that might diverge by consumer |
| API client | Single, versioned API contract | Different consumers need different versions |
The Microservices Lesson:
The microservices architecture movement highlights coupling dangers. A common anti-pattern is the "distributed monolith"—microservices that share so many libraries and patterns that they must be deployed together. The services are technically separate but practically coupled.
This often comes from over-aggressive DRY: "We have this utility in service A; let's extract it so service B can use it too." The result is a shared library that couples A and B, negating the independence that microservices were meant to provide.
Sometimes, duplication is the price of independence. Each service having its own copy of utility code—code that represents different knowledge or might evolve differently—preserves the ability to change and deploy independently.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// COUPLING FROM DRY IN MICROSERVICES // ❌ Over-DRYed: Shared library couples services // @acme/shared-utils (shared npm package)export function formatCurrency(amount: number): string { return `$${amount.toFixed(2)}`;} // payment-service/invoice.tsimport { formatCurrency } from '@acme/shared-utils';// When shared-utils updates, payment-service must update // notification-service/email.ts import { formatCurrency } from '@acme/shared-utils';// When shared-utils updates, notification-service must update // analytics-service/reports.tsimport { formatCurrency } from '@acme/shared-utils';// When shared-utils updates, analytics-service must update // PROBLEM: Change to formatCurrency → deploy 3+ services// PROBLEM: Services can't have different formatting needs// PROBLEM: Version conflicts if services upgrade at different times // ✅ Better: Appropriate duplication for independence // payment-service/utils/currency.tsfunction formatCurrency(amount: number): string { return `$${amount.toFixed(2)}`; // Payment needs precision} // notification-service/utils/formatting.tsfunction formatDisplayCurrency(amount: number): string { return `$${Math.round(amount)}`; // Notifications can round} // analytics-service/utils/reporting.tsfunction formatReportCurrency(amount: number, currency: string): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount); // Reports need localization} // Each service owns its formatting// Changes are scoped to one service// Different needs can evolve independentlyNot all sharing is bad. Genuine shared contracts (API schemas, event formats) and truly stable utilities (date parsing, string manipulation) should be shared. The key question is: "Do these consumers need to change together?" If yes, share. If no, duplicate.
Extreme DRY can produce code that, while deduplicating knowledge, is significantly harder to understand. The cognitive cost of navigating abstractions exceeds the maintenance cost of the original duplication.
Sources of DRY-Induced Complexity:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// COMPLEXITY FROM OVER-DRY // ❌ Over-abstracted: Too "clever" to understand // Generic data loader with all possible optionsabstract class DataLoader< TInput, TOutput, TCache extends CacheStrategy = DefaultCache, TRetry extends RetryPolicy = DefaultRetry, TTransform extends Transformer<TInput, TOutput> = IdentityTransformer<TInput, TOutput>> { constructor( protected readonly config: LoaderConfig<TInput, TOutput, TCache, TRetry, TTransform> ) {} async load(input: TInput): Promise<LoaderResult<TOutput>> { const cacheKey = this.config.cache.keyFor(input); const cached = await this.config.cache.get(cacheKey); if (cached) return this.wrap(cached); return await this.config.retry.execute(async () => { const raw = await this.fetchInternal(input); const transformed = await this.config.transform.apply(raw); await this.config.cache.set(cacheKey, transformed); return this.wrap(transformed); }); } protected abstract fetchInternal(input: TInput): Promise<TOutput>; // ... more abstract methods} // To load users, you need:class UserLoader extends DataLoader< string, User, RedisCache<User>, ExponentialRetry, UserTransformer> { // ...} // QUESTION: What happens when you call userLoader.load('123')?// ANSWER: Good luck figuring that out! // ✅ Simple: Explicit is better than clever // Simple user loader with clear flowclass UserService { private cache = new Map<string, User>(); async getUser(id: string): Promise<User | null> { // Check cache if (this.cache.has(id)) { return this.cache.get(id)!; } // Fetch with retry const user = await this.fetchWithRetry(id); // Cache result if (user) { this.cache.set(id, user); } return user; } private async fetchWithRetry(id: string, retries = 3): Promise<User | null> { for (let attempt = 1; attempt <= retries; attempt++) { try { return await this.api.getUser(id); } catch (error) { if (attempt === retries) throw error; await this.delay(attempt * 1000); } } return null; } private delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }} // YES, there's some code we could abstract// But: the flow is obvious, debugging is straightforward// Trade-off: A little repetition for a lot of clarityAfter creating an abstraction, ask: "Can a new team member understand this in 5 minutes?" If not, the abstraction might be too complex. Sometimes the "naive" approach—straightforward code with some acceptable duplication—is the professional choice.
Let's be explicit about situations where allowing duplication is the wiser choice:
1. Early in Development:
Duplication tolerance is especially valuable when the domain isn't yet well understood. Premature abstraction locks in assumptions. As you build, patterns emerge. The right abstraction becomes clearer after seeing three instances—or sometimes after failed attempts at the wrong abstraction.
2. Across Bounded Contexts:
In Domain-Driven Design, different bounded contexts may share similar concepts with different meanings. Customer in Sales and Customer in Support might look the same but represent different knowledge. Each context should have its own model.
3. When Concepts May Diverge:
If you can imagine realistic scenarios where the "same" code needs to evolve differently for different consumers, keep them separate. Today's identical code is tomorrow's forked abstraction.
4. For Test Clarity:
Test code has different goals than production code. Tests need to be readable, isolated, and self-documenting. Excessive DRY in tests creates:
Some duplication in test setup makes each test a readable, independent story.
5. For Onboarding and Comprehension:
New team members learn by reading code. Hyper-DRY code forces them to jump through many layers to understand a single operation. Moderately duplicated code provides multiple examples, and each is comprehensible in isolation.
There's a natural tension between DRY (fewer places to maintain) and readability (fewer jumps to understand). The right balance depends on how often the code changes vs. how often it's read. Code read frequently but changed rarely can afford more duplication for clarity.
If you've inherited or created the wrong abstraction, how do you fix it? The answer is counterintuitive: inline the abstraction back to duplication, then re-abstract correctly.
The Recovery Process:
Recognize the Pain — Acknowledge that the abstraction is fighting you. Signs: frequent bugs, fear of changes, mounting flags/conditionals.
Inline the Abstraction — For each caller, replace the call with the full implementation. Yes, this creates duplication. That's the point.
Customize Per Use Case — Now that each caller has its own code, modify each to serve its specific needs cleanly. Remove the conditionals that handled "this case vs. that case."
Re-evaluate — With clean, separate implementations, look for actual shared knowledge. There may be some; there may be none.
Re-abstract Carefully — If genuine shared knowledge exists, extract it. But now you have the evidence to get it right.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// RECOVERY: From Wrong Abstraction to Right Design // ❌ The Wrong Abstraction (accumulated over time)function processPayment( payment: Payment, options: { isRefund?: boolean; isSubscription?: boolean; isInternational?: boolean; isB2B?: boolean; skipValidation?: boolean; useAlternateGateway?: boolean; customFees?: FeeStructure; } = {}): Promise<PaymentResult> { // 200 lines of conditionals handling all combinations // Everyone is afraid to touch this} // STEP 1 & 2: Inline to each call site (creates duplication!) // checkout-service/payment.tsasync function processCheckoutPayment(payment: Payment): Promise<PaymentResult> { // Full implementation, customized for checkout // No isRefund, no isSubscription... just checkout} // subscription-service/billing.tsasync function processSubscriptionPayment(subscription: Subscription): Promise<PaymentResult> { // Full implementation for subscription billing // Different validation, different gateway rules} // refund-service/refund.tsasync function processRefund(refund: Refund): Promise<RefundResult> { // Full implementation for refunds // Completely different flow} // STEP 3 & 4: Customize and re-evaluate // After cleaning up each implementation, we notice:// - Gateway communication is actually shared infrastructure// - Fee calculation rules differ but have some common structure// - Validation is totally different per context // STEP 5: Re-abstract only what's genuinely shared // Shared: Payment gateway communicationclass PaymentGateway { async charge(amount: number, method: PaymentMethod): Promise<GatewayResponse>; async refund(transactionId: string, amount: number): Promise<GatewayResponse>;} // Shared: Fee calculation interface (but implementations differ!)interface FeeCalculator { calculate(amount: number, context: FeeContext): number;} class CheckoutFeeCalculator implements FeeCalculator { ... }class SubscriptionFeeCalculator implements FeeCalculator { ... }class RefundFeeCalculator implements FeeCalculator { ... } // NOT shared: The overall payment processing// Each context remains separate, using shared componentsThere's often psychological resistance to "going backward" by inlining an abstraction. But this isn't backward—it's corrective. The sunk cost of the wrong abstraction shouldn't prevent fixing it. Professional engineers refactor toward simplicity, even if it means undoing previous work.
DRY doesn't exist in isolation. It must be balanced against competing principles, each with its own valid concerns.
DRY vs. YAGNI (You Aren't Gonna Need It):
DRY encourages creating abstractions; YAGNI discourages building things until needed. The tension: should you abstract based on anticipated duplication or wait for actual duplication?
Resolution: Wait for actual duplication (YAGNI wins for first two occurrences). Abstract when duplication is real (DRY wins at third occurrence).
DRY vs. KISS (Keep It Simple, Stupid):
DRY can produce complex, indirect code. KISS values simplicity.
Resolution: If the abstraction is harder to understand than the duplication it eliminates, simplicity may trump DRY. Use the "5-minute onboarding" test.
DRY vs. Decoupling:
DRY creates dependencies (coupling). Decoupling seeks to minimize dependencies.
Resolution: Only share code that represents genuinely shared knowledge. Coincidental similarity should remain duplicated to preserve independence.
| Situation | Favor DRY | Favor Other Principle |
|---|---|---|
| Duplicated business rule | ✓ DRY wins | — |
| Similar code, unsure if same concept | — | ✓ YAGNI (wait) |
| Abstraction adds significant complexity | — | ✓ KISS (tolerate duplication) |
| Sharing would couple independent services | — | ✓ Decoupling |
| Third occurrence of same pattern | ✓ DRY wins | — |
| Test code with shared fixtures | — | ✓ Clarity (test isolation) |
| Security/compliance code | ✓ DRY wins | — |
| Code in different bounded contexts | — | ✓ DDD boundaries |
DRY vs. Performance:
In rare cases, DRY-compliant code may have performance implications (function call overhead, lost inlining opportunities). Modern compilers and JITs usually eliminate these concerns, but in hot paths, some controlled duplication for performance can be justified.
DRY vs. Locality:
Locality of behavior—keeping related code together—aids comprehension. DRY moves shared code away from its uses. In modules with high cohesion, some duplication keeps the full picture in one place.
The Meta-Principle:
DRY serves maintainability. If applying DRY hurts maintainability (through coupling, complexity, or forced co-evolution), DRY is being misapplied. The goal is healthy code, not minimum duplication.
DRY, KISS, YAGNI, and decoupling are heuristics developed from experience. None is absolute. The experienced engineer knows all the principles and applies judgment about which best serves the situation. Rigid adherence to any single principle creates different problems.
The goal is not to follow DRY blindly, but to develop the judgment to apply it wisely. This judgment comes from experience, reflection, and deliberate practice.
Questions to Ask Before DRYing:
Learning from Experience:
The best way to develop judgment is to:
Create abstractions deliberately — When you create an abstraction, write down your reasoning. Why here? Why now? What do you expect?
Revisit old abstractions — Six months later, how did it go? Did the abstraction hold up? Did it become a dumping ground for flags? Learn from both successes and failures.
Study others' code — When reading code, identify abstractions. Are they good? Would you do it differently? Why?
Discuss trade-offs in code review — Use reviews to discuss "is this the right abstraction?" rather than just "does this work?"
Be willing to be wrong — Creating the wrong abstraction isn't failure; it's learning. The failure is refusing to fix it.
Expert engineers have seen thousands of abstractions. They've watched good ones succeed and bad ones fail. This experience builds intuition. You accelerate this by reflecting on each abstraction decision—right or wrong—and internalizing the lessons.
We've explored the pathologies of over-applied DRY and developed judgment for its appropriate use. Let's consolidate the key takeaways:
Module Complete:
You've now completed the comprehensive study of the DRY principle. You understand its definition (knowledge, not code), the distinction between essential and coincidental duplication, practical methods for finding and fixing violations, and—critically—when NOT to apply DRY.
With this knowledge, you can apply DRY wisely: eliminating harmful duplication without creating harmful abstractions. You'll write code that is maintainable not because every line is unique, but because knowledge is appropriately centralized and complexity is appropriately managed.
You've mastered the DRY principle—not just its definition, but its nuanced application. You can distinguish knowledge from code duplication, fix violations appropriately, and know when duplication is the wiser choice. You're equipped to apply DRY as professionals do: with wisdom, judgment, and an eye toward long-term maintainability.