Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 007 / 100

DRY, WET and AHA: a Design Patterns guide for developers

Duplication is cheaper than the wrong abstraction.

Expona Admin10 min read

We've all stared at two pieces of code and thought: should we combine these? The instinct to eliminate duplication is deeply ingrained in software development, but following it blindly has cost teams more than it's saved. This article explores DRY, WET and AHA: a Design Patterns guide for developers—three philosophies that help us decide when to abstract and when to wait.

Why duplication matters (but not always how you think)

The mantra "don't repeat yourself" has shaped how we build software for decades. It's sound advice in principle: duplicated code is harder to maintain, easier to make inconsistent, and creates multiple points of failure. But there's a subtlety that separates experienced teams from those that struggle with unmaintainable codebases.

The real principle isn't about code—it's about knowledge. If the same business rule appears in three places, and that rule changes, you now have to update all three. That's a knowledge problem. But if three pieces of code look identical yet serve completely different purposes and change for different reasons, combining them doesn't solve a problem; it creates one.

We've worked on systems where aggressive DRY practices created god functions, leaky abstractions, and tight coupling that made seemingly simple changes ripple across the codebase. We've also seen the other extreme: teams that copy-paste entire modules to avoid creating a shared function, then discovered they'd accidentally diverged the implementations in subtle ways.

DRY: Don't Repeat Yourself (but repeat knowledge, not code)

The DRY principle emerged to combat a real problem: maintaining the same logic in multiple places. When you have true duplication—the same knowledge encoded differently—DRY is your friend.

Consider a validation function used in multiple API endpoints:

typescript
// Endpoint 1
export async function createUser(req: Request) {
  if (!req.body.email || !req.body.email.includes('@')) {
    return { error: 'Invalid email' };
  }
  if (req.body.password.length < 12) {
    return { error: 'Password must be at least 12 characters' };
  }
  // ... create user
}

// Endpoint 2
export async function updateUser(req: Request) {
  if (!req.body.email || !req.body.email.includes('@')) {
    return { error: 'Invalid email' };
  }
  if (req.body.password.length < 12) {
    return { error: 'Password must be at least 12 characters' };
  }
  // ... update user
}

This is genuine duplication. The validation rules represent shared knowledge about what makes a valid user. Extracting this follows DRY:

typescript
function validateUserInput(data: any) {
  const errors: string[] = [];
  
  if (!data.email || !data.email.includes('@')) {
    errors.push('Invalid email');
  }
  
  if (data.password && data.password.length < 12) {
    errors.push('Password must be at least 12 characters');
  }
  
  return errors.length === 0 ? null : errors;
}

export async function createUser(req: Request) {
  const validationErrors = validateUserInput(req.body);
  if (validationErrors) {
    return { error: validationErrors };
  }
  // ... create user
}

export async function updateUser(req: Request) {
  const validationErrors = validateUserInput(req.body);
  if (validationErrors) {
    return { error: validationErrors };
  }
  // ... update user
}

Now the validation logic has a single source of truth. When security requirements change and passwords need to support passphrases or when email validation needs to be stricter, there's one place to update.

WET: Write Everything Twice Before Abstracting

WET is a pragmatic counterpoint. The idea: before you abstract something, see it in at least two places. Ideally three. This sounds counterintuitive, but it solves a critical problem—premature abstraction.

We've all experienced the cost of premature abstraction. You see two similar functions and create a shared utility with parameters to handle both cases. Six months later, a third use case emerges that doesn't fit the abstraction. You add another parameter. Then another. The "shared" code becomes a mess of conditionals, each branch serving a different master.

Consider a real scenario from an e-commerce backend. Early on, you have order export to CSV and invoice export to CSV:

typescript
// Order export
export function exportOrdersToCSV(orders: Order[]): string {
  const headers = ['Order ID', 'Date', 'Total', 'Status'];
  const rows = orders.map(order => [
    order.id,
    order.createdAt.toISOString(),
    order.total.toString(),
    order.status
  ]);
  return [headers, ...rows].map(row => row.join(',')).join('\n');
}

// Invoice export
export function exportInvoicesToCSV(invoices: Invoice[]): string {
  const headers = ['Invoice ID', 'Date', 'Amount', 'Status'];
  const rows = invoices.map(inv => [
    inv.id,
    inv.issuedAt.toISOString(),
    inv.amount.toString(),
    inv.status
  ]);
  return [headers, ...rows].map(row => row.join(',')).join('\n');
}

The code looks similar. The temptation to abstract is high. But each serves a different purpose. Orders and invoices have different fields, different formatting rules (orders might need SKU per line item; invoices might need tax calculations). The fact that they both produce CSV is almost coincidental.

Under WET, you leave this alone. You wait for a third CSV export to emerge. When it does—maybe reports for reconciliation—you have three concrete implementations. Now you can see the true pattern. Do all three really share logic, or does each have unique requirements?

In many real cases, by the time you've written similar code three times, you understand the problem space well enough to create an abstraction that actually fits. Or you realize they're different enough to stay separate.

AHA: Avoid Hasty Abstractions

AHA is the synthesis of the two. It's the philosophy that most abstractions are premature, but some duplications are real problems. The key is intentionality.

Hasty abstraction happens when you optimize for "lines of code" rather than clarity. You see repetition and immediately reach for a shared function, often paying a hidden cost in coupling and complexity.

Here's a practical example from a ride-hailing system. You have distance calculation used in two contexts: estimating ride fare, and filtering nearby drivers:

typescript
// Fare estimation
function estimateFare(pickupLat: number, pickupLng: number, 
                     dropoffLat: number, dropoffLng: number): number {
  const distance = Math.sqrt(
    Math.pow(dropoffLat - pickupLat, 2) + 
    Math.pow(dropoffLng - pickupLng, 2)
  ) * 111; // km approximation
  
  return distance * 2.5; // $2.50 per km
}

