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

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