Skip to content
Home>All Insights>Let’s talk about invisible tech debt! Vol. 2: Surprise dependencies (and key steps to tackle them)

Let’s talk about invisible tech debt! Vol. 2: Surprise dependencies (and key steps to tackle them)

This time I’m talking about “surprise dependencies”: places where a change to the code in one place requires an unexpected or hard-to-trace change somewhere else.

a picture of a surprised child

Surprise dependencies are invisible when planning changes

Code changes. A lot. And large parts of those changes aren’t planned in minute detail. Whether it’s a customer feature with a High Level Design, a refactor to simplify an overly complex class, or a spontaneous drive-by cleanup, there’s never a plan that lists out every file that’s going to be changed. A workflow like that would be wildly over-engineered and add far too much overhead.

As a result, it’s extremely common for a developer to make some (or all) of a code change “on the fly”, using a workflow that’s best summarised as follows:

  1. See some piece of code that should (or perhaps merely could) change
  2. Briefly check the impact and implications of that change, and estimate the level of effort involved
  3. Decide whether or not to make a change
  4. (If yes) Make the change wherever that piece of code is defined
  5. Progressively extend that change into all of the usages and other affected areas

Both steps 2 and 5 depend upon being able to identify all the places that are impacted by a change in one location – that is, finding everything coupled to the code you’re proposing to change[1].

Some couplings are easy to predict – perhaps through sufficient familiarity with the codebase, or because the dependencies are located just a few lines away. But any time there is a coupling that the developer can’t easily see, that risks them starting a refactor that they later regret (because it’s taking much longer to do step 5 than they predicted in step 2) or risks that they’ll believe they have completed the work and later find out that they missed an impacted location[2] (or both!). These are the “surprise dependencies”.

Between them, the IDE’s automated tools and the compiler provide an expectation that “find all the related code” is a fundamentally quick and safe process involving a few moments of clicking in those tools, and developers can make sound decisions on that basis. Surprise dependencies break that expectation, by coupling code in a way that is invisible to the IDE and compiler.

The dev might still catch dependencies in the automated test suite, and failing that, manual end-to-end testing. But the earlier they spot couplings, the more reliable their progress and predictions will be. Conversely, if your surprise dependencies regularly only reveal themselves during testing, then your developers will experience them as low-level invisible tech debt, with all its attendant problems.

Now that I’ve summarised the concept and what we’re trying to avoid, here are a few concrete examples of tools and patterns to steer clear of, and some techniques to mitigate the pain where it’s not avoidable. If you’re less interested in the gory details, skip ahead to the final section where I have some notes for how you can help your teams identify these problems.

Avoid implicit mapping tools as much as possible

In C#, this is Automapper and its ilk. In Python, it’s ObjectMapper. In Java, it’s dOOv, Jackson and others. All of these tools exist to provide a way to convert between objects that look very similar; normally from a Data Object into a Domain Object or a Model Object, and vice versa.

These tools can save some time when first setting up a system, but they also leave behind a long-term time sink. There are a variety of other reasons that implicit mapping tools can be a bad idea (performance, hiding business logic in unexpected places, low discoverability), but for this article I’ll only be discussing their impact on refactoring.

When the mapping between similar-looking objects is explicit, this isn’t too bad – the compiler/IDE can see the reference to the classes and properties in the map. The problem comes when you start to use the “implicit” mapping functionality that all of these libraries offer – the ability for properties with the same name on both objects to be automatically mapped to each other.

Using implicit mapping tools saves you from writing a bunch of repetitive explicit mappings, but means that the compiler/IDE no longer knows that your code is using this column there, or that your system relies on those two properties having the same name. If you rename one side of the relationship, the mapping library will no longer map the column and you won’t get warned about it. Worse, since mapping logic is often defined in a separate class, you don’t even have co-location to fall back on.

There isn’t really a fix for this downside; it’s an inherent part of using these sorts of mapping frameworks. You could include every column explicitly, but that defeats the point of the using the framework in the first place. Some frameworks provide utilities for testing that a mapping is valid, but those tests rarely check for “is every single property mapped”, because it’s so common to want a partial mapping for numerous reasons.

Alas my personal conclusion is that whilst these libraries are a cool idea, and do have value in some very niche cases (perhaps where you don’t have control over the structure of the objects being mapped?) in the majority of cases…they should just be entirely avoided.

Make sure every object your API exposes to consumers is very obviously a consumer-facing model

Another kind of implicit mapping is the binding of objects at the point of serialisation between layers. This is an inherent part of multi-layer development and cannot reasonably be avoided – you are just going to have to know that when you change the backend model, you also have to update the frontend that uses that model. But there are options to mitigate some of the risks and minimise the time lost around this.

The main thing to do is to ensure your API is only ever sending out objects that are very clearly “API Model objects”. That is, make it really easy for a dev to know when an object they are editing will go across a serialisation boundary. Then they’ll know to go and find the counterpart and extend any applicable edits onto that.

Generally, this is achieved simply by ensuring that the model objects are all defined in a particular location, though a naming convention can also achieve the same effect. The main point is to ensure that this delineation is kept absolute. There will often be a temptation to just include some minor domain-centric data object as a sub-property of a model, because it holds exactly the information you need, in exactly the right shape. That is the temptation that must be avoided, as it creates a booby-trapped object – an object that appears superficially to be domain-only (and thus safe to make changes to whilst relying only on the compiler/IDE) but actually has surprise dependencies in the form of a frontend counterpart.

