3

I work on a code base which was started by a small team. Over time, more features were added, and the team has grown significantly. Everyone has added functionality here and there. There is a rather central struct which is used to keep track of intermediate results as it is processed through the system. It started out with just a handful of fields, but now it has a mind-boggling number of extra fields for special cases in added features. The fields are not encapsulated at this point, various functions change the state of that object.

The sheer size of this struct prevents me from reason about it and the associated functions completely. There are lots of things where function f() sets fields a to some value, and function g() takes field a and sets field b if flag c is enabled. I can understand these things locally, but I lack a detailed big-picture view, which I would need for this. So when I need to make changes, I try to find some spot to put in another thing without having to change anything else. This only leads to an incremental build-up of even more complexity.

From what I read [1] the code should rather be in a state where there is less coupling and more cohesion, clearly defined components, encapsulated fields and enforced invariants. My problem is that I don't see how one could get there. The code has grown into a complexity that I cannot fully understand. And I have the impression that my coworkers feel the same. I fear that nobody really understands this complexity, but that the current state was a group effort of local changes. Due to this nature, the code feels rather brittle and cannot be tested by the construction. So whatever one changes might break something without realizing it at first.

This qualifies for Feather's definition of legacy code [2]: code without tests. And one cannot simply introduce tests into a code base which has evolved from a monolithic prototype. Rather one has to break out little pieces, make them testable and continue doing that. This will be a significant effort, and it might still break some things as one goes. But I feel that I don't really know where to start, because there is just so much coupling and so little structure. Also it is still possible to add more features by tacking on even more complexity and ignoring everything else. Yet I know that this problem will only get worse with time going forward.

I find myself in the situation where I think that reducing the complexity is necessary to stay productive in the future. But there is just so much complexity accumulated already, that I don't know where to start. Also there are different perceptions to the urgency of this problem. When I add new features I try to build them of small pieces and integrate them into the main code, but often I find myself bogged down by the surrounding complexity. For every single new feature I can just tack it on and be done within a few hours, or start to refactor everything which will take months. So the decision seems to be made to just tack it on for now.

Whenever such a decision is made, I voice my concerns but then follow the decision. Is there anything more I can do than just point out the looming problems and wait for the team as a whole to decide on an effort to reduce complexity?


  1. Martin, R. C. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. (2017).
  2. Feathers, M. Working Effectively with Legacy Code. (2004).
Martin Ueding
  • 933
  • 1
  • 7
  • 14

1 Answers1

2

Focus on Behavioural Tests

To emphasise another quote from Feathers' book:

Code without tests is bad code. It doesn’t matter how well written it is; it doesn’t matter how pretty or object-oriented or well-encapsulated it is. With tests, we can change the behavior of our code quickly and verifiably. Without them, we really don’t know if our code is getting better or worse.

The reason this is important is that the book and indeed Feathers' overall approach is not really about reducing complexity or improving structure, and it's not even necessarily about decoupling or refactoring all your code into nice, clean, simple objects, it's ultimately about testing.

Good vs Bad Code

To look at Feathers' advice another way - the presence (and quality) of tests around some code is a key metric relating directly to the quality of the code itself. While it clearly does not follow that fully-tested code is great code, the fact that code has (useful) tests means that it is objectively a whole lot better than code with no tests, on the basis that it has protection against future breaking changes, more confidence that the behaviour works as-intended, and built-in test cases for future developers to understand why the code exists.

Business Value vs. Code Structure

While this might not be satisfying from a developer perspective, it's important not to treat improving the structure of the code or reducing code complexity as the end-goal, prefer to view it as a side-effect of a need to build tests instead.

While such goals about "clean" code might be appetising as a developer, an already-existing spaghetti mess deployed into production which delivers for your users/stakeholders has equal business value as the same behaviour built under Uncle-Bob-SOLID-Clean-Architecture craftmanship.

The problem is that once the code has already been released into production, additional craftmanship and refactoring does not generate any measurable or tangible business value; it costs money up-front without any way to measure a potential return on investment.

On the other hand, behavioural tests have measurable value because they correlate directly to the behaviour, requirements, functionality and guarantees that the business cares about; so the more important certain behaviours are to your stakeholders, the greater the risk and costs in that behaviour getting broken, and therefore the greater immediate, measurable value in building tests for it.

With that in mind, tests around the most important code are often a "mission critical" objective because they correlate directly to risk and the product/service that your stakeholders are paying for; obviously that also means they need to be the right kinds of tests around the right behaviours; prioritising the stuff that your stakeholders really care about.

Where to Start

With the focus on business value, the question "What should I test?" might be better phrased as "Where should I prioritise my time?".

You might pick up a new ticket in an area which is obviously important/critical to the software, in which case it's a no-brainer to try investing time building tests as part of that ticket if possible.

Alternatively you might consider a "tech debt" backlog based on behaviour which is frequently getting all the wrong kind of attention from stakeholders because users are complaining and the business is spending a lot of time and money fixing it -- this needs to be understood through the prism of time/money which has already been spent and is likely to be spent on maintenance (e.g. check your incident history, bug history, how much has some "bad" bit of code already cost?).

Priorities

Finding where to start can be helped by understanding why the code exists and who it exists for; ultimately the people who are going to complain when the system breaks are those users and stakeholders, and the business will have to decide when/how to fix it, how much time/money to invest, etc. A good test should to serve their needs and be worthwhile, which means considering questions about what behaviour they expect/require from the system.

Consider some questions:

  • What problems does the behaviour solve for them?
  • What's the expected behaviour from their point of view
  • How valuable is this behaviour from a business point of view?
  • Do we understand the use cases, and are we able to usefully represent those as test data and repeatable assertions?
  • Do we know where the "seams" (i.e. boundaries) are which allow behaviour isolation in a test?
  • Can I build some good, useful tests without changing any code?
  • If I can't easily build good unit tests, can I build some good, useful integration tests which serve the same goal?

Remember none of these would necessarily lead you to changing or refactoring even a single line of code; your code might be a spaghetti mess, but if you can find a reasonably effective way of getting good tests around the behaviour inside that spaghetti then your job is done and your code quality has improved. Refactoring tends to be the tool to grab when the behaviour is too hard to test, or the test would be so brittle that it'd just add more tech debt.

Maybe someday someone will return to the spaghetti and unpick a few threads, but if nobody ever does that, your tests have already delivered value by adding protection against breaking some behaviour which is important to your users, also enshrining knowledge of expected behaviour for future developers if they want to understand why the code exists and what your users expect it to do.

Ben Cottrell
  • 11,739