The DRY Trap: Why Duplication Isn't Your Worst Enemy

Ayobami OmotayoAyobami Omotayo
15 min read

We've all been there. You see two pieces of code that look similar, and the voice in our head screams "DRY! Don't Repeat Yourself!" So you extract, abstract, and consolidate until we have a beautiful, reusable piece of code. Six months later, we're debugging a nightmare where changing one thing breaks three unrelated features, and we wonder how we got here.

The truth is, premature DRY optimization might be doing more harm than good. While eliminating duplication is important, blindly following DRY can lead straight into what I call "the DRY trap”, where our obsession with avoiding repetition creates tightly coupled, hard-to-maintain code.

In this article, we'll explore why duplication isn't always our worst enemy, when DRY principles can backfire, and how to make smarter decisions about when to eliminate duplication versus when to embrace it. We'll use Laravel examples to illustrate these concepts, but the principles apply to any programming language or framework.

The DRY Principle: A Double-Edged Sword

What DRY Really Means

The DRY principle, coined by Andy Hunt and Dave Thomas in "The Pragmatic Programmer," states that "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."

Notice the key word here: knowledge. DRY isn't just about avoiding code duplication, it's about avoiding duplication of knowledge, logic, and intent throughout your system.

The Problem: DRY Without Context

Many interpret DRY as "never write similar code twice," leading to premature abstractions that create more problems than they solve. Here's the uncomfortable truth: not all duplication is bad, and not all abstractions are good.

When DRY Goes Wrong: The Classic Trap

Let's examine a scenario where aggressive DRY optimization leads to maintenance nightmares.

Example 1: The "Smart" Notification System

Consider a Laravel application that needs to send notifications to users in different contexts:

<?php