// Driver filtering
function findNearbyDrivers(userLat: number, userLng: number, 
                          drivers: Driver[], maxDistance: number): Driver[] {
  return drivers.filter(driver => {
    const distance = Math.sqrt(
      Math.pow(driver.lat - userLat, 2) + 
      Math.pow(driver.lng - userLng, 2)
    ) * 111;
    return distance <= maxDistance;
  });
}

These look identical. But they're not the same knowledge. Fare estimation might eventually need different calculation logic (actual road networks, surge pricing adjustments). Driver filtering might need to consider traffic or geographic boundaries. They just happen to both use distance today.

Under AHA, you might extract the distance calculation—that's genuinely reusable math—but keep the business logic separate:

typescript
function calculateDistance(lat1: number, lng1: number, 
                          lat2: number, lng2: number): number {
  return Math.sqrt(
    Math.pow(lat2 - lat1, 2) + 
    Math.pow(lng2 - lng1, 2)
  ) * 111; // km approximation
}

function estimateFare(pickupLat: number, pickupLng: number, 
                     dropoffLat: number, dropoffLng: number): number {
  const distance = calculateDistance(pickupLat, pickupLng, dropoffLat, dropoffLng);
  return distance * 2.5; // $2.50 per km
}

function findNearbyDrivers(userLat: number, userLng: number, 
                          drivers: Driver[], maxDistance: number): Driver[] {
  return drivers.filter(driver => {
    const distance = calculateDistance(userLat, userLng, driver.lat, driver.lng);
    return distance <= maxDistance;
  });
}

The pure calculation is shared, but each business context remains independent. They can evolve separately without affecting each other.

Similar code with different reasons to change should stay separate

This is the core insight that ties everything together. In a well-designed system, code changes for clear, bounded reasons. If two pieces of code look similar but change for different reasons, combining them violates the Single Responsibility Principle and creates hidden coupling.

Consider two payment processing flows: credit card processing and ACH bank transfer. Both process payments. Both have similar error handling, retry logic, and audit logging:

typescript
// First attempt: merge everything
async function processPayment(
  amount: number, 
  paymentMethod: 'card' | 'ach',
  details: any
): Promise {
  try {
    let result;
    
    if (paymentMethod === 'card') {
      result = await stripe.charges.create({
        amount: Math.round(amount * 100),
        currency: 'usd',
        source: details.tokenId
      });
    } else {
      result = await plaid.processors.achPayment({
        amount,
        accountId: details.accountId
      });
    }
    
    await logAudit('payment_processed', { amount, paymentMethod });
    return { success: true, result };
  } catch (error) {
    await logAudit('payment_failed', { amount, paymentMethod, error: error.message });
    throw error;
  }
}

This looks efficient but creates problems. Credit card processing might need to retry differently than ACH (cards have different decline reasons). Compliance audit requirements differ between payment methods. When Stripe changes their API, you're touching code that also handles ACH.

Better design keeps them separate but shares what genuinely belongs together:

typescript
async function processCardPayment(
  amount: number, 
  tokenId: string
): Promise {
  try {
    const result = await stripe.charges.create({
      amount: Math.round(amount * 100),
      currency: 'usd',
      source: tokenId
    });
    await logAudit('card_payment_processed', { amount });
    return { success: true, result };
  } catch (error) {
    await logAudit('card_payment_failed', { amount, error: error.message });
    throw error;
  }
}

async function processAchPayment(
  amount: number, 
  accountId: string
): Promise {
  try {
    const result = await plaid.processors.achPayment({
      amount,
      accountId
    });
    await logAudit('ach_payment_processed', { amount });
    return { success: true, result };
  } catch (error) {
    await logAudit('ach_payment_failed', { amount, error: error.message });
    throw error;
  }
}

// Shared: the audit logging pattern
async function logAudit(
  event: string, 
  data: Record
): Promise {
  await db.insert('audit_log', {
    event,
    data,
    timestamp: new Date()
  });
}

Now each payment method can evolve independently. They share the audit pattern (genuine, stable shared knowledge) but remain separate where they differ.

Common mistakes

Mistake 1: Confusing similar-looking code with shared knowledge. Two functions might have identical syntax but represent different business concerns. Before abstracting, ask: do these change for the same reason? If not, leave them alone.

Mistake 2: Over-parameterizing abstractions. You'll often see shared functions that take a dozen boolean flags or configuration objects to handle slightly different cases. This is a sign the abstraction is wrong. Two similar but separate functions are clearer than one over-generalized one.

Mistake 3: Abstracting before the second instance exists. Don't create a "utility" for something you've only written once. You'll guess wrong about what it needs to support. Wait for the second implementation, then decide.

Mistake 4: Treating file size as the goal. Teams sometimes optimize for "lines of code" or "functions" as if those are metrics. An extra 50 lines of clear, independent logic is better than 30 lines of tangled shared code.

Key takeaways

  • DRY targets duplicated knowledge—the same business rule or system behavior expressed multiple ways. Extract this.
  • WET encourages you to see a pattern at least twice, ideally three times, before committing to an abstraction.
  • AHA is the practical middle ground: abstract only when you're confident about the shared pattern, and keep separate code that changes for different reasons.
  • Use the "different reasons to change" test: if two pieces of code would change for different business reasons, they should stay separate even if they look similar today.
  • Premature abstraction is often more costly than code duplication. The abstractions you add early tend to be wrong.
  • When you do abstract, ensure you're really capturing shared knowledge, not just syntax similarity. Extract at the level of intent, not just surface similarity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles