DEV Community

Cover image for When Duplicate Code Is the Better Design
Adam - The Developer
Adam - The Developer

Posted on

When Duplicate Code Is the Better Design

Reframing DRY as knowledge rather than code

You've seen the developer. Maybe you are the developer.

They discover DRY — ✨ Don't Repeat Yourself ✨ — and something switches in their brain. A primal need awakens. Every duplicated string, every similar-looking function, every pair of lines that rhyme in the wrong light becomes a personal affront. An itch. A moral failing.

Two weeks later, their codebase looks like a game of Jenga where every piece is also load-bearing, also abstract, also parametrized six ways to Sunday, and also, crucially, completely impossible to understand.

Congratulations. You've achieved ✨ DRY ✨. You've also achieved a codebase that will ruin your next three Fridays and everyone's.


What DRY Actually Says (and Doesn't)

DRY comes from The Pragmatic Programmer by Andy Hunt and Dave Thomas. The actual rule is:

"Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."

Notice what it says: knowledge. it's not code. it's not characters. it's not "things that look similar when squinted at from across the room."

It says knowledge.

The principle is about avoiding duplicated intent and logic — not scrubbing every repeated character like you're laundering evidence. But somewhere between the book and the keyboard, people stopped reading and started pattern-matching like raccoons sorting shiny garbage:

"If two lines of code look alike, merge them immediately or you're morally bankrupt, professionally suspect, and probably the reason standups run long."

That's not DRY. That's aesthetic OCD with a philosophy degree and a GitHub contribution graph to protect.


The Taxonomy of DRY Crimes

Crime #1: Abstracting Coincidental Similarity

// Two functions that happen to look the same TODAY
function formatUserName(user) {
  return `${user.firstName} ${user.lastName}`;
}

function formatAuthorName(author) {
  return `${author.firstName} ${author.lastName}`;
}
Enter fullscreen mode Exit fullscreen mode

The classic DRY-brain response: "These are identical! Extract!"

// The abstraction
function formatName(entity) {
  return `${entity.firstName} ${entity.lastName}`;
}
Enter fullscreen mode Exit fullscreen mode

Fine. Harmless, even. Like a campfire in dry grass. Six months later, user names want a salutation, author names want a pen name fallback, and your cute little formatName has metastasized into a fifteen-parameter horror show with an enum called NameFormattingStrategy — the kind of function that needs its own onboarding doc and makes junior devs reconsider their career choices.

The duplication wasn't a bug. It was two different things that happened to share a shape for one Tuesday. A user is not an author. Their names evolve on different timelines. The duplicate code was whispering that — you heard "refactor opportunity" and built a cage instead.


Crime #2: The Abstraction That Needs a Manual

// Before: readable in 3 seconds
function getActiveAdminUsers(users) {
  return users.filter(u => u.isActive && u.role === 'admin');
}

// After: DRY'd into a puzzle box
function filterEntities(collection, predicates, options = {}) {
  const { limit, sortBy, transform } = options;
  const filtered = collection.filter(item =>
    predicates.every(pred => pred(item))
  );
  const sorted = sortBy ? filtered.sort(sortBy) : filtered;
  const limited = limit ? sorted.slice(0, limit) : sorted;
  return transform ? limited.map(transform) : limited;
}

// Usage (good luck, new hire)
filterEntities(users, [u => u.isActive, u => u.role === 'admin']);
Enter fullscreen mode Exit fullscreen mode

You saved four lines. You created a filterEntities function that now silently accumulates every filtering need in your entire app, grows to 200 lines, and gets passed to new developers with the haunted look of someone handing off a cursed object.

The original function had a name. It was easy to understand, it told a simple story, getActiveAdminUsers is self-documenting. Your generalized thing is a puzzle.

Code is read far more than it's written. Abstractions are not free — they are paid for in comprehension, every single time someone opens that file.


Crime #3: DRY Across Wrong Boundaries

The most insidious form. And unlike the toy examples above, this one has a body count.

Every backend dev has seen this happen. You're building a notification system. You have email, SMS, push, and in-app notifications. They all take a user, a type, and some options. They look identical at the call site:

// v1 — reasonable. clean. you feel good about yourself.
sendNotification(user, 'order_confirmed', { orderId });
Enter fullscreen mode Exit fullscreen mode

The abstraction makes sense. You write one function. You route internally. Ship it.

Then marketing wants to send a promotional email blast. Different thing, same function — but now you need an isMarketing flag because marketing emails have unsubscribe footers and transactional ones don't.

Then legal needs CAN-SPAM compliance on marketing sends. Different opt-out logic per locale. Now you need locale and complianceRules.

Then mobile push notifications need to respect iOS quiet hours, which email doesn't care about. Add respectQuietHours.

Then in-app notifications don't go through any external service at all — they're just a database write — but they're still "notifications" so they stay in the function.

Then SMS gets a character limit and needs message chunking logic that email will never need.

Eighteen months after that clean sendNotification(user, type, options), you have:

// v∞ — the function that consumed itself
async function sendNotification(
  user: User,
  type: NotificationType,
  options: NotificationOptions,
  channel: 'email' | 'sms' | 'push' | 'in_app',
  isMarketing: boolean,
  isTransactional: boolean,
  locale: string,
  complianceRules: ComplianceConfig,
  respectQuietHours: boolean,
  chunkIfOverLimit: boolean,
  bypassOptOut: boolean, // "just this once, for the launch"
  trackingPixel?: string,
) {
  if (channel === 'email') {
    if (isMarketing) {
      // 40 lines of compliance logic
    } else {
      // 30 lines of transactional logic
    }
  } else if (channel === 'sms') {
    // 50 lines that have nothing to do with email
  } else if (channel === 'push') {
    // 45 lines that have nothing to do with SMS
  } else if (channel === 'in_app') {
    // just writes to a table but must pass through this gauntlet anyway
  }
}
Enter fullscreen mode Exit fullscreen mode

This function is now 300 lines. It has no tests that actually cover all the flag combinations — there are 2⁷ of them, good luck. Every engineer who touches it adds one parameter at the top and one if branch somewhere in the middle, and prays they didn't break the Malaysian SMS path.

You have not DRY'd your code. You have built a load-bearing monolith disguised as a helper function.

The actual fix is humbling: email, SMS, push, and in-app were never the same thing. They shared a surface-level interface and nothing else. They have different rate limits, different compliance regimes, different retry logic, different failure modes, and different delivery guarantees. The right design was always:

sendEmail(user, template, options);
sendSms(user, message, options);
sendPushNotification(user, payload, options);
createInAppNotification(user, content);
Enter fullscreen mode Exit fullscreen mode

Yes, it's four functions. Yes, some options are repeated across them. That's fine — the repetition is honest. Each function can evolve independently. The SMS team can add chunking without touching the email function. Legal can update the CAN-SPAM logic without a regression on push. A new engineer can read sendEmail and understand sendEmail without holding the entire notification universe in their head.

The shared abstraction didn't eliminate complexity. It hid it, then grew it, then held it hostage.


Crime #4: The Generic Repository That Ate Your Domain

Every backend dev hits this phase. You have UserService, OrderService, ProductService. They all need to fetch things from the database. The queries look suspiciously similar:

// Three services, three nearly identical queries. Your DRY alarm is screaming.
async findAllUsers() {
  return db.users.findMany({ where: { deletedAt: null } });
}

async findAllOrders() {
  return db.orders.findMany({ where: { status: { not: 'draft' } } });
}

async findAllProducts() {
  return db.products.findMany({ where: { isPublished: true } });
}
Enter fullscreen mode Exit fullscreen mode

The fix writes itself. You extract a base class. You add generics. You feel like you're finally doing real architecture:

// The abstraction that will haunt three teams
abstract class BaseRepository<T> {
  abstract table: string;

  async findAll(filters?: Record<string, unknown>) {
    return db.query(this.table, { where: filters });
  }

  async findOne(id: string) {
    return db.query(this.table, { where: { id } });
  }

  async findPaginated(page: number, limit: number, filters?: Record<string, unknown>) {
    const offset = (page - 1) * limit;
    const [data, total] = await Promise.all([
      db.query(this.table, { where: filters, limit, offset }),
      db.count(this.table, { where: filters }),
    ]);
    return { data, total, page, limit };
  }
}

class UserRepository extends BaseRepository<User> { table = 'users'; }
class OrderRepository extends BaseRepository<Order> { table = 'orders'; }
class ProductRepository extends BaseRepository<Product> { table = 'products'; }
Enter fullscreen mode Exit fullscreen mode

Beautiful. Reusable. You deleted forty lines and posted about it in Slack. Life is good.

Then product management asks for order search with customer name, date range, and payment status — but only for admins, and only orders that haven't been refunded unless the refund was partial. Your findPaginated now accepts a QueryOptions object with twelve optional fields and a include array that half the team misconfigures.

Then users need soft-delete scoping, role-based visibility, and a "last active" sort that requires a join. You override findAll in UserRepository. Then you override findPaginated too. The base class is now mostly dead code that new hires still have to read.

Then products need full-text search, category trees, and inventory counts from a warehouse table in another service. Someone adds findPaginatedWithSearch. Someone else adds findPaginatedWithJoins. The base class grows a buildQuery hook, then a QueryBuilder parameter, then an escape hatch called rawQueryOverride that three repositories use and nobody documents.

Eighteen months later:

// v∞ — BaseRepository, but make it suffer
async findPaginated<T extends Entity>(
  page: number,
  limit: number,
  filters?: FilterMap,
  options?: {
    include?: IncludeMap;
    joins?: JoinConfig[];
    search?: SearchConfig;
    sort?: SortConfig | SortConfig[];
    scope?: 'admin' | 'public' | 'internal';
    softDelete?: boolean;
    bypassTenantScope?: boolean; // "temporary, for the migration"
    aggregate?: AggregateConfig;
    cursor?: string;
    transform?: (row: T) => unknown;
  }
): Promise<PaginatedResult<T | TransformedRow<typeof options>>> {
  // 180 lines of conditional query assembly
  // every repository passes a different options shape
  // the type signature is longer than most functions it replaces
}
Enter fullscreen mode Exit fullscreen mode

You have not eliminated duplication. You have built a generic pagination framework that every entity in your system must awkwardly fit into, like forcing every piece of furniture through the same IKEA allen wrench.

The honest version was always:

// UserRepository — boring, explicit, correct
async findActiveUsersForAdmin(page: number, limit: number) { /* ... */ }

// OrderRepository — different domain, different query
async findOrdersForDashboard(filters: OrderDashboardFilters) { /* ... */ }

// ProductRepository — nobody else's problem
async findPublishedProductsWithInventory(categoryId: string) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Yes, they all paginate. Yes, they all query a database. That's infrastructure similarity, not domain knowledge. Users, orders, and products don't share a reason to change — they share a SQL dialect. Conflating those two is how you end up with a BaseEntityManagerFactoryHelperUtil and a Jira ticket titled "refactor findPaginated (blocked: needs architect approval)."

Pagination is not a domain concept. It's a transport detail. Your repository layer is not a place to build a query DSL because SELECT statements rhymed once.


The Principle You Actually Need: AHA

Sandi Metz — of Practical Object-Oriented Design fame — offers the antidote:

AHA: Avoid Hasty Abstractions.

The rule: prefer duplication over the wrong abstraction.

A wrong abstraction is worse than duplication because:

  1. Duplication is visible and easy to fix
  2. A wrong abstraction is load-bearing, hard to see, and expensive to undo
  3. People are afraid to delete abstractions, so they pile parameters on top until it collapses

The heuristic she and others suggest: wait for the third time. Once — write it. Twice — note the repetition. Three times — now think about what abstraction, if any, makes sense. By then you have enough examples to see the actual shape of the knowledge, not just the accidental shape of the code.


When DRY Is Right

To be fair to the principle: DRY is genuinely critical in the right places.

Business rules. The formula for calculating interest, the rule for what makes a user "active," the threshold for triggering an alert — these must live in exactly one place. When the business logic changes, you want to change exactly one thing.

Configuration. A base URL hardcoded in twelve files is twelve places a typo can happen. That's rightfully DRY.

Data schemas. Your database schema and your validation schema should not diverge because you copy-pasted them. Derive one from the other.

The test for whether something should be DRY: if this thing needs to change, how many places need to change with it, and do those places share a reason to change?

If yes — DRY it.

If they just look similar today — leave it.


The Real Anti-Pattern DRY Is Trying to Fight

DRY's real enemy was never "duplicate code." It was duplicate knowledge — the same business rule, scattered across the system, slowly drifting out of sync.

The nightmare scenario: your pricing logic is in the database trigger, the API controller, the frontend calculation, and a comment in a Slack message from 2019. When pricing changes, you find three of them. The fourth one quietly disagrees for six months, occasionally giving users the wrong number, and you never know why.

That's what DRY is for. Not for making your formatName function 40% more reusable.


A Checklist for Before You Abstract

Before you extract that abstraction, ask:

  • Is this duplicate knowledge, or duplicate shape? Two things can look alike for different reasons.
  • Can I name this abstraction clearly? If you're reaching for processEntityData or handleThing, stop. The abstraction isn't real yet.
  • What happens when one usage changes? If you can't change the abstraction without worrying about the other usages, it's too coupled.
  • Am I doing this because it's right, or because repetition makes me uncomfortable? Valid question. The answer matters.

Closing

DRY is a heuristic, not a commandment. It was written to fight a specific enemy: knowledge duplication causing systems to rot. It was not written to make your codebase a shrine to abstraction.

The best codebases I've read had a little repetition in them. Deliberate repetition. The kind where someone clearly decided "these two things look alike but they're not the same thing, and I'm going to leave them separate so they can evolve separately."

That's not laziness. That's wisdom.

Write the thing twice if you have to. Your future self, reading the code at 11pm with no context, will thank you for the clarity.

Top comments (14)

Collapse
 
tobi_augenstein profile image
Tobias Augenstein

Thanks for this balanced perspective. One aspect I'd like to add is that unifying implementation doesn't necessarily mean to also unify the API. For the name formatting example you could do:

function formatUserName(user) {
  return formatName(user);
}
Enter fullscreen mode Exit fullscreen mode

Avoids duplication and is still easy to change, e.g.:

function formatUserName(user) {
  const name = formatName(user);
  return `${name} (${user.username})`;
}
Enter fullscreen mode Exit fullscreen mode

Usually it's not all or nothing. I think it's often best to try find a middle ground - avoiding duplication of logic as much as reasonable possible without trying to create the ultimate abstraction.

Collapse
 
adamthedeveloper profile image
Adam - The Developer • Edited

Yeah I get what you mean, and I think that’s the healthy version of DRY most people eventually land on.

The problem usually isn’t sharing code, it’s when we start forcing shared structure where only part of it is actually shared. That’s when APIs get split from implementations and everything starts feeling indirect.

Your example is actually a good one because it keeps the API clear while still reusing the core logic. That’s the sweet spot.

Where I usually get cautious is when it starts looking like this:

function getActiveUsers() {
  return getEntities('users', { isActive: true });
}

function getActiveOrders() {
  return getEntities('orders', { status: 'active' });
}
Enter fullscreen mode Exit fullscreen mode

At first it looks fine, but then every domain starts bending around this generic “getEntities” shape, and suddenly the abstraction is dictating how features are expressed instead of the other way around.

I’m fine with reuse when it’s just “this is the same logic, let’s not duplicate it.” But I try to avoid turning that into a framework layer unless I’ve actually seen the differences emerge in practice.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻

Great read - a thoughtful reminder that sometimes intentional duplication improves clarity and reduces risky coupling. The practical examples made the trade-offs clear; thanks for challenging the DRY-first mindset.

Collapse
 
adamthedeveloper profile image
Adam - The Developer

Appreciate that ⭐️

Yeah that’s pretty much it — once you’ve been burned a couple times by “smart” abstractions, you stop trusting similarity as a reason to unify things 😄

Collapse
 
rondo profile image
Rondo

Great insight! As a junior dev, I tend to try to abstract functions looking similar. But.. It mostly turned out to be increase of complexity😂
And of course, I also think duplication is better than too much abstraction. Duplicated code is normally, at least, easy to understand. But too-much-abstracted code is hard to even read.

Collapse
 
adamthedeveloper profile image
Adam - The Developer

Haha, same story for a lot of us 😆

I think most developers go through a phase where we discover DRY and start abstracting everything that looks remotely similar. Then a few months later we're adding more parameters, more conditionals, and more overrides than the duplicate code ever had.

I've found that duplicate code is usually easier to deal with than a wrong abstraction. At least the duplication is obvious and doesn't force unrelated things to evolve together.

Collapse
 
kyej_dev profile image
Kye Jones

This is such a good breakdown. DRY is one of those principles that sounds simple until you realise the wrong abstraction can be way more painful than a little duplication. The “duplicate knowledge vs duplicate shape” point is huge. Do you usually wait until the third repeated pattern before abstracting?

Collapse
 
adamthedeveloper profile image
Adam - The Developer

Thanks! And yeah, I generally follow the "third time" rule. The first time, I just write it. The second time, I notice the pattern. By the third time, I usually have enough examples to tell whether it's actually the same knowledge being repeated or just similar-looking code.

I've been burned more times by abstracting too early than by leaving a bit of duplication around for a while. It's surprisingly common for two things to start out looking identical and then head in completely different directions a few months later 😅

Collapse
 
cryptovibeapp profile image
CryptoVibe

The sendNotification example is so real. I've inherited that exact function before, except it had grown to take a config object because someone got tired of adding parameters. At that point you're not really sharing logic anymore, you're just hiding the fact that these are three different things.

The "wait for the third time" rule is good but I'd add: even when you do abstract, check if the things you're unifying actually change together. If email and SMS notifications evolve independently, keeping them separate isn't duplication, it's just accurate modeling.

Collapse
 
adamthedeveloper profile image
Adam - The Developer

Yeah, that’s exactly the failure mode, once a function turns into a “config object sink”, it usually means we’ve stopped modeling anything and just started hiding complexity behind a single entry point.

I really like your addition too. The “change together” check is honestly the part that matters more than the “third time” rule. Repetition alone isn’t enough — what really tells you whether abstraction makes sense is whether the reasons for change are actually shared.

If email and SMS evolve for different business reasons, keeping them separate isn’t duplication at all, it’s just honest modeling of reality. Trying to force them together usually just delays the pain.

Collapse
 
rynux profile image
Rynux

Good, it's long text but it's very usefull. Thanks

Some comments may only be visible to logged-in visitors. Sign in to view all comments.