The solution might be “just create a model copy”, or it might be “create a folder that clearly indicates that an object is both a domain object and an API model”, or it might, in extremis, be the old standby of a screamy comment warning developers of the danger lurking invisibly.

Naturally if the API in question is a public API then it’s even more critical to make this obvious to a dev – a public API which has been published and has active consumers should never be modified unless absolutely required or safely versioned!

Think hard before duplicating enumerations across system layers

A less common case of compiler-invisible dependencies sometimes occurs with enumerations, and particularly with their handling through the different layers of a system – database, server-side logic and frontend display. Different languages handle them in different ways, but the concept of “there are a static set of values in a list, which a user can choose from” is extremely common. When that choice is just being recorded, and nothing depends on it, then there’s no real complexity…but when there is logic dependant on the value (“which workflow does this report go through”, for example) then you often end up wanting to refer to, and condition your code upon, the different options available.

That then leads to the list of values existing independently in both your backend code and frontend code. And if you decided to store any metadata about the options in the DB then it might exist in your database layer too! Your compiler/IDE doesn’t know that these different copies of the list are actually all the same list (indeed each compiler can probably only see one list at a time!), but it’s critical that they all remain in sync.

The invisible tech debt then reveals itself when a dev modifies an enum, not expecting to have to propagate that change elsewhere, and doesn’t discover the actual scope of the change required until things start breaking during testing. Potentially worse, it might not actually break anything, per se: you could have set it up such that all the communication between layers uses underlying numbers, rather than the option names, in which case renaming a value (or adding a new value in the middle of the list) in one layer wouldn’t break anything…until two years later you discover that the backend and frontend are now using “value #7” to mean different things with entirely inconsistent logic!

In a small and recently built system, one could reasonably expect the devs to Just Know that such enumerations exist in multiple places, and account for it when developing. But in a huge sprawling system it could be entirely reasonable for the developer to be unaware of the parallel usage, and infeasible for them to search for its existence.

The standard solution to this is just a big screamy comment on each of the definitions, warning the devs not to change this without also change that. And that’s a great option in a lot of cases – low effort to introduce, no additional complexity for the devs to understand, and clear enough that all but the most preoccupied devs will notice it when making the change.

But it’s still worth thinking carefully about whether there’s an alternative, before creating the second enum. Is there a reasonable compromise of, for example, simply passing Boolean flags from the middle tier to the frontend, to directly control the choice?

Assuming not, one other alternative to the “warning comment” solution may be viable: Templated Enum definitions. This approach involves having one file that holds the single definition of an enum across all layers, and then having a build step that pushes that definition into a set of templates for each layer. For a project I was once involved with, where we built a React SPA over a .NET/SqlServer backend, the single enum definition was parlayed into:

  • A C# enum definition file, which defined both the names and the explicit numeric values of the entries
  • A JS file defining a frozen JS object with keys derived from the enum entries names pointing to matching numeric values – the closest thing to an enum that JS has
  • A SQL file containing the table creation statement to hold the values in a two-column table, to facilitate database migrations on release
  • A SQL file containing the SQL MERGE statement necessary to update the contents of the previously created table to reflect the latest set of enum values

The template hydration occurred on build, overwriting any manual changes that might inadvertently have been made (naturally, the files also had screamy ‘Do Not Manually Edit’ comments). This overwrite meant that any attempt to edit just one layer directly would immediately trigger compiler errors, since the changes to the definition would get reverted and then the code that had depended on them would complain about the absence of the new enum value that you’d manually added.

This was a fairly heavy-duty solution, but it was highly effective in a system that had a lot of these kinds of enums that needed to exist, in sync, between multiple layers.

Help your teams find these problems

The meat of this article has been very much for devs and Tech Leads. So how can Engineering Managers help their devs to resolve these issues? Just as in the original article on the high-level concept of invisible tech debt, the answer is easy: ask your team about the problems, and then allocate time to fix them.

Here are some prompts that may help your devs think about what sorts of surprise dependencies they find within their codebase:

  • Which parts of the codebase catch you out when you’re renaming and refactoring?
  • Which classes do you avoid changing because you don’t know what they might impact?
  • What sorts of changes do you watch out for in code reviews, to say “have you checked over there”?
  • What are the “simple” refactors that you’ve been avoiding doing, because you know that they won’t actually be simple?
  • What sorts of refactors cause errors that only get caught by the tests, or CI framework?
  • Are there places that you’d advise a new dev against trying to change anything?
  • If I told you that the codebase compiled but didn’t work when deployed, and asked you to bet on where the problem was…where would you place your bet?
  • Where are the lurking monsters in your codebase, ready to bite you if you rename something?

[1]: In many ways the art of writing clean, maintainable code can be viewed as the art of making these couplings as small and as self-evident as possible.

[2]: As has been widely discussed elsewhere, fixing bugs later in the development or release cycle can be exponentially more expensive.

Digital Engineering

Get expert help with your digital challenges and unlock modern digital engineering solutions.