// app/Models/Order.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Order extends Model
{
    protected $fillable = ['user_id', 'total', 'status'];

    protected $casts = [
        'total' => 'decimal:2',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

// app/Models/Subscription.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Subscription extends Model
{
    protected $fillable = ['user_id', 'plan', 'expires_at', 'is_active'];

    protected $casts = [
        'expires_at' => 'datetime',
        'is_active' => 'boolean',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Now, let's look at an overly DRY approach that seems efficient but creates a maintenance nightmare:

<?php

// app/Services/NotificationService.php - The DRY Trap in Action
namespace App\Services;

use Illuminate\Support\Facades\Mail;
use App\Models\User;

class NotificationService
{
    public static function sendNotification(User $user, string $notificationType, array $context): void
    {
        // "Smart" logic that handles everything in one place
        $config = self::getNotificationConfig($notificationType);

        // Dynamic subject generation
        $subject = self::generateSubject($config['subject_template'], $context);

        // Dynamic template selection
        $template = $config['template'];

        // Send the email
        Mail::send($template, $context, function ($message) use ($user, $subject) {
            $message->to($user->email)->subject($subject);
        });

        // Log with dynamic formatting
        \Log::info(self::formatLogMessage($notificationType, $user->email, $context));
    }

    private static function getNotificationConfig(string $type): array
    {
        $configs = [
            'order_confirmation' => [
                'subject_template' => 'Order #{order_id} Confirmed',
                'template' => 'emails.order_confirmation',
                'log_format' => 'Order {order_id} confirmation sent to {email}',
            ],
            'order_shipped' => [
                'subject_template' => 'Order #{order_id} Shipped - Tracking: {tracking_number}',
                'template' => 'emails.order_shipped', 
                'log_format' => 'Order {order_id} shipping notification sent to {email}',
            ],
            'subscription_renewal' => [
                'subject_template' => '{plan} Subscription Renewed',
                'template' => 'emails.subscription_renewal',
                'log_format' => 'Subscription renewal for {plan} sent to {email}',
            ],
            'password_reset' => [
                'subject_template' => 'Password Reset Request',
                'template' => 'emails.password_reset',
                'log_format' => 'Password reset sent to {email}',
            ],
            'welcome' => [
                'subject_template' => 'Welcome to {app_name}!',
                'template' => 'emails.welcome',
                'log_format' => 'Welcome email sent to {email}',
            ],
        ];

        if (!isset($configs[$type])) {
            throw new \InvalidArgumentException("Unknown notification type: {$type}");
        }

        return $configs[$type];
    }

    private static function generateSubject(string $template, array $context): string
    {
        return preg_replace_callback('/\{(\w+)\}/', function ($matches) use ($context) {
            $key = $matches[1];
            return $context[$key] ?? $matches[0];
        }, $template);
    }

    private static function formatLogMessage(string $type, string $email, array $context): string
    {
        $config = self::getNotificationConfig($type);
        $template = $config['log_format'];
        $context['email'] = $email;

        return preg_replace_callback('/\{(\w+)\}/', function ($matches) use ($context) {
            $key = $matches[1];
            return $context[$key] ?? $matches[0];
        }, $template);
    }
}

// Usage looks clean...
class OrderController extends Controller
{
    public function confirmOrder(Request $request, int $orderId)
    {
        $order = Order::findOrFail($orderId);

        NotificationService::sendNotification(
            $order->user,
            'order_confirmation',
            ['order_id' => $order->id, 'total' => $order->total]
        );

        return response()->json(['message' => 'Order confirmed']);
    }
}

Why This "DRY" Approach Is Actually Worse:

This notification service looks impressively DRY on the surface, but it's actually a maintenance nightmare waiting to happen:

  1. Hidden Coupling: Order notifications, password resets, and welcome emails are now coupled through shared infrastructure

  2. Change Amplification: Adding a field to order confirmations requires understanding the entire template system

  3. Cognitive Overload: To modify one notification type, you need to understand the entire system

  4. Testing Complexity: Unit testing requires setting up complex context arrays and understanding the template engine

  5. Debugging Difficulty: When something breaks, you need to trace through multiple layers of abstraction

Six months later, when the business asks you to add rich HTML formatting to order emails but keep subscription emails plain text, you realize you've built a monster.

A Better Approach: Strategic Duplication

Instead of forcing everything into one "smart" service, let's embrace some strategic duplication:

<?php

// app/Services/Notifications/OrderNotificationService.php
namespace App\Services\Notifications;

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use App\Models\{User, Order};

class OrderNotificationService
{
    private User $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function sendConfirmation(Order $order): void
    {
        $subject = "Order #{$order->id} Confirmed";
        $context = [
            'order' => $order,
            'user' => $this->user,
            'total' => $order->total,
        ];

        Mail::send('emails.order_confirmation', $context, function ($message) use ($subject) {
            $message->to($this->user->email)->subject($subject);
        });

        Log::info("Order confirmation sent", [
            'user_id' => $this->user->id,
            'order_id' => $order->id,
        ]);
    }

    public function sendShipped(Order $order): void
    {
        $subject = "Order #{$order->id} Shipped";
        $context = [
            'order' => $order,
            'user' => $this->user,
            'tracking_number' => $order->tracking_number,
        ];

        Mail::send('emails.order_shipped', $context, function ($message) use ($subject) {
            $message->to($this->user->email)->subject($subject);
        });

        Log::info("Order shipping notification sent", [
            'user_id' => $this->user->id,
            'order_id' => $order->id,
            'tracking_number' => $order->tracking_number,
        ]);
    }
}

// app/Services/Notifications/SubscriptionNotificationService.php
namespace App\Services\Notifications;

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use App\Models\{User, Subscription};

class SubscriptionNotificationService
{
    private User $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function sendRenewal(Subscription $subscription): void
    {
        $subject = "Your {$subscription->plan} subscription has been renewed";
        $context = [
            'subscription' => $subscription,
            'user' => $this->user,
            'next_billing_date' => $subscription->expires_at,
        ];

        Mail::send('emails.subscription_renewal', $context, function ($message) use ($subject) {
            $message->to($this->user->email)->subject($subject);
        });

        Log::info("Subscription renewal notification sent", [
            'user_id' => $this->user->id,
            'subscription_id' => $subscription->id,
            'plan' => $subscription->plan,
        ]);
    }

    public function sendExpiryWarning(Subscription $subscription): void
    {
        $daysUntilExpiry = now()->diffInDays($subscription->expires_at);
        $subject = "Your subscription expires in {$daysUntilExpiry} days";
        $context = [
            'subscription' => $subscription,
            'user' => $this->user,
            'days_until_expiry' => $daysUntilExpiry,
            'renewal_url' => route('subscription.renew', $subscription),
        ];

        Mail::send('emails.subscription_expiry_warning', $context, function ($message) use ($subject) {
            $message->to($this->user->email)->subject($subject);
        });

        Log::info("Subscription expiry warning sent", [
            'user_id' => $this->user->id,
            'subscription_id' => $subscription->id,
            'days_until_expiry' => $daysUntilExpiry,
        ]);
    }
}

// app/Services/Notifications/AuthNotificationService.php
namespace App\Services\Notifications;

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use App\Models\User;

class AuthNotificationService
{
    private User $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function sendWelcome(): void
    {
        $subject = "Welcome to " . config('app.name') . "!";
        $context = [
            'user' => $this->user,
            'app_name' => config('app.name'),
            'dashboard_url' => route('dashboard'),
        ];

        Mail::send('emails.welcome', $context, function ($message) use ($subject) {
            $message->to($this->user->email)->subject($subject);
        });

        Log::info("Welcome email sent", ['user_id' => $this->user->id]);
    }

    public function sendPasswordReset(string $token): void
    {
        $subject = "Reset Your Password";
        $context = [
            'user' => $this->user,
            'reset_url' => route('password.reset', ['token' => $token, 'email' => $this->user->email]),
            'expires_in' => config('auth.passwords.users.expire'),
        ];

        Mail::send('emails.password_reset', $context, function ($message) use ($subject) {
            $message->to($this->user->email)->subject($subject);
        });

        Log::info("Password reset email sent", ['user_id' => $this->user->id]);
    }
}

// Usage - Clean, focused, and easy to understand
class OrderController extends Controller
{
    public function confirmOrder(Request $request, int $orderId)
    {
        $order = Order::findOrFail($orderId);

        $notificationService = new OrderNotificationService($order->user);
        $notificationService->sendConfirmation($order);

        return response()->json(['message' => 'Order confirmed']);
    }
}

Why This "Duplicated" Approach Is Better:

Yes, there's some duplication in the email sending and logging code, but look at the benefits:

  1. Clear Intent: Each service class has one job and does it well

  2. Easy Changes: Need to modify order emails? Only touch OrderNotificationService

  3. Independent Evolution: Order notifications can become complex without affecting auth emails

  4. Simple Testing: Each service can be tested in isolation

  5. Easy Debugging: Problems are localized and easy to trace

When to DRY, When to Duplicate

The key is learning to distinguish between coincidental duplication and knowledge duplication.

Eliminate Knowledge Duplication

DO eliminate duplication when:

  • It represents the same business rule or logic

  • Changes to one instance should always trigger changes to others

  • The duplication creates maintenance burden

<?php

// Good: Shared business logic
class PricingService
{
    public static function calculateTax(float $amount, string $region): float
    {
        // Tax calculation logic that should be consistent everywhere
        return match ($region) {
            'US' => $amount * 0.08,
            'EU' => $amount * 0.20,
            'UK' => $amount * 0.17,
            default => 0,
        };
    }

    public static function applyDiscount(float $amount, float $discountPercent): float
    {
        // Discount logic that must be consistent
        return $amount * (1 - $discountPercent / 100);
    }
}

// Used consistently across the application
class OrderService
{
    public function calculateTotal(Order $order): float
    {
        $subtotal = $order->items->sum('price');
        $discounted = PricingService::applyDiscount($subtotal, $order->discount_percent);
        return $discounted + PricingService::calculateTax($discounted, $order->shipping_region);
    }
}

class InvoiceService
{
    public function generateInvoice(Order $order): Invoice
    {
        $subtotal = $order->items->sum('price');
        $discounted = PricingService::applyDiscount($subtotal, $order->discount_percent);
        $tax = PricingService::calculateTax($discounted, $order->shipping_region);

        // Same calculation logic ensures consistency
        return new Invoice($discounted, $tax, $discounted + $tax);
    }
}

Embrace Coincidental Duplication

DON'T eliminate duplication when:

  • The similarity is coincidental, not conceptual

  • The duplicated code serves different business purposes

  • The parts might evolve independently

<?php

// Good: Similar structure, different purposes - don't DRY this
class UserRegistrationValidator
{
    public function validate(array $data): array
    {
        $errors = [];

        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            $errors[] = 'Valid email is required for registration';
        }

        if (empty($data['password']) || strlen($data['password']) < 8) {
            $errors[] = 'Password must be at least 8 characters for new accounts';
        }

        if (User::where('email', $data['email'])->exists()) {
            $errors[] = 'This email is already registered';
        }

        return $errors;
    }
}

class PasswordResetValidator
{
    public function validate(array $data): array
    {
        $errors = [];

        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            $errors[] = 'Valid email is required for password reset';
        }

        if (!User::where('email', $data['email'])->exists()) {
            $errors[] = 'No account found with this email address';
        }

        return $errors;
    }
}

// Bad: Over-DRY version that couples unrelated concerns
class UniversalValidator
{
    public function validate(string $type, array $data): array
    {
        $errors = [];

        // Email validation (same logic, but different error messages)
        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            $errors[] = $this->getEmailErrorMessage($type);
        }

        // Different existence checks for different purposes
        if ($type === 'registration' && User::where('email', $data['email'])->exists()) {
            $errors[] = 'This email is already registered';
        } elseif ($type === 'password_reset' && !User::where('email', $data['email'])->exists()) {
            $errors[] = 'No account found with this email address';
        }

        return $errors;
    }

    private function getEmailErrorMessage(string $type): string
    {
        return match ($type) {
            'registration' => 'Valid email is required for registration',
            'password_reset' => 'Valid email is required for password reset',
        };
    }
}

Smart Abstraction: When DRY Makes Sense

There are times when smart abstraction makes sense. The key is abstracting infrastructure and utilities, not business logic:


<?php

// app/Services/Infrastructure/EmailService.php - Good abstraction
namespace App\Services\Infrastructure;

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;

class EmailService
{
    public function send(string $to, string $subject, string $template, array $data = []): void
    {
        try {
            Mail::send($template, $data, function ($message) use ($to, $subject) {
                $message->to($to)->subject($subject);
            });

            Log::info("Email sent successfully", [
                'to' => $to,
                'subject' => $subject,
                'template' => $template,
            ]);
        } catch (\Exception $e) {
            Log::error("Failed to send email", [
                'to' => $to,
                'subject' => $subject,
                'error' => $e->getMessage(),
            ]);

            throw $e;
        }
    }

    public function sendBulk(array $recipients, string $subject, string $template, array $data = []): void
    {
        foreach ($recipients as $recipient) {
            $this->send($recipient, $subject, $template, $data);
        }
    }
}

// Business services use the infrastructure, but keep their domain logic separate
class OrderNotificationService
{
    private EmailService $emailService;

    public function __construct(EmailService $emailService)
    {
        $this->emailService = $emailService;
    }

    public function sendConfirmation(Order $order): void
    {
        // Business logic stays here - specific to orders
        $subject = "Order #{$order->id} Confirmed - Total: $" . number_format($order->total, 2);
        $data = [
            'order' => $order,
            'items' => $order->items,
            'shipping_address' => $order->shippingAddress,
            'estimated_delivery' => $order->estimatedDelivery(),
        ];

        $this->emailService->send(
            $order->user->email, 
            $subject, 
            'emails.order_confirmation', 
            $data
        );
    }

    public function sendShipped(Order $order): void
    {
        // Different business logic for shipped notifications
        $subject = "Your order is on the way! Tracking: {$order->tracking_number}";
        $data = [
            'order' => $order,
            'tracking_url' => "https://tracking.example.com/{$order->tracking_number}",
            'carrier' => $order->shipping_carrier,
            'estimated_delivery' => $order->estimatedDelivery(),
        ];

        $this->emailService->send(
            $order->user->email, 
            $subject, 
            'emails.order_shipped', 
            $data
        );
    }
}

class SubscriptionNotificationService
{
    private EmailService $emailService;

    public function __construct(EmailService $emailService)
    {
        $this->emailService = $emailService;
    }

    public function sendRenewal(Subscription $subscription): void
    {
        // Completely different business logic - subscription domain
        $subject = "Your {$subscription->plan} subscription has been renewed";
        $data = [
            'subscription' => $subscription,
            'next_billing_date' => $subscription->next_billing_date,
            'billing_amount' => $subscription->monthly_cost,
            'manage_url' => route('subscriptions.manage'),
        ];

        $this->emailService->send(
            $subscription->user->email, 
            $subject, 
            'emails.subscription_renewal', 
            $data
        );
    }
}

The "Rule of Three" for DRY Decisions

Here's a practical approach to DRY decisions:

1. First Occurrence: Write It

class UserService
{
    public function createUser(array $data): User
    {
        // First time writing user validation
        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            throw new ValidationException('Invalid email');
        }

        return User::create($data);
    }
}

