Reducing repetitive code is an important skill to keep technical debt in a codebase to a minimum. Knowing why, when, and how to reduce duplication is critical to growing a healthy codebase. Further understanding of this concept will make you a better engineer.
Defining repetitive code
Repetitive code is a block of code that is repeated to two or more places in your codebase. If you ever find yourself copying and pasting code elsewhere, then you’ve just committed code duplication (and thus repetitive code).
Not to say this is always bad. Sometimes it is acceptable and necessary due to project time constraints or shifting priorities when these decisions are made, you trade project technical debt for short-term objectives.
Duplicating code is often a sure sign of a developer trading off for speed in exchange for long term maintenance. It also adds technical debt that will take its toll over time.
The technical “mantra” that engineers chant when dealing with this subject is DRY (Don’t Repeat Yourself), which is opposite to WET (Write Everything Twice). While it is important always to aim to be DRY with your coding, it’s also important to say it is okay to be WET strategically. For example, when the technical debt cost is acceptably lower to expedite the work or reduce risk. It is, however, never okay to be WET without realizing the disadvantages. Being too DRY in some circumstances may hamper readability. It can even introduce more risk than the team is willing to take at the time.
Always try to aim to be DRY first and refrain from doing so as the situation is needed. When making things DRY, please do not do it at the cost of simplicity. If making something DRY adds complexity, question how you are making it DRY or leave it as it was. Adding additional “if” statements in will be adding complexity and likely mixing concerns of a function.
Why can repetitive code create technical debt
Some may argue that if you have a template with all of the execution logic it needs in one place. Then you can duplicate this template as many times as you need and customize the code from there. Following this methodology, a template will duplicate many times to support the isolation of different components because they have no dependencies.
I’ve also seen developers copy and paste a function to regress other dependencies on the original function for a small change. However, this is achievable in equally effective ways without duplicating the code. If needed to reduce risk, an API can use feature flags or versioned APIs to introduce a new function. The prior function then is deprecated to communicate to others not to use it and allow for it to be removed eventually.
Over time, engineers committing to this path will be trading short term gains for long term maintenance. This will not set up the codebase for long term success and will necessitate significant refactors in its future.
Reducing risk without copy and paste pasta
Imagine a scenario where a requirements change or the template contains a critical bug. You now have to pay the cost of updating every component from the duplicated template. Furthermore, each component is customized slightly for different purposes, so it is not a simple copy and paste fix. There is a legitimate risk of slipping in regressive bugs as part of a large change since the code has diverged. This is a very high regression territory.
Let’s look at an example. In this example, we have a duplicate function but labeled with “V2”. Meanwhile, the old function now has a deprecation warning with clear instructions about how the “V2” function needs to be used.
@deprecated use someFuncV2() instead, this function will eventually be removed
function someFunc (value1) {
...
}
/** API descriptor */
async function someFuncV2 (value1, value2) {
...
}
Isolation of concerns versus isolation of code
Some engineers may prefer to duplicate and isolate code for the short term gains and reduction of risk. You can’t break other things if you don’t touch them. This may be true in the short term, but the pain will be felt in the long term. This ultimately leads to higher engineering costs to fix the problem.
Furthermore, duplicating code indicates poor isolation of concerns. If there is a need to duplicate code from one area to another, ask yourself first: is that even a concern that needs to exist in this module? If it is a shared concern, then it can exist as an importable pure function or another module entirely.
The example below represents a component where the original design is a repeatable template. The second version shows what the function looks like after becoming DRY. It allowed a fair amount of the functionality to be moved into shared modules instead.
// Designed to be a template code that can be copied and pasted many times
// as a base core of all of our components. Observe how simply making it
// DRYer improves our readability significantly
// what this component does or how it does it is not important
function initComponent (parent, dataId) {
// init all of our base event listeners
...
// if parent is given, setup base infrastructure and events with parent
if (parent) {
...
}
// if we need data, do fetch for the data this component needs
if (dataId) {
...
}
}
// Because we decided we want to be DRY and not rely on duplicate code, we // needed to move the bulk of the code into separate modules to be shared
function initComponent (parent, dataId) {
CoreComponent.initEventListeners(this)
CoreComponent.setupParentToChild(parent, this)
DataLayer.fetchComponent(dataId).then(data => this.handleData(data))
}
Advantages of DRY code
- Encourages readability. When thinking DRY, first, you tend to isolate your concerns better and break things into functions more. These functions are reusable as the code base grows.
- Easier testing. If you write your code once and have thorough testing coverage on its behavior, others can trust its usage. Duplicated code ends up having small differences over time. Since not all blocks are the same, they need different testing routines despite initially starting as cloned code. Can you imagine not only having to fix the implementation across the code but all of the slightly different unit tests for the duplicated code to address a bug fix?
- Reducing technical debt. As the code base grows, there will be bugs and decisions that need to be made. Duplicate code will cost an absurd amount of engineering time to fix the longer it continues to happen. It will lead to an expensive refactor or require intimate knowledge of how all the implementations have built on top of the duplicated code.
- Code reuse. Over time of practicing DRY, you will find reusable modules of precise functions. These modular functions will reduce the overall lines of code throughout. This can be especially important in client-side code where large binaries decrease performance. It will also encourage others to reuse already solved problems that can save engineering time in the long run.
Disadvantages of DRY code
- More complexity. There is a balance when refactoring similar blocks of code out to more generic shared functions. Whether that is two times, three times, or more is up to you and your team. Sometimes it is good to keep things simple at first to promote readability. This is at a decision that is made instead of a failure to acknowledge actions that will accrue technical debt.
Example of making something DRY
Let’s give a new scenario that you were given a task to add a new component to a client front end. You observe the UI quickly you need to make is similar in pattern to other components. When adding the additional feature, you decide to refactor the repetitive code out. The example is done in Javascript.
// Component below is conceptual and doesn't use any particular framework; // its more of pseudo-code for portrayal.
const Component1 = {
render () {
const data = await this.getData()
return ... // rendering component1 details
},
async getData () {
const url = ...
try {
const data = await fetch(url)
if (data && data.ok) {
return data.json()
}
} catch (error) {
return .. // API specific error handling
}
}
}
const Component2 = {
render () {
const data = await this.getData()
return ... // rendering component2 details
},
async getData () {
const url = ...
try {
const data = await fetch(url)
if (data && data.ok) {
return data.json()
}
} catch (error) {
return .. // API specific error handling
}
}
}
// Now let's add Component3 and make this thing more DRY!
const fetchData = (url, errorHandlerFunc) => {
try {
const data = await fetch(url)
if (data && data.ok) {
return data.json()
}
} catch (error) {
if (errorHandlerFunc) return errorHandlerFunc(error)
else throw error
}
}
const Component1 = {
render () {
const url = ...
const data = await fetchData(
url,
(error) => this.fetchErrorHandler(error)
)
return ... // rendering component1 details
},
fetchErrorHandler (error) { ... }
}
const Component2 = {
render () {
const url = ...
const data = await fetchData(
url,
(error) => this.fetchErrorHandler(error)
)
return ... // rendering component1 details
},
fetchErrorHandler (error) { ... }
}
const Component3 = {
render () {
const url = ...
const data = await fetchData(
url,
(error) => this.fetchErrorHandler(error)
)
return ... // rendering component1 details
},
fetchErrorHandler (error) { ... }
}
Notice that making the repetitive code into a re-usable function cleaned up quite a bit. Further, the function still allowed our fetch to be relatively generic and be comparable by allowing an input function for detailed error handling. This keeps detailed business logic segregated out of the generic utility function to be shared.
Guidelines to help reduce repetitive code
- Progressively reduce repetitive code. If you see some similar code written twice, consider pulling it out into a shared function. If the code is already systematically duplicated throughout the code, then consider working with the team to clean it up and get time to dedicate to the task.
- Isolate different concerns. When reducing duplicate code, make sure you break the concerns apart. Sometimes long execution blocks that are doing many things can be split into several smaller execution blocks. Further, divide the code into several smaller functions that will improve readability, share-ability, and maintainability. This will reduce the need for duplicate code.
- Do not add complexity when trying to be DRY. Favor keeping things simple over making things too DRY. These cases should be rare; aim to be DRY first and refrain as needed.
- Deprecate and add new APIs as needed. Making it clear there will be a plan to remove the old duplicated code in time.
For more thoughts on improving code design for yourself or your team, refer to this article to understand the importance of code design.