What DRY Actually Means
Ask ten developers what DRY means and nine will say some version of "don't copy-paste code." That answer is close enough to sound right in a standup and wrong enough to cause real bugs in production — because the actual rule isn't about copy-pasting at all.
DRY = Don't Repeat Yourself. Here's the more accurate version, and the one worth actually internalizing:
Every piece of knowledge in your system should exist in exactly one place.
That distinction matters more than it sounds like it should. Two pieces of code can look completely different and still violate DRY if they encode the same business rule in two places. And two pieces of code can look nearly identical and not violate DRY at all, if they represent two genuinely unrelated rules that just happen to look the same today.
We'll get to that second case — it's where most developers overcorrect.
Violates DRY: The Same Rule, Copied Twice
Here's a leave-eligibility rule, living in two different places in the same codebase:
// LeaveRequestsController.cs
[HttpPost]
public async Task<IActionResult> Create(CreateLeaveRequestDto dto)
{
var employee = await _employeeRepository.GetByIdAsync(dto.EmployeeId);
bool isEligible = employee.ProbationEndDate <= DateTime.UtcNow
&& employee.EmploymentStatus == "Active";
if (!isEligible)
return BadRequest("Employee is not eligible to apply for leave.");
// ... create the leave request
return Ok();
}
// LeaveAutoApprovalJob.cs — a nightly background job
public async Task ProcessPendingRequestsAsync()
{
foreach (var request in await _leaveRepository.GetPendingRequestsAsync())
{
var employee = await _employeeRepository.GetByIdAsync(request.EmployeeId);
bool isEligible = employee.ProbationEndDate <= DateTime.UtcNow
&& employee.EmploymentStatus == "Active";
if (!isEligible)
{
request.Reject("Employee is not eligible.");
continue;
}
request.Approve();
}
}
Nobody copy-pasted this maliciously — it's a completely natural thing to happen when two different files need the same check and writing it inline feels faster than going looking for a shared place to put it.
The cost shows up later: when the rule changes, there are now two places to remember, and only one of them is in front of the person making the change.
Follows DRY: One Source of Truth
// Domain/Entities/Employee.cs
public enum EmploymentType
{
FullTime,
PartTime,
Contractor
}
public class Employee
{
public DateTime ProbationEndDate { get; init; }
public string EmploymentStatus { get; init; } = "Active";
public EmploymentType EmploymentType { get; init; }
public bool IsEligibleForLeave =>
ProbationEndDate <= DateTime.UtcNow
&& EmploymentStatus == "Active"
&& EmploymentType != EmploymentType.Contractor;
}
Both call sites shrink to one line:
if (!employee.IsEligibleForLeave)
return BadRequest("Employee is not eligible to apply for leave.");
if (!employee.IsEligibleForLeave)
{
request.Reject("Employee is not eligible.");
continue;
}
A new rule — say, contractors don't get paid leave — is now a one-line change to IsEligibleForLeave, and both the Controller and the background job pick it up automatically because there's only one place the rule actually lives.
DRY Isn't Just About Logic — Magic Strings Count Too
A quieter version of the same problem: values, not logic, scattered across the codebase.
// Violates DRY — the same status string, typed by hand, repeatedly
if (request.Status == "Pending") { /* ... */ }
var pendingCount = await _context.LeaveRequests
.CountAsync(r => r.Status == "pending"); // casing mismatch — silently returns 0
await _context.LeaveRequests
.Where(r => r.Status == "Pending")
.ToListAsync();
That casing mismatch isn't hypothetical — it's exactly the kind of bug that happens when "Pending" exists as a string in five different files instead of one declared value.
// Follows DRY
public enum LeaveRequestStatus
{
Pending,
Approved,
Rejected
}
Now the compiler — not a human remembering the exact casing — enforces consistency.
The same applies to configuration values like tax rates or thresholds: a 0.18m hardcoded in three different calculators should instead live once in appsettings.json, read through the IOptions<T> pattern.
When DRY Goes Wrong
This is the part most "DRY" advice skips, and it's the part senior engineers actually get asked about in interviews: merging code that looks the same but isn't the same thing.
public bool IsValidLeaveRequest(LeaveRequest request) =>
request.StartDate <= request.EndDate && request.NumberOfDays > 0;
public bool IsValidTrainingRequest(TrainingRequest request) =>
request.StartDate <= request.EndDate && request.NumberOfDays > 0;
These two methods are identical today. It's tempting to "DRY them up" into one shared IsValidDateRange(start, end, days) method used by both.
Do that, and watch what happens six months later when training requests get a new rule: minimum 3 days' notice before the training start date. Leave requests have no such rule.
Now the "shared" method needs a flag:
// What premature DRY-ing leads to
public bool IsValidDateRange(DateTime start, DateTime end, int days, bool requiresAdvanceNotice)
{
if (start > end || days <= 0) return false;
if (requiresAdvanceNotice && start < DateTime.UtcNow.AddDays(3)) return false;
return true;
}
That bool requiresAdvanceNotice parameter is the tell.
The two validations were never actually the same rule — they just looked the same because neither had diverged yet.
Merging them created a false coupling, and now every future change to either request type risks affecting the other through a method that's pretending to be one thing while doing two.

Join the conversation! Your thoughts help the community grow.