Should We Always Follow the DRY Principle?

Software Architecture

When you first started learning how to write code, or more specifically, learning the theory behind writing good code, it’s likely that one of the first things you were taught is some interpretation of the “DRY” principle. DRY is a powerful concept, where even a beginner can easily identify violations of the fundamental rule: Do Not Repeat Yourself.

The intentions behind the principle are sound. We don’t want to have to write the same code twice, nor do we want to have make modifications in more than one place when our system logic changes. However, strict adherence to this rule can occasionally manifest as developers scouring their entire codebase, on a relentless pursuit to extract a new method every time they see a small collection of lines that look even vaguely similar. Should DRY truly be so axiomatic? Are we possibly missing the real motivations of the principle?

This article will demonstrate some different applications of the DRY principle, where we’ll attempt to rediscover the underlying reasons behind why DRY is so effective, and why eliminating “duplication” might not always be the right choice.

A Basic Example of Applying DRY

Let’s imagine we have a Typescript application. As we’re building the application, we find that we need to take some user-supplied text, and convert it to “Title Case”. That is, taking a space-separated sentence and converting the first letter of each word to upper-case. For example: “hello world” -> “Hello World”. We quickly discover a technique for achieving the desired result:

// index.ts
const userSuppliedText = getTextFromInput(); // "hello world"

const userSuppliedTextInTitleCase = userSuppliedText.replace(
  /\w\S*/g,
  (word) => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase(),
);

console.log(userSuppliedTextInTitleCase); // "Hello World"

While we don’t need to understand specifically how this works, we can see a few different concepts have gone in to building this solution. We’ve combined 5 different instance methods from Javascript’s String object, along with a relatively cryptic regular expression (/\w\S*/g).

As we continue to develop the application, we realise that we’ll need to perform this “title casing” behaviour in several different places across the codebase. Naturally, we don’t want to repeat the code above multiple times, so we decide to extract the functionality to a standalone function:

// string-utilities.ts
export const toTitleCase = (text: string) =>
  text.replace(
    /\w\S*/g,
    (word) => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase(),
  );
// index.ts
import { toTitleCase } from 'string-utilities.ts';

const userSuppliedText = getTextFromInput(); // "hello world"

const userSuppliedTextInTitleCase = toTitleCase(userSuppliedText);

console.log(userSuppliedTextInTitleCase); // "Hello World"

We can see that we now have access to this function anywhere in the application, and we can avoid repeating the internal logic for converting to title case. That’s all very straightforward so far, but why might this be referred to as only a “basic” application of DRY?

While we have certainly eliminated duplication, it might be fair to suggest that the benefits of this were only a matter of convenience. It’s probably safe to assume that the approach we use to convert English text to title case is not going to change any time soon (that is, we aren’t going to start capitalising the second letter of each word instead of the first). Consequently, it’s unlikely that this function would ever need to change. If you never extracted a method, and continued to manually copy the logic each time you needed it, you might not notice a significant problem in the long-term, as the function is extremely stable.

Of course, that’s not to suggest that extracting the toTitleCase method here was the wrong to do. It was, however, to suggest that, under different circumstances, application of DRY can produce far more important benefits. We’ll look at one of those examples next.

A Better Example of Applying DRY

Our application has now evolved to the point of handling payments. We’re told that, before taking each payment, we need to apply a shipping fee of $10, but only when the total amount spent was under $50. Our code will look something like this:

// index.ts
const basketTotal = getBasketTotalAmount(); // We'll assume the amount is measured in whole dollar values

const basketTotalWithShippingFee =
  basketTotal >= 50 ? basketTotal : basketTotal + 10;

Once again, we find we need to calculate this shipping fee in more than one place across the application. We decide to extract the behaviour to a function:

// money-utilities.ts
const SHIPPING_FEE_THRESHOLD = 50;
const SHIPPING_FEE_AMOUNT = 10;

export const calculateBasketTotalWithShippingFee = (basketTotal: number) =>
  basketTotal >= SHIPPING_FEE_THRESHOLD
    ? basketTotal
    : basketTotal + SHIPPING_FEE_AMOUNT;
// index.ts
const basketTotal = getBasketTotalAmount();

const basketTotalWithShippingFee =
  calculateBasketTotalWithShippingFee(basketTotal);

As before, we can now access this function from anywhere in the application (and we’ve removed a few magic numbers while we were at it). We can see that this function is even simpler than the title case example. It’s effectively one ternary statement with some very basic arithmetic. So, why is this a “better” application of DRY?

