A Philosophy of Software DesignWorking with ComplexityLesson 11 of 13

AHA Over DRY

"Don't Repeat Yourself" is one of the most quoted principles in programming. It is also one of the most misapplied. DRY was originally about knowledge duplication: every piece of knowledge should have a single, unambiguous representation. But in practice, DRY gets applied to code duplication, which is a different thing entirely.

Two pieces of code that look identical are not necessarily duplicates of the same knowledge. They might be coincidentally similar today but diverge tomorrow. Merging them creates coupling between concepts that should be independent.

DRY's Hidden Cost: Coupling

When you extract shared code, you create a dependency. Every caller of the shared code is now coupled to it. If any caller needs the behavior to change, you have three options:

  1. Change the shared code and hope all callers still work
  2. Add a parameter or flag to the shared code to handle the new case
  3. Duplicate the code back out

Option 1 is dangerous. Option 2 creates a parameter creep monster. Option 3 is what you should have done in the first place.

// Three notification handlers that look similar
function sendEmailNotification(user: User, message: string): void {
  const formatted = `Dear ${user.name},\n\n${message}\n\nBest regards`;
  emailService.send(user.email, "Notification", formatted);
  logNotification(user.id, "email", message);
}

function sendSMSNotification(user: User, message: string): void {
  const truncated = message.slice(0, 140);
  smsService.send(user.phone, truncated);
  logNotification(user.id, "sms", truncated);
}

function sendPushNotification(user: User, message: string): void {
  const payload = { title: "Update", body: message, userId: user.id };
  pushService.send(user.deviceToken, payload);
  logNotification(user.id, "push", message);
}

A DRY enthusiast sees three functions with similar structure and extracts:

// "DRY" version: one function with 8 parameters
function sendNotification(
  user: User,
  message: string,
  channel: "email" | "sms" | "push",
  truncate: boolean,
  addGreeting: boolean,
  addSignoff: boolean,
  maxLength: number,
  title: string,
): void {
  let text = message;
  if (addGreeting) text = `Dear ${user.name},\n\n${text}`;
  if (addSignoff) text = `${text}\n\nBest regards`;
  if (truncate) text = text.slice(0, maxLength);

  switch (channel) {
    case "email":
      emailService.send(user.email, title, text);
      break;
    case "sms":
      smsService.send(user.phone, text);
      break;
    case "push":
      pushService.send(user.deviceToken, { title, body: text, userId: user.id });
      break;
  }
  logNotification(user.id, channel, text);
}

This is worse in every way. The function has eight parameters. Most are only relevant to one channel. The boolean flags create a combinatorial explosion of behavior. Adding a new channel requirement (like SMS needing a sender ID, or push needing an action URL) means adding more parameters. The "shared" code is now the hardest function in the codebase to modify.

WET Code Is Sometimes Right

WET stands for "Write Everything Twice" (or "We Enjoy Typing"). It is tongue-in-cheek, but the principle is real: code that is duplicated but independent is easier to change than code that is shared but coupled.

The three original notification functions are WET. Each repeats the log call. Each has similar structure. But they are independent. You can change the email format without worrying about SMS. You can add a sender ID to SMS without affecting push. Each function owns its channel completely.

Independence is more valuable than brevity.

The Rule of Three

Kent Beck's Rule of Three: do not abstract until you have three identical uses. Two similar things might be coincidence. Three similar things suggest a pattern.

Even at three, ask: are these three things the same concept or are they coincidentally similar? If they represent the same concept (same knowledge, same business rule), abstract. If they are similar code for different reasons, leave them separate.

// Coincidentally similar: tax calculation and discount calculation
// Both multiply a number by a percentage, but they are different concepts
const tax = subtotal * taxRate;
const discount = subtotal * discountRate;
// DO NOT extract: applyPercentage(subtotal, rate)
// Tax rules and discount rules will diverge.

// Same concept: formatting a date for display
// Used in user profile, order history, and notification
const displayDate = (date: Date): string =>
  date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
// DO extract: this is one business rule (how we display dates)
// applied in three places.

Recognizing a Hasty Abstraction

