Dry Principle
10 min readDRY -- Don't Repeat Yourself
DRY Explained
A: DRY stands for "Don't Repeat Yourself." It was coined by Andy Hunt and Dave Thomas in The Pragmatic Programmer (1999). The principle states: "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." It applies to code, configuration, documentation, database schemas, and processes. Crucially, DRY is about knowledge duplication, not textual duplication. Two functions can have identical syntax but represent different business concepts and should not be merged.
A: When the same business rule lives in multiple places, a requirements change forces you to hunt down every copy. Miss one and you have a bug. DRY ensures changes propagate from a single source of truth, reducing defects, simplifying maintenance, and making the system easier to reason about.
DRY vs WET vs AHA
| Philosophy | Motto | When to apply |
|---|---|---|
| DRY | Don't Repeat Yourself | Same knowledge exists in multiple places |
| WET | Write Everything Twice | Tolerate duplication until a pattern is proven |
| AHA | Avoid Hasty Abstractions | Prefer duplication over the wrong abstraction |
A: WET ("Write Everything Twice" or "We Enjoy Typing") describes code where the same knowledge is repeated. AHA ("Avoid Hasty Abstractions"), coined by Kent C. Dodds, argues that duplication is cheaper than the wrong abstraction -- you can always refactor duplicates later, but tearing apart a bad abstraction is painful and risky.
// AHA in practice: these handlers look similar but serve different domains.
// Merging them prematurely would couple billing to shipping.
public class BillingNotificationHandler
{
public void Handle(BillingEvent e)
{
var msg = $"Invoice {e.InvoiceId} is {e.Status}";
_emailService.Send(e.CustomerEmail, msg);
}
}
public class ShippingNotificationHandler
{
public void Handle(ShippingEvent e)
{
var msg = $"Package {e.TrackingNumber} is {e.Status}";
_emailService.Send(e.CustomerEmail, msg);
}
}
// Same structure, different knowledge. Leave them separate.Types of Duplication
A: Four types: (1) Knowledge duplication -- the same business rule in multiple places (the real target of DRY). (2) Code duplication -- identical text that usually signals knowledge duplication. (3) Accidental duplication -- code that looks the same today but represents different concepts that will diverge. (4) Essential duplication -- cases where eliminating the duplicate costs more (tight coupling, complex generics) than living with it.
// Knowledge duplication: discount rule in two services
public class WebOrderService
{
public decimal GetDiscount(decimal total) => total > 500 ? total * 0.10m : 0;
}
public class MobileOrderService
{
public decimal GetDiscount(decimal total) => total > 500 ? total * 0.10m : 0; // same rule!
}
// Accidental duplication: same shape, different concepts
public record Address(string Line1, string City, string PostCode);
public record WarehouseLocation(string Line1, string City, string PostCode);
// These will diverge: warehouse adds bay/shelf, address adds country.A: Ask: "If the business requirement changes for one, must the other change the same way?" If yes, it is essential duplication -- extract it. If they can change independently, it is accidental -- leave them separate.
Extracting Methods, Base Classes, and Services
A: Extract a method. Pull repeated logic into a named private method.
// BEFORE: duplicated validation
public class InvoiceService
{
public void Create(Invoice inv)
{
if (inv == null) throw new ArgumentNullException(nameof(inv));
if (inv.Total <= 0) throw new InvalidOperationException("Total must be positive");
// ... create logic
}
public void Update(Invoice inv)
{
if (inv == null) throw new ArgumentNullException(nameof(inv));
if (inv.Total <= 0) throw new InvalidOperationException("Total must be positive");
// ... update logic
}
}
// AFTER: extracted method
public class InvoiceService
{
public void Create(Invoice inv) { Validate(inv); /* create */ }
public void Update(Invoice inv) { Validate(inv); /* update */ }
private static void Validate(Invoice inv)
{
ArgumentNullException.ThrowIfNull(inv);
if (inv.Total <= 0) throw new InvalidOperationException("Total must be positive");
}
}A: Use a base class (Template Method pattern) when subclasses share a common workflow skeleton but vary in specific steps. Use a shared service via DI when unrelated classes need the same logic -- this avoids inheritance coupling.
// Base class: shared workflow
public abstract class ReportGeneratorBase
{
public byte[] Generate(ReportRequest req)
{
var data = FetchData(req);
return Render(data);
}
protected abstract IEnumerable<Row> FetchData(ReportRequest req);
protected abstract byte[] Render(IEnumerable<Row> rows);
}
// Shared service via DI: no inheritance coupling
public interface IDiscountCalculator
{
decimal Calculate(decimal total, CustomerTier tier);
}
public class WebOrderService(IDiscountCalculator calc) { /* uses calc */ }
public class MobileOrderService(IDiscountCalculator calc) { /* uses calc */ }When DRY Goes Wrong
A: Blindly chasing DRY produces: a shared utility class everything depends on (deployment bottleneck), "god helpers" with dozens of unrelated methods, generic methods with boolean flags to handle all callers, and tight coupling between unrelated modules through a shared abstraction that serves neither well.
// Over-abstracted: one method serves billing, shipping, and HR
public static class NotificationHelper
{
public static void Send(string to, string subject, string body,
bool isBilling = false, bool isUrgent = false, string dept = null)
{
if (isBilling) { /* ... */ }
if (isUrgent) { /* ... */ }
// Branching nightmare coupling every domain
}
}A: A guideline from Martin Fowler: first time, just write it. Second time, note the duplication but tolerate it. Third time, refactor. By the third occurrence you have enough evidence to design a correct abstraction.
DRY in Code, Config, and Tests
A: In ASP.NET Core, use appsettings.json for defaults and override only per-environment deltas in appsettings.Development.json, etc. Bind to IOptions<T> so consuming code reads from one injected object, not scattered Configuration["key"] calls.
A: Use EF Core where C# model classes are the single source of truth. Migrations generate SQL from the model -- one definition for both application logic and database constraints.
public class Product
{
public int Id { get; set; }
[Required, MaxLength(200)]
public string Name { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal Price { get; set; }
}
// The migration generated from this model IS the schema -- DRY.A: Use builder patterns for test data, shared fixtures for setup, and parameterized tests for variations. Keep assertions explicit -- do not hide them in generic helpers.
public class OrderBuilder
{
private decimal _amount = 100m;
public OrderBuilder WithAmount(decimal a) { _amount = a; return this; }
public Order Build() => new() { Amount = _amount };
}
[Fact]
public void Discount_applied_for_large_orders()
{
var order = new OrderBuilder().WithAmount(1000m).Build();
Assert.Equal(100m, _calc.Calculate(order));
}Relationship with SOLID
| SOLID Principle | Relationship to DRY |
|---|---|
| SRP | DRY extractions must have a single reason to change. A "utils" dump violates SRP. |
| OCP | DRY abstractions should be open for extension via interfaces/abstract classes. |
| LSP | Base-class extractions must honour substitutability. |
| ISP | Shared interfaces should be focused. Don't force consumers to depend on unused methods. |
| DIP | Depend on abstractions for shared services, not concrete helpers. Keeps DRY loosely coupled. |
A:
// DRY + SRP: extracted focused validator
public interface IOrderValidator
{
ValidationResult Validate(Order order);
}
public class OrderValidator : IOrderValidator
{
public ValidationResult Validate(Order order)
{
if (order.Amount <= 0) return ValidationResult.Fail("Amount must be positive");
if (order.Items.Count == 0) return ValidationResult.Fail("Must have items");
return ValidationResult.Success();
}
}
// DRY + OCP: pricing strategy extracted, open for extension
public interface IPricingStrategy
{
decimal CalculatePrice(Order order);
}
public class StandardPricing : IPricingStrategy
{
public decimal CalculatePrice(Order o) => o.Items.Sum(i => i.Price * i.Quantity);
}
public class HolidayPricing : IPricingStrategy
{
public decimal CalculatePrice(Order o) => o.Items.Sum(i => i.Price * i.Quantity) * 0.85m;
}Refactoring Techniques
A: (1) Extract Method -- pull identical blocks into a named method. (2) Extract Class / Interface -- when a method cluster forms around a concept, promote it. (3) Pull Up Method -- move a shared method from derived classes to the base. (4) Replace Conditional with Polymorphism -- when if/switch blocks duplicate structure. (5) Introduce Parameter Object -- when the same parameter group appears in many signatures.
// Replace Conditional with Polymorphism
// BEFORE: duplicated switch
public decimal CalculateShipping(Order o) => o.Method switch
{
"Ground" => o.Weight * 0.5m,
"Air" => o.Weight * 2.0m,
_ => o.Weight * 1.0m
};
// AFTER: polymorphism
public interface IShippingCalculator { decimal Calculate(Order o); }
public class GroundShipping : IShippingCalculator
{
public decimal Calculate(Order o) => o.Weight * 0.5m;
}
public class AirShipping : IShippingCalculator
{
public decimal Calculate(Order o) => o.Weight * 2.0m;
}
// Introduce Parameter Object
// BEFORE: repeated parameter groups
public void Search(string city, string state, string zip, int radius) { }
public void Validate(string city, string state, string zip) { }
// AFTER: bundled
public record LocationQuery(string City, string State, string Zip, int? Radius = null);
public void Search(LocationQuery q) { }
public void Validate(LocationQuery q) { }Practical C# Examples
A: Here is a service with duplicated validation, audit logging, and notification extracted into focused collaborators.
// BEFORE -- WET: validation, audit, and email duplicated in both methods
public class CustomerService
{
public async Task Create(CustomerDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name)) throw new ValidationException("Name required");
if (!dto.Email.Contains('@')) throw new ValidationException("Invalid email");
var c = new Customer { Name = dto.Name, Email = dto.Email };
_db.Customers.Add(c);
await _db.SaveChangesAsync();
_db.AuditLogs.Add(new AuditLog { Action = "Create", EntityId = c.Id });
await _db.SaveChangesAsync();
await _email.SendAsync(dto.Email, "Welcome!", $"Hello {dto.Name}");
}
public async Task Update(int id, CustomerDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name)) throw new ValidationException("Name required");
if (!dto.Email.Contains('@')) throw new ValidationException("Invalid email");
var c = await _db.Customers.FindAsync(id) ?? throw new NotFoundException();
c.Name = dto.Name; c.Email = dto.Email;
await _db.SaveChangesAsync();
_db.AuditLogs.Add(new AuditLog { Action = "Update", EntityId = c.Id });
await _db.SaveChangesAsync();
await _email.SendAsync(dto.Email, "Updated", $"Hello {dto.Name}");
}
}
// AFTER -- DRY: each concern extracted once
public class CustomerDtoValidator : AbstractValidator<CustomerDto>
{
public CustomerDtoValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Email).NotEmpty().EmailAddress();
}
}
public interface IAuditService { Task LogAsync(string action, string entity, int id); }
public class CustomerService(
AppDbContext db, IValidator<CustomerDto> validator,
IAuditService audit, IEmailSender email)
{
public async Task Create(CustomerDto dto)
{
validator.ValidateAndThrow(dto);
var c = new Customer { Name = dto.Name, Email = dto.Email };
db.Customers.Add(c);
await db.SaveChangesAsync();
await audit.LogAsync("Create", "Customer", c.Id);
await email.SendAsync(dto.Email, "Welcome!", $"Hello {dto.Name}");
}
public async Task Update(int id, CustomerDto dto)
{
validator.ValidateAndThrow(dto);
var c = await db.Customers.FindAsync(id) ?? throw new NotFoundException();
c.Name = dto.Name; c.Email = dto.Email;
await db.SaveChangesAsync();
await audit.LogAsync("Update", "Customer", c.Id);
await email.SendAsync(dto.Email, "Updated", $"Hello {dto.Name}");
}
}Questions & Answers
Q1: Is removing every duplicate always the right call?
A: No. Accidental duplication -- code that looks alike but represents different concepts -- should be left separate. Merging it creates coupling between unrelated domains that will need to diverge later.
Q2: How does middleware apply DRY in ASP.NET Core?
A: Middleware centralizes cross-cutting concerns (error handling, logging, auth) so they are defined once in the pipeline, not duplicated in every controller action. Action filters provide similar benefits at the controller level.
Q3: How do extension methods help with DRY?
A: Extension methods add reusable behaviour to types you don't own. Common examples: pagination on IQueryable<T>, string formatting helpers, and domain-specific LINQ filters -- all defined once and reused everywhere.
Q4: Can DRY apply to CI/CD pipelines?
A: Yes. Repeated pipeline steps across repos should be extracted into shared templates (GitHub Actions composite actions, Azure DevOps task groups). Changes to the build process are made once.
Q5: In microservices, should you share code via a NuGet package or duplicate it?
A: Cross-cutting infrastructure (logging, auth, tracing) belongs in a shared package. Domain-specific business logic should be duplicated per bounded context because it will diverge. Sharing domain models across services creates tight coupling.
Q6: How do you detect DRY violations in a codebase?
A: SonarQube/SonarCloud detect code duplication. ReSharper and Rider have built-in duplicate detection. Code reviews catch structural duplication. Architectural fitness functions (NetArchTest) enforce dependency rules that prevent shared-logic drift.
Q7: How do you refactor towards DRY in a legacy codebase?
A: Identify high-churn files (repeated fixes signal duplication). Use duplication-detection tools. Apply the Strangler Fig pattern: extract shared logic into new classes, delegate from legacy code, and cover extractions with tests. Refactor incrementally.