How To Not Make Things Worse

How To Not Make Things Worse

Balancing speed, simplicity, and future-proofing in feature development is a subtle art. In an ideal world, we’d ship only pristine, perfectly factored code. However, technology and software aren’t ends in themselves; they exist in service of the business goals or purpose for which the code is being written. Developers sometimes forget this, which can lead to self-indulgence in the pursuit of perfectionism.

Balance in all things

Churning out poorly designed code is like laying down a tarpit to bog down our future selves. But equally, we should not prioritise our own satisfaction or aesthetic preferences over the business purpose our code serves. Maintaining this balance as a codebase evolves alongside the corresponding product roadmap is one of the most challenging and important tasks faced by developers.

So, let’s take a look at some ways to approach this!

Tech Debt Should Be A Choice

Taking on technical debt should be a conscious strategic decision, much like taking on financial debt, with the understanding that it will need to be repaid or written off eventually. If a system or feature is only being implemented as a stop-gap measure, then it can make sense to take some shortcuts in its implementation, knowing that it will be “written off” when it is decommissioned or replaced.

The risk here, of course, is that something intended to be temporary often winds up being nothing of the sort, and our debt is allowed to compound. So, even when taking a calculated risk with a shortcut like this, there are certain things it pays not to compromise on.

What Not To Skimp On

When you need to ship something that isn’t perfect, or when you inherit code with particularly troublesome components, do your best to isolate the nastiness. Avoid letting these imperfections pervade the entire codebase – taking a shortcut which results in spaghetti-code is rarely a good tradeoff.

Designing a robust domain/data model is also particularly important. Fixing this later can be incredibly costly, because so many assumptions in your other systems – or even worse, assumptions made by your users – tend to build on these decisions. Therefore, ensuring these models are reasonable from the start is vital.

Skimping on certain non-functional requirements such as performance, scalability, or even aspects of code quality, can be easier to justify. If the system is the right overall “shape” in terms of its interface / domain model, and these issues are fairly localised, then reworking things over time to address these issues as and when it becomes necessary can be genuinely viable.

Understand The Context

In order to make these decisions properly, it’s crucial to understand what the business actually needs – the underlying motivation for feature requests, and the timelines involved. Sometimes its possible to satisfy the really essential requirements in a way which pares down the amount of new functionality we need to implement. By doing this we can minimise compromises on quality, keeping both the devs and the non-technical folks happy.

A birds-eye view helps us to navigate these twisting paths

For example, a client recently requested a bunch of new features with some urgency. It turned out that they had a lucrative contract lined up with a prospective customer, but the customer’s requirements for how billing and reporting were to be handled could not be met with the current system. It seemed like a mad rush to build out this functionality might be necessary…

Digging deeper though, this contract also included an initial pilot phase, where things would be operating at a much smaller volume. Ultimately we were able to implement a stripped-down set of functionality to support this pilot, bridging the gaps behind the scenes with some manual admin work. The manual work required to keep things running would only be sustainable during this low-volume pilot, but this bought us enough development time to lay down track ahead of ourselves to support the full volume as the contract ramped up, all the while doing things the “right way” as far as the codebase was concerned.

This approach also minimised our up-front investment. We wouldn’t have to implement all of these features if it looked like the pilot would not convert into an ongoing contract.

It often pays to ask questions about the underlying motivation for changes, and look for strategic opportunities to sequence your feature development in step with your operational roadmap in this way.

Tidying Up

Tech debt cleanup tasks often linger on backlogs indefinitely because their value is hard to articulate, and so they are rarely a high enough priority to bubble up to then top of the list. One pragmatic approach is to look for opportunities to piggyback refactoring and architectural improvements onto new features. Embrace the Boy Scouts’ principle of “leave the campground in a better state than you found it” whenever you need to touch a piece of code.

A robust test suite is invaluable in enabling this. It provides confidence that changes haven’t broken any important user-facing features or flows, enabling incremental improvements without fear of unintended consequences.

You’ll want to keep track of the vestigial / legacy components of your system explicitly, and actively look for opportunities to revisit them. Once you end up with a substantial enough feature on the roadmap which requires touching a neglected part of the code, you’ll be better able to justify the time and effort of a major tidy-up. This approach ensures that major refactoring efforts are tied to new feature launches, which is more appealing to non-technical stakeholders.

Overdoing DRY

The principle of “Don’t Repeat Yourself” (DRY) is often overstated. Repetition can be ok, particularly if it allows the code to be more simple, straightforward, and understandable. Factoring out and abstracting common functionality has a cost, which is often overlooked: it can fragment and complicate the codebase, spreading it across a vast landscape of functions and classes, ultimately making it harder to navigate and comprehend.

Over-abstraction in pursuit of DRY can lead to its own kind of fractal spaghetti nightmare!

The real benefit of DRY is not eliminating repeated code per-se. It is ensuring that when a business or domain rule changes, the number of sections of code which need to be changed to reflect this is minimised. This is the kind of duplication which is truly toxic, as it makes the code rigid (requiring multiple edits to implement a change in business logic) and also error-prone (due to the possibility of overlooking an area which needs to be updated). So, we should value locality over simple minimisation of repetition.

Really, code should not be as abstracted as possible, but rather written at a level of abstraction which is appropriate and understandable for somebody working on a particular section of the code. If you have a process which relates to processing a customer order, for example, there is huge value in there actually being a function defined somewhere in your codebase which reads like a straight-line, direct translation of the business logic for that process.

Using Your Judgement

Premature abstraction can surely be a bad thing, but it’s equally possible to paint yourself into a corner with under-abstraction. Over time, developers accumulate a degree of wisdom and judgement that allows them to shape code for future requirements, even if those abstractions aren’t immediately necessary. Often, it pays to decide to go just one level of abstraction higher than the immediate feature you’re implementing requires.

For example, we recently had a new requirement to identify users who were under the umbrella of a particular government-sponsored program. I could have simply added an ‘is_in_program_X’ field to each user and called it a day. After all, that’s the most simple and direct way to satisfy the immediate requirement. But I opted instead to implement a more flexible system, allowing programs to be defined, and users to be enrolled in these programs.

The business is actively seeking out similar partnerships with other government agencies, and so it’s quite reasonable to assume that the requirements will be expanded in this direction in the future, and more programs will be introduced. And this way, the changes required to add a second or third program will be very localised to one small part of the backend code, as other parts of the system (UI etc) already expect a user to potentially be associated with multiple programs. The development effort on those components was not significantly different than it would have been with the single ‘is_in_program_X’ field.

Sure, there is only one ‘program’ currently, so technically this is over-abstracted. But we know that a single field is almost certainly the wrong way to model this in the longer term, given what we understand about the domain. So we’d be shooting ourselves in the foot to go with for the sake of dogged adherence to the principle of avoiding premature abstraction.

Building for N

I previously worked for a company who landed a huge contract to allow another company in their sector to license and use a “white-label” version of their entire software solution. This was a lucrative deal, but because the need hadn’t been anticipated, it ended up being a very costly project. It essentially put all feature development on hold for a full year, while the devs took inventory of – and painstakingly untangled – all of the baked-in assumptions throughout the tech stack.

Had this been considered from the get-go, it could have been handled much more efficiently. Very often, adopting a model which allows for N things, rather than 1 thing, is the right way to go if you want to avoid painting yourself into a very awkward corner. This experience is why, whenever building out the tech for a new startup, I always anticipate the need for some kind of multi-tenancy or white-labelling.

Along similar lines, it often pays to build in support for localisation with the UI from day one – it’s a little more effort up-front, but a lifesaver if the requirement to support multiple languages ever crops up!

Allow Patterns To Crystallise

Sometimes your assumptions about future needs are correct, especially if you’re working on a familiar type of project, or have a good understanding of the product’s long-term roadmap. In such cases, allow your experience and judgement to guide you!

However, we don’t always know what the correct patterns will be. Often our assumptions about this are wrong. If you’re not confident, then fall back to writing code in the simplest, most straightforward way possible. Over time, as patterns of repetition and common functionality become evident, you can gradually refactor and abstract the code. This way, the abstractions you build are directly in service of the code / functionality you actually intended to write, and not the other way around.

Observe and embrace the structures which form naturally

This approach is more aligned with WET (“Write Everything Twice”) or “Compression-oriented programming”, which I believe are more pragmatic than strictly adhering to the DRY maxim.

Wrapping It Up

Balancing speed, simplicity, and future-proofing in software development requires careful consideration and judgement. By making deliberate choices about technical debt, isolating problematic code, engaging in opportunistic refactoring, and using appropriate levels of abstraction, developers can avoid making things worse.

Hopefully they can even steer the codebase towards an increased level of quality over time. And by understanding the business context, perhaps this can be done while making incremental steps towards the broader goals which the code serves!

At Devbeat we specialize in bespoke software development, solving deep technical problems for clients in all sectors. Find out more at devbeat.co.uk.