The "shared" utility file is the strongest smell. When you see utils.ts, helpers.ts, or shared.ts growing without bound, someone is abstracting prematurely.

Red flags for hasty abstraction:

  • The abstraction has boolean parameters that switch between different behaviors
  • The abstraction's name is vague: processData, handleStuff, doWork
  • Adding a new use case requires modifying the abstraction instead of just using it
  • The abstraction is used in only one or two places but was "extracted for reuse"
  • The abstraction requires more context to understand than the code it replaced
// Hasty abstraction: generic "formatter" that handles everything
function formatValue(
  value: unknown,
  type: "currency" | "date" | "percentage" | "phone",
  locale?: string,
  precision?: number,
  includeSymbol?: boolean,
): string {
  // 50 lines of switch/case with different formatting per type
}

// Better: separate formatters for separate concepts
const formatCurrency = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
const formatDate = (date: Date): string => date.toISOString().slice(0, 10);
const formatPercentage = (ratio: number): string => `${(ratio * 100).toFixed(1)}%`;
const formatPhone = (digits: string): string =>
  `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;

Refactoring Toward and Away from Abstraction

Abstraction is not a one-way street. Sometimes the right move is to inline an abstraction back into its callers.

Refactoring Toward Abstraction (After Seeing the Pattern)

// You notice three handlers all validate, transform, and save
// After the third, you extract the common pattern

type Handler<TInput, TValidated, TSaved> = {
  readonly validate: (input: TInput) => Result<TValidated, string>;
  readonly transform: (validated: TValidated) => TSaved;
  readonly save: (data: TSaved) => Promise<void>;
};

function createHandler<TInput, TValidated, TSaved>(
  config: Handler<TInput, TValidated, TSaved>,
) {
  return async (input: TInput): Promise<Result<void, string>> => {
    const validated = config.validate(input);
    if (validated.kind === "err") return validated;
    const transformed = config.transform(validated.value);
    await config.save(transformed);
    return ok(undefined);
  };
}

This abstraction is justified only if the validate-transform-save pattern genuinely repeats and each caller varies only in the specifics of validate, transform, and save. If any caller needs a different flow (validate-then-check-permissions-then-transform), the abstraction breaks.

Refactoring Away from Abstraction (When It No Longer Fits)

// The "shared" validation was extracted too early
// Now email validation needs async (check MX records)
// but phone validation is sync
// The shared validateField() cannot handle both

// BEFORE: shared abstraction
function validateField(value: string, type: "email" | "phone"): boolean {
  // ... sync validation for both
}

// AFTER: inlined back to callers
async function validateEmail(email: string): Promise<boolean> {
  if (!email.includes("@")) return false;
  return await checkMXRecord(email.split("@")[1]);
}

function validatePhone(phone: string): boolean {
  return /^\d{10}$/.test(phone);
}
// The shared abstraction was removed because the callers diverged.
// This is not a failure. This is correct design evolution.

Check Your Understanding

What is the hidden cost of DRY?

Not quite. The correct answer is highlighted.
The Rule of Three says: do not abstract until you have identical uses.
Not quite.Expected: three

What is a red flag for a hasty abstraction?

Not quite. The correct answer is highlighted.

When two pieces of code look identical, they are always duplicates of the same knowledge.

Not quite. The correct answer is highlighted.

Practice

Un-abstract an over-DRY'd notification system, then extract only the genuinely shared parts:

Summary

  • DRY is about knowledge duplication, not code duplication. Coincidentally similar code is not necessarily duplicate knowledge.
  • Extraction creates coupling. Every caller of shared code is now coupled to it. If they diverge, the coupling fights you.
  • WET code is sometimes right. Independent, duplicated code is easier to change than shared, coupled code.
  • Rule of Three: wait for three identical uses before abstracting. Even then, verify they represent the same concept.
  • Hasty abstraction smells: boolean parameters, vague names, growing parameter lists, single-use "shared" code.
  • Abstraction is not a one-way street. Inline abstractions back into callers when they no longer fit.

Next, we examine another principle that gets taken too far: separation of concerns, and its antidote, locality of behavior.