Well, let’s consider, is the extraction of this behaviour still just a “convenience”? Definitely not. Despite the code itself not even being particularly complicated, the logic it represents is extremely important. The calculateBasketTotalWithShippingFee function is decidedly unstable. How the shipping fee calculation is performed is highly subject to change. It’s not only the shipping fee amount/threshold values that might change, it could be how we compare the basket total against the threshold, or if we even have a threshold at all!

This is the real importance of applying DRY. We still have the convenience factor, but we’ve eliminated the logical duplication contained within behaviour that was likely to change, reducing a significant degree of risk.

We’ve now seen that DRY can have different benefits in different scenarios. Finally, we’ll investigate whether there are cases where eliminating duplication might not produce a benefit at all.

A Bad Example of Applying DRY

In our final example we’ll imagine we have two entities: a User and a Product. We want to represent both of these as a class:

// User.ts
class User {
  private id: int;
  private name: string;
  private phoneNumber: string;
  private createdAt: Date;
}
// Product.ts
class Product {
  private id: int;
  private name: string;
  private tags: string[];
  private createdAt: Date;
}

As we look at these classes, we realise that they share 3 of their 4 properties: id, name and createdAt. Our DRY instincts kick in, and we decide it would be prudent to create a NamedEntity super class:

// NamedEntity.ts
export class NamedEntity {
  private id: int;
  private name: string;
  private createdAt: Date;
}
// User.ts
import { NamedEntity } from 'NamedEntity';

class User extends NamedEntity {
  private phoneNumber: string;
}
// Product.ts
import { NamedEntity } from 'NamedEntity';

class Product extends NamedEntity {
  private tags: string[];
}

Let’s step back and evaluate what we’ve done. Have we reduced “duplication”? Well, technically speaking, sure - we’ve only had to mention id, name and createdAt once.

However, consider the reasons why your extracted logic might need to change. Perhaps one day you decide to represent the name of your user separately, replacing name with firstName and lastName. We now need to make that change within NamedEntity, but that doesn’t make any sense in the context of a Product. We realise we need to rollback our changes, and represent User and Product separately once again.

In this case, we fell in to the trap of trying to eliminate structural duplication: code that looks anatomically the same, but any perceived connection is purely coincidental. Users and Products are completely isolated concepts, and they have good reason to diverge independently of each other. By creating the NamedEntity class, we coupled our entities together with no advantage beyond a superficial benefit of reducing the number of lines we had to write, which on its own is a meaningless metric.

Conclusion

When looking to apply the DRY principle, try to look beyond whether code simply “looks” the same. Consider whether the logic contained within will change together, so we reduce risk and improve the long-term maintainability of the system by knowing that we can easily and safely make a change only within one place.

Eliminating duplication is not always the right choice. It can result in tight coupling between concepts that aren’t truly related. Your goal when using DRY shouldn’t be to write the fewest number of lines possible, it should be to eliminate logical duplication, most importantly within behaviour that is subject to change.

Bonus: Using Interfaces

In a somewhat related vein, it’s worth discussing how using interfaces in the wrong context can increase coupling without any substantial benefit. Using interfaces to force classes to implement the same set of methods/fields is fine but only when you plan to use those classes in a polymorphic context. That is, where the specific instance of a class may be swapped out with another that implements the same interface.

Consider the standard “Repository” interface pattern:

// UserRepository.ts
export interface UserRepository {
  getById: (id: int);
}
// UserDatabaseRepository.ts
class UserDatabaseRepository implements UserRepository {
  public getById(id: int) {
    ...
  }
}
// UserCacheRepository.ts
class UserCacheRepository implements UserRepository {
  public getById(id: int) {
    ...
  }
}
// UserService.ts
class UserService {
  private userRepository: UserRepository;

  public constructor(userRepository: UserRepository) {
    this.userRepository = userRepository;
  }

  public index(id: int) {
    const user = this.userRepository.getById(id);
    ...
  }
}

This is a beneficial usage of an interface. Our UserService needn’t know which specific data-access medium is being used (cache or database), and we are free to inject whichever version suits best.

On the other hand, let’s consider our User and Product example from before, and imagine they both need a method to return their name in title case. Should we create and implement an interface that contains a getTitleCaseName method?

If nothing else, it’s highly unlikely that this method would ever be used in a polymorphic context, and it’s almost certainly just a coincidence that both classes need the same behaviour. At this point, all we’ve really achieved is forcing both classes to use the same method name, which alone is of no practical use. On the explicit downside, we’ve managed to couple our classes together, creating problems for ourselves if the method signature of one of the classes needs to change (for example, User requiring an option to switch the order of the first and last name).

You might argue that, at the very least, the interface prevents you from “forgetting” to implement the method. However, since the method isn’t being used polymorphically in this scenario, you will presumably need to introduce the method call at the same time anyway, where it would be unusual to forget to implement the method itself!