Why I like the Contract-Based Dependency Management idea

Created
Wed, 13/10/2021 - 13:05
Updated
Wed, 13/10/2021 - 13:05

About a week ago, @tomasino published a post on his contract-based dependency management idea (aka CBDM), and I would be lying if I said I didn’t like it.

Not only does it provide a better model for dependency management than SemVer or any other versioning scheme, but it also:

  • provides strong incentive for developers to maintain extensive test suites for their own software;
  • provides strong incentive for developers to help developers of their project’s dependencies maintain extensive test suites, too;
  • provides very clear and unambiguous information on whether or not some functionality or behaviour of the dependency is, in fact, officially supported by dependency’s developers, or not;
  • provides very clear and unambiguous information if some functionality or behaviour of the dependency has changed;
  • makes it very, very clear who done goofed if a dependency upgrade breaks a dependent project.

What’s CBDM?

The basic idea boils down to this: when deciding if a given version of a given dependency is compatible with a dependent piece of software, instead of relying on version numbers – rely on tests that actually verify the functionality and behaviour that piece of software actually depends on.

In other words, when considering updating dependencies of a project, don’t look at version numbers, but look at tests of the dependency (and their results).

Tomasino’s post goes into more detail and is well-worth a read.

What’s wrong with version numbers?

Version numbers are are notoriously unreliable in predicting if something breaks after the upgrade. That’s the whole point of SemVer – to try to make them more reliable.

The problem is that it’s impossible to express, in a set of just few numbers, all the dimensions in which a piece of software might change. More importantly, certain changes might be considered irrelevant or minor by the developers, but might break projects that depend on some specific peculiarity.

Cue specifications, and endless debates whether or not a particular change breaks the specification or not.

How could CBDM work in practice?

Let’s say I’m developing a piece of software, call it AProject. It depends on a library, say: LibBee. LibBee developers are Gentlefolk Scholars, and therefore LibBee has quite extensive test coverage.

As the developer of AProject I specify the dependency not as:

LibBee ver x.y.z

…but as:

LibBee, (list of upstream tests I need to be unchanged, and to pass)

(Bear with me here and let’s, for the moment, wave away the question of how exactly this list of upstream tests is specified.)

This list does not need to contain all of LibBee’s tests – in fact, it should not contain all of them as that would effectively pin the current exact version of LibBee (assuming full coverage; we’ll get back to that). However, they should be tests that test all of LibBee’s functionality and behaviour AProject does rely on.

This set of tests becomes a contract. As long as this contract is fulfilled by any newer (or older) version of LibBee I know it should be safe for it to be upgraded without breaking AProject.

What if a LibBee upgrade breaks AProject anyway?

I say “should”, because people make mistakes. If upgrading LibBee breaks AProject even though the contract is fulfilled (that is, all specified tests have not been modified, and are passing), there is basically only a single option: AProject relied on some functionality or behaviour that was not in the contract.

That makes it very clear who is responsible for that unexpected breakage: I am. I failed to make sure the contract contained everything I needed. Thus a long and frustrating blame-game between myself and LibBee’s developers is avoided. I add the information about the additional test to the contract, and deal with the breakage as in any other case of dependency breaking change.

AProject just got a better, more thorough dependency contract, and I didn’t waste any time (mine nor LibBee developers’) blaming anyone for my own omission.

Win-win!

What if the needed upstream test does not exist?

If a test does not exist upstream for a particular functionality or behaviour of LibBee that I rely on, it makes all the sense in the world for me to write it, and submit it as a merge request to LibBee.

When that merge request gets accepted by LibBee’s developers, it clearly means that functionality or behaviour is supported (and now also tested) upstream. I can now add that test to AProject’s dependency contract. LibBee just got an additional test contributed and has more extensive test coverage, for free. My project has a more complete contract and I can be less anxious about dependency upgrades.

Win-win!

What if the needed test is rejected?

If LibBee developers reject my merge request, that is a very clear message that AProject relies on some functionality or behaviour that is not officially supported.

I can either decide to roll with it, still add that test to the contract, and keep the test itself in AProject to check each new version of LibBee when upgrading; or I can decide that this is too risky, and re-write AProject to not rely on that unsupported functionality or behaviour.

Either way, I know what I am getting into, and LibBee’s developers know I won’t be blaming them if they change that particular aspect of the library – after all, I’ve been warned, and have a test to prove it.

You guessed it: win-win!

Abolish version numbers, then?

No, not at all. They’re still useful, even if just to know that a dependency has been upgraded. In fact, they probably should be used alongside a test-based dependency contract, allowing for a smooth transition from version-based dependency management to CBDM.

Version numbers work fine on a human level, and with SemVer they carry some reasonably well-defined information. They are just not expressive enough to rely on them for dependency management. Anyone who has ever maintained a large project with a lot of dependencies will agree.

Where’s the catch?

There’s always one, right?

The difficult part, I think, is figuring out three things:

  1. How does one “identify a test”?
  2. What does it mean that “a test has not changed”?
  3. How to “specify a test” in a dependency contract?

The answers to 1. and 2. will almost certainly depend on the programming language (and perhaps the testing framework used), and will almost certainly mostly define the answer to 3.

One rough idea would be:

  1. A test is identified by it’s name (basically every unit testing framework provides a way to “name” tests, often requiring them to be named).
  2. If the code of the test changes in any way, the test is deemed to have changed. Probably makes sense to consider some linting first, so that whitespace changes don’t invalidate the contracts of all dependent projects.
  3. If a test is identified by it’s name, using that name is the sanest.

I really think the idea has a lot of merit. Software development is becoming more and more test-driven (which is great!), why not use that to solve dependency hell too?