Skip to main content

Don’t Repeat Yourself (DRY)

As developers, we spend a lot of our time changing code.

We add features. We fix bugs. We update requirements. We adjust business rules. Very little software stays the same for long.

That reality is what makes DRY important.

In The Pragmatic Programmer, David Thomas and Andrew Hunt define DRY this way:

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

DRY stands for Don’t Repeat Yourself.

At its simplest, it means:

Do not write the same thing twice.

That definition sounds simple, but the implications are far-reaching.

It is easy to interpret DRY as simply “don’t write the same code twice.” That is a critical part of it, but it is only the most visible form of duplication.

As the authors explain:

DRY is about the duplication of knowledge, of intent. It’s about expressing the same thing in two different places, possibly in two totally different ways.

When the same knowledge exists in multiple places, it becomes necessary to update it in all uses. And when you are making those updates, it's really easy to miss one of them. That leads to inconsistent behavior, mismatched expectations, and subtle bugs.

The Litmus Test

A simple question helps identify DRY violations:

When something changes, do I have to update it in more than one place?

If the answer is yes, you likely have duplication.

Here are a few examples:

Do you need to change code or documentation in multiple places when...

  • The data model changes
  • A query changes
  • Business rules change
  • Validation rules change

The more places that must change together, the more work it is to change it, and the higher the risk of missing something critical.

Duplication in Code

As mentioned, duplication in code is the most obvious example. But even when being intentional, it is really easy to introduce duplication into your code.

Example 1: Duplicate Presentation Logic

❌ Don’t: Repeat UI Formatting Logic

In this first example, we have what appears to be a straightforward display of data from a model.

class EmployeeList extends StatelessWidget {
final List<Employee> employees;

const EmployeeList({super.key, required this.employees});


Widget build(BuildContext context) {
return ListView(
children: employees.map((e) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("First Name: ${e.firstName}"),
Text("Last Name: ${e.lastName}"),
Text("Start Date: ${e.startDate.toString()}"),
Text("Years Worked: ${e.yearsWorked.toString()}"),
],
),
);
}).toList(),
);
}
}

This seems fairly standard. But if you look closely, you'll see that the "Label: value" pattern is repeated four times. What happens when you decide to update the format?

✅ Do: Extract Reusable Presentation Logic

A better approach is to use a shared formatter.

class EmployeeList extends StatelessWidget {
final List<Employee> employees;

const EmployeeList({super.key, required this.employees});


Widget build(BuildContext context) {
return ListView(
children: employees.map((e) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_labeledValue("First Name", e.firstName),
_labeledValue("Last Name", e.lastName),
_labeledValue("Start Date", e.startDate.toString()),
_labeledValue("Years Worked", e.yearsWorked.toString()),
],
),
);
}).toList(),
);
}

Widget _labeledValue(String label, String value) {
return Text("$label: $value");
}
}

Now that formatting lives in one place, updates are extremely easy. Want to trim all text values? It's now just a one-line change.

  Widget _labeledValue(String label, String value) {
return Text("${label.trim()}: ${value.trim()}");
}

Example 2: Duplicate logic in comments

In The Pragmatic Programmer, the authors make a bold statement:

Somehow the myth was born that you should comment all your functions.

This seems to go against elementary principles that are taught to new programmers from early on. But their statement has a lot of merit when it comes to avoiding duplication.

❌ Don’t: Repeat Logic in Comments

It's incredibly easy to document the logic for a method in its comments:

/**
* Adds sales tax to the given amount.
* Accepts amount and percent.
* If no percent is provided, defaults to 7%.
*/
function addSalesTax(amount: number, percent?: number): number {
const taxRate = percent ?? 7;
const taxAmount = amount * (taxRate / 100);
return amount + taxAmount;
}

The comment tells us exactly what the code does. It seems like a good thing to do, but it is actually harmful because the logic is duplicated.

If the behavior is updated, for example by changing the default tax rate to 10%, you need to make a change in two places.

✅ Do: Use Comments to Clarify Intent

In many cases, when the method signature is very clear, the comment may not be needed at all. In cases where you decide to include comments, it's important to limit comments to clarifying intent, not detailing exact behavior.

/**
* Add sales tax to the given amount.
*/
function addSalesTax(amount: number, percent: number = 7): number {
return amount + amount * (percent / 100);
}
Side Note

By moving the default value of 7% into the method signature, we can now easily see the default value in IDE tooltips whenever we hover over this function. This lets users understand the default value without needing to write it in comments.

Duplication in Data

It's also possible to have duplication in data. There are some situations where this is necessary, but it should be avoided whenever possible.

❌ Don’t: Store Derived Data

It can be really tempting to store derived fields in a data model.

class WorkSession {
final DateTime startTime;
final DateTime endTime;
final double totalHours;
}

This stores both:

  • Raw inputs (startTime, endTime)
  • Derived knowledge (totalHours)

The danger here is that if the logic for calculating duration changes, you must update every place that calculates or sets totalHours. All existing data stored in databases may be incorrect.

✅ Do: Derive From a Single Source

Instead, we should use derived functions for calculating values.

class WorkSession {
final DateTime startTime;
final DateTime endTime;

WorkSession({
required this.startTime,
required this.endTime,
});

double get totalHours =>
endTime.difference(startTime).inMinutes / 60.0;
}

Now the duration logic is moved out of stored data and into computed logic. We can now change how totalHours is calculated without impacting the stored data.

Performance Tradeoffs in Data Duplication

There are cases where storing derived data is justified:

  • Large datasets
  • Expensive calculations
  • High query volume

In those cases:

  • Prefer caching at the domain or client level when possible.
  • Compute once on load or change.
  • Persist derived values in the database only when necessary.

This is a tradeoff decision that must be made carefully by the developer.

Useful vs Harmful Abstraction

Removing duplication for shared logic often means introducing an abstraction.

An abstraction pulls shared logic into one place so that knowledge lives once instead of being repeated. Done well, this reduces the number of places that must change and makes intent clearer at the call site.

But not all abstractions help. Some reduce duplication. Others add layers of indirection that make it harder to find the real source of truth. This is something that we as developers must be constantly aware of, as it's really easy to actually make our code harder to change.

Harmful Abstractions

There are several kinds of harmful abstraction. We'll look at a few common examples here.

❌ Example: Thin Pass-Through Wrapper

A common side-effect of poorly designed abstraction is unnecessary indirection that makes code harder to trace.

An abstraction makes code harder to trace when it:

  • Simply forwards a call
  • Adds no business meaning
  • Does not protect invariants
  • Does not centralize rules

Consider this example:

class Employee {
String firstName;

Employee(this.firstName);
}

class EmployeeUseCases {
static void setFirstName(Employee employee, String name) {
employee.firstName = name;
}
}

This does not centralize rules or reduce duplication. It is, in fact, a form of duplication of intent, because we add a new interface method that does the same thing as the setter in the data model. By adding a pass-through method between the caller and the model, other classes now have access to two different methods that do the exact same thing.

// ### To set the value
// we can use
employee.firstName = "John";

// or we can use
EmployeeUseCases.setFirstName(employee, "John");

// which one is better to use? It's impossible to tell without inspecting the code.

The system is more indirect, the function in the use case does not add value, and we introduce an unnecessary layer of coupling. In short, we added boilerplate without reducing duplication of logic or protecting knowledge.

Instead, we should skip the method in the use case, leaving only one available method for all other classes to use:

employee.firstName = "John";

One simple method = easy to find, and easy to change.

❌ Example: Over-Generalized Utility

Employee processEmployee(Employee employee, DateTime lastDay) {
return employee.copyWith(
status: EmploymentStatus.inactive,
workEmail: null,
supervisor: null,
lastDay: lastDay,
);
}

This abstraction does not capture domain meaning. It hides behavior behind a vague name and makes the system harder to understand.

If we do need this functionality, choose a well-defined name for the method to reduce obscurity and clarify intent:

Employee deactivateEmployee(Employee employee, DateTime lastDay) {
return employee.copyWith(
status: EmploymentStatus.inactive,
workEmail: null,
supervisor: null,
lastDay: lastDay,
);
}

A good rule of thumb is: If an abstraction does not reduce the number of places that you change when logic changes, it is likely unnecessary.

Useful Abstractions

A useful abstraction centralizes meaningful knowledge.

It reduces duplication and gives one authoritative place for a rule, policy, or invariant.

✅ Example: Encapsulating Domain Invariants in a Use Case

Here's an example of a simplified bank account model:

class BankAccount {
double balance;

BankAccount(this.balance);
}

As written, any part of the system can do something like:

account.balance -= 500;

But there is no protection. What is preventing someone from withdrawing more than is available in the account?

A better approach is to make the model immutable and centralize the rule in a use case:

class BankAccount {
final double balance;

const BankAccount(this.balance);
}

class BankAccountUseCases {
BankAccount withdrawFunds(BankAccount account, double amount) {
if (amount > account.balance) {
throw Exception("Insufficient funds");
}

return BankAccount(account.balance - amount);
}
}

Now:

  • The invariant lives in one place.
  • The model cannot be modified directly.
  • Any withdrawal must go through the rule.

If the withdrawal logic changes (fees, limits, audit rules), there is one authoritative implementation that needs to be changed.

This is a useful abstraction because it centralizes domain knowledge and provides a single place where withdrawal logic is defined and can be reused.

A Practical Test

Before introducing an abstraction, ask:

  • Does this centralize real knowledge?
  • Will it reduce future edits?
  • Does it clarify intent?

If it only adds a layer without reducing duplication, it is likely harmful.

The goal is not more structure - the goal is one clear home for each piece of knowledge.

Practical Framing Questions

As with ETC, it's important to remember that DRY is a value, not a rule. Values guide our decisions, but sometimes the situation demands that we set it aside and do something a bit unusual.

When writing or reviewing code, pause and ask:

  • Where does this knowledge or logic live?
  • If it changes, how many places must change?
  • Am I duplicating rules, behavior, or data?
  • Is this abstraction reducing duplication or just adding unhelpful layers?
  • Is this duplication essential for performance?