May 27, 2025 · 4 min read · Career Growth and Technical Leadership
Choosing Between Duplication and a Shared Abstraction
A solution can look better in isolation and still be harder for the team to maintain. The people who will change and support it are part of the decision too.
Two workflows in an enterprise system looked similar enough to share a generic implementation. The abstraction looked good. It removed duplication, created a clean class hierarchy and made the first pull request smaller. I usually don’t like duplicated code, so my first choice was the abstraction. It also required engineers to understand several extension points before changing one business rule. That was the trade-off we had to evaluate on a project at Softcraft.
Following the Abstraction
The two workflows shared validation, persistence, and part of their processing sequence. They differed in a few domain rules that were expected to keep evolving. We could put the common behavior in a reusable base and add extension points for the differences. Technically, it worked. But we also tried to imagine an engineer changing one business rule six months later. The cost appeared when we walked through an ordinary future change. To understand one workflow, an engineer would need to move between the base implementation, configuration, and several overrides. A rule that belonged to one business process would be spread across code designed to serve both. We’d save lines now and make context harder to find later.
We built a small version of the shared approach before deciding. The common sequence was easy to extract, but every difference required another option or hook. A new engineer reading the code would need to understand which default ran, which workflow replaced it and which parts of the base state were safe to change. The abstraction was correct and tested. It was also asking the reader to understand both workflows before modifying either one.
Who Would Maintain It?
We sometimes discuss maintainability as if it only depends on the code. In practice, a team maintains the code. People join, move between projects, respond to incidents, and make changes without the person who designed the original abstraction beside them. That doesn’t mean every implementation must be basic. Teams should learn, and difficult problems sometimes need difficult designs. But the complexity needs to solve a problem that justifies the extra context every engineer will need later. In this case, the shared behavior was smaller than it first appeared. The differences were the parts most likely to change.
The team already had several abstractions built around configuration and inheritance. They weren’t all bad, but production fixes in those areas usually took longer because the effective behavior was spread across files. That history influenced the decision. In another codebase with different conventions or more stable workflows, we might have accepted the shared implementation.
We also separated duplication of syntax from duplication of business meaning. A few validation calls looked the same, but the reason for each validation belonged to a different workflow and could change independently. A small database utility had the same meaning in both places and was safe to share. Extracting only that part gave us reuse without forcing the business rules into the same lifecycle.
Keeping the Workflows Separate
We kept the workflows separate and extracted only the parts that already had the same stable meaning in both. The result had more lines and looked a little untidy at first, which made me uncomfortable because I normally try to remove duplication. But each business process could be read from beginning to end, and changing one rule didn’t require checking a base class, configuration and several hooks. If the workflows became more similar later, we could extract more with better information. On that project they continued changing in different directions, so the repeated lines ended up being easier for us than the cleaner abstraction we almost introduced.
The next changes confirmed the difference sooner than we expected. One workflow added an approval step while the other changed its validation order. With the separate implementations, each change stayed inside the process it belonged to. The repeated code was still visible, and we reviewed it occasionally, but it didn’t force unrelated rules through a shared set of extension points.
I still extract behavior when its meaning and change pattern are shared. In this case, the workflows kept changing in different directions, so waiting gave us better information than the first implementation did.