2. Second Occurrence: Note It

class AdminService  
{
    public function createAdmin(array $data): Admin
    {
        // Hmm, similar validation - but maybe admin rules are different?
        if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            throw new ValidationException('Invalid admin email');
        }

        // Maybe admins need additional validation later
        return Admin::create($data);
    }
}

3. Third Occurrence: Refactor

// Now it's clear this is a pattern worth abstracting
class EmailValidator
{
    public static function validate(string $email, string $context = 'email'): void
    {
        if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new ValidationException("Invalid {$context}");
        }
    }
}

class UserService
{
    public function createUser(array $data): User
    {
        EmailValidator::validate($data['email'] ?? '', 'email');
        return User::create($data);
    }
}

class AdminService  
{
    public function createAdmin(array $data): Admin
    {
        EmailValidator::validate($data['email'] ?? '', 'admin email');
        // Admin-specific logic can evolve independently
        return Admin::create($data);
    }
}

Red Flags: When DRY Has Gone Too Far

Watch out for these warning signs that DRY optimization has backfired:

1. Configuration Hell

// Bad: Everything is configurable, nothing is clear
class UniversalService
{
    public function process($type, $data, $options = [])
    {
        $config = $this->getConfig($type);

        foreach ($config['steps'] as $step) {
            $this->executeStep($step, $data, $options);
        }
    }

    private function getConfig($type)
    {
        // 200 lines of configuration arrays
    }
}

2. Boolean Parameter Explosion

// Bad: Too many boolean flags indicate the function does too much
public function sendNotification(
    $user, 
    $type, 
    $data, 
    $urgent = false, 
    $trackOpens = true, 
    $allowUnsubscribe = true,
    $includeAttachment = false,
    $useTemplate = true
) {
    // This function is trying to do everything
}

3. The "God Object" Pattern

// Bad: One service handles everything
class NotificationManager
{
    public function handleEmailNotification() { /* ... */ }
    public function handleSMSNotification() { /* ... */ }  
    public function handlePushNotification() { /* ... */ }
    public function handleSlackNotification() { /* ... */ }
    public function handleDiscordNotification() { /* ... */ }
    // ... 20 more methods
}

Guidelines for Healthy DRY

Extract Infrastructure, Not Business Logic

// Good: Extract common infrastructure
class DatabaseService
{
    public function transaction(callable $callback)
    {
        DB::beginTransaction();
        try {
            $result = $callback();
            DB::commit();
            return $result;
        } catch (\Exception $e) {
            DB::rollback();
            throw $e;
        }
    }
}

// Business logic stays separate
class OrderService
{
    public function __construct(private DatabaseService $db) {}

    public function createOrder(array $data): Order
    {
        return $this->db->transaction(function () use ($data) {
            $order = Order::create($data);
            $this->updateInventory($order);
            $this->sendConfirmation($order);
            return $order;
        });
    }
}

Prefer Composition Over Inheritance

// Good: Compose behavior instead of inheriting everything
class OrderService
{
    public function __construct(
        private EmailService $emailService,
        private InventoryService $inventoryService,
        private PaymentService $paymentService
    ) {}

    public function processOrder(Order $order): void
    {
        $this->paymentService->charge($order);
        $this->inventoryService->reserve($order);
        $this->emailService->send(/* ... */);
    }
}

Use Traits for Cross-Cutting Concerns

// Good: Traits for infrastructure that crosses domains
trait Cacheable
{
    protected function remember(string $key, callable $callback, int $ttl = 3600)
    {
        return Cache::remember($key, $ttl, $callback);
    }
}

class UserService
{
    use Cacheable;

    public function getUser(int $id): User
    {
        return $this->remember("user.{$id}", fn() => User::find($id));
    }
}

class ProductService
{
    use Cacheable;

    public function getProduct(int $id): Product  
    {
        return $this->remember("product.{$id}", fn() => Product::find($id));
    }
}

When "Wet" Code Is Actually Better

Sometimes having "WET" (Write Everything Twice) code is the right choice:

1. During Rapid Prototyping

// Acceptable during exploration phase
class QuickPrototype
{
    public function handleUserRegistration($data)
    {
        // Quick and dirty validation
        if (!$data['email']) return 'Email required';
        if (!$data['password']) return 'Password required';

        User::create($data);
        Mail::send('emails.welcome', $data, function($m) use ($data) {
            $m->to($data['email'])->subject('Welcome!');
        });
    }

    public function handleAdminRegistration($data) 
    {
        // Similar but might evolve differently
        if (!$data['email']) return 'Email required';
        if (!$data['password']) return 'Password required';
        if (!$data['admin_code']) return 'Admin code required';

        Admin::create($data);
        Mail::send('emails.admin_welcome', $data, function($m) use ($data) {
            $m->to($data['email'])->subject('Admin Welcome!');
        });
    }
}

2. When Requirements Are Unclear

// Don't abstract until you understand the patterns
class ReportGenerator
{
    public function salesReport($startDate, $endDate)
    {
        $sales = Sale::whereBetween('created_at', [$startDate, $endDate])->get();

        return [
            'total' => $sales->sum('amount'),
            'count' => $sales->count(),
            'average' => $sales->avg('amount'),
        ];
    }

    public function userReport($startDate, $endDate)
    {
        $users = User::whereBetween('created_at', [$startDate, $endDate])->get();

        return [
            'total' => $users->count(),
            'active' => $users->where('last_login', '>', now()->subDays(30))->count(),
            'verified' => $users->where('email_verified_at', '!=', null)->count(),
        ];
    }

    // Wait to see if more reports follow similar patterns before abstracting
}

Duplication as a Design Tool

The goal isn't to eliminate all duplication, it's to eliminate the right duplication while preserving flexibility and clarity. Here's your decision framework:

Eliminate duplication when:

  • It represents the same business rule or constraint

  • Changes should always be synchronized

  • It's pure infrastructure or utility code

  • You've seen the pattern at least 3 times

Keep duplication when:

  • The similarity is coincidental

  • Different parts might evolve independently

  • You're still exploring the problem space

  • The abstraction would be more complex than the duplication

Remember: Code is read far more often than it's written. Sometimes a little duplication makes code much easier to understand, debug, and modify. The most "DRY" code isn't always the most maintainable code.

Your future self (and your teammates) will thank you for choosing clarity over cleverness, and strategic duplication over premature abstraction. Don't let the pursuit of DRY code lead you into the trap of unreadable, unmaintainable abstractions.

The best code is code that clearly expresses its intent, can be easily changed when requirements evolve, and doesn't surprise anyone six months from now. Sometimes that means embracing a little duplication, and that's perfectly okay.

Measuring Success: When You've Got the Balance Right

You've likely achieved a good balance when:

  1. Each class has a clear, single purpose that can be described in one sentence

  2. Common infrastructure is reused without forcing unrelated domains together

  3. Changes in one domain don't ripple through unrelated parts of the system

  4. New features can be added by composing existing components rather than modifying them

  5. Testing is straightforward because each component has focused responsibilities

Conclusion

The tension between DRY and SRP isn't a problem to be solved, it's a design force to be balanced. The key is recognizing that both principles serve the ultimate goal of maintainable, understandable code.

Use DRY to eliminate duplication in infrastructure, utilities, and cross-cutting concerns. Use SRP to keep business logic focused and domain boundaries clear. When they conflict, consider composition, layered abstractions, and careful separation of concerns.

Remember: Good software design isn't about following rules blindly, it's about understanding the trade-offs and making conscious decisions that serve your specific context and constraints.

The next time you find yourself torn between eliminating duplication and maintaining clear responsibilities, step back and ask: "What would make this code easier to understand, test, and change six months from now?" The answer will guide you toward the right balance for your situation.

0
Subscribe to my newsletter

Read articles from Ayobami Omotayo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ayobami Omotayo
Ayobami Omotayo

Hi, I’m Ayobami Omotayo, a full-stack developer and educator passionate about leveraging technology to solve real-world problems and empower communities. I specialize in building dynamic, end-to-end web applications with strong expertise in both frontend and backend development