What’s true of every bug found in the field? … It passed the type checker [and it] passed all of the tests.
- Rich Hickey
All bugs pass the type checker and all of the tests. More importantly all bugs represent a guarantee that was intended to be present, but for some reason it wasn’t.
Perhaps you intended to display the first name of a person but firstName was mistyped as firstname and undefined was displayed instead.
Perhaps you intended the user experience of all data entry forms to be the same. Users should see validation errors as they tab through each form. However, on one particular form validation only occurs on form submission.
You intended for there to be some guarantee with the application’s behavior, but somehow that guarantee was broken. That invariant was not upheld.
What is an invariant?
An invariant is a rule, or condition, that can be relied upon to be true during the execution of the program. Another way of describing that is:
An invariant is a logical guarantee about the application
Invariants can be very low level. They can be represented by basic types, or the fact that you might want certain data to be immutable.
Invariants can also represent more abstract concepts. For example, they can represent the asynchronous life cycle of data, or they can represent the user experience of certain user-facing components.
What is Invariant Driven Development (IDD)?
Invariants are an important concept in software development and are commonly seen in other development approaches, such as Domain Driven Design (DDD). In this post, I’m going so speak to a related approach and refer to it as Invariant Driven Development.
Invariant Driven Development is an approach to software development that promotes invariance to a first class concern. It has three steps:
- Identify the desired invariants
- Enforce those invariants
- Refactor if necessary
At first glance this looks somewhat similar to TDD, but we’ll see the two approaches part ways shortly.
Step 1: Identify the desired invariants
Let’s assume we’re building a simple Github user search. This page will allow a user to search for Github users and it will show their profile picture.
A simple Github user search
The first step is to identify our invariants. In this example we are displaying data on a page that is asynchronous in nature. As a result there are four guarantees that we want in order to give users an enjoyable and consistent user experience.
- Initial state - Users coming across this page for the first time should see instructions for what to do since we don’t have any Github users to show yet.
- Retrieval state - Once a user performs a search there should be an indication that the application is actively performing a request.
- Success state - When the request completes successfully then we should show the results to the user.
- Error state - If the request fails then we should show the user an error message so that they understand that there was a problem.
Step 2: Enforce the invariants
If possible, we prefer to enforce the invariants at compile time through carefully modeling the data and carefully defined function interfaces.
However, if we approached the problem naïvely we might model a solution like this:
There’s an issue with the above model and the issue is this model wasn’t designed with invariants in mind. There are a number of bugs, or a number of broken guarantees, that are possible.
For example, does it make sense for hasRetrievedData to be false while at the same time there is an error?
Does it make sense for there to be a collection of retrieved users and to have an error at the same time? For some applications, perhaps. But not for this application.
A better question is this:
Why model a solution that is capable of representing bugs?
It’s worth letting that question sink in for a moment. Too often is model design an afterthought or a side-effect of narrowly focused unit tests. Too often we find ourselves modeling a solution that is capable of representing avoidable bugs. We (developers in general) create models capable of representing broken guarantees.
What if we model the solution such that certain bugs are literally impossible, and enforced as such?
Modeling the solution with invariants
Earlier we identified four invariants that we’d like to have enforced. We could use the above model, but we only get the guarantees we want if we’re exhaustively disciplined with writing enough unit tests to ensure those guarantees. We would also be faced with the awkward situation of trying to decide what the expected behavior should be when we are testing what should be an impossible state of the application.
Rather than relying on unit tests for those particular invariants, let’s deliberately model the solution and rely on the compiler.
With the above changes we have replaced a model capable of several invalid states with a model that is only capable of representing valid states. Additionally, by using a union type we get our invariants enforced at compile time. The compiler will force any developer that uses PageState to explicitly handle those four states.
What this ultimately means is any developer that is writing code to handle PageState cannot forget to handle certain cases, like the error state, because the compiler will prevent them from doing so. The compiler is guaranteeing a user experience with regards to viewing asynchronous data.
Step 3: Refactor if necessary
Just as with any approach to software development, refactoring should be part of the process.
In the above example we are modeling asynchronous Github user data, but we could just as easily use a similar model for other asynchronous data. In fact, from a user experience perspective it is very desirable to give your users a consistent experience for similar actions.
For example, if the user needs to log in we still want an initial state to show a login form. We want a retrieval state to tell the user we’re actively attempting to log in. We want a success state to show the name of the currently logged in user and we want an error state to tell the user why login failed.
We can refactor the above code to be slightly more generic so that other parts of the application can get the same guarantees regarding async data.
This refactor will help ensure that any other pages that are using the AsyncData type will have a consistent user experience.
Does TDD fit into this picture?
It absolutely does. IDD and TDD are not mutually exclusive approaches. Rather, the goal is to obtain invariance sooner than later, even before test time. This can yield a few benefits.
- There are fewer tests to write. If certain bugs are made impossible because the compiler will not allow them, then we don’t need to write tests for those cases.
- An invariant-first approach produces holistic solutions. Unfortunately, writing unit tests first may introduce implementation details that originate in narrowly focused unit tests. Oftentimes this leads to a solution that is capable of many more potential bugs, which then require more tests.
- Your IDE will assist you with invariants that leverage the type system. This is a great advantage because now we have one more tool helping us avoid bugs.
However, it’s not always practical or possible to leverage the type system for invariants. It’s in those cases that Test Driven Development becomes a more appealing option. After all, TDD is still in the business of upholding invariants. The reason why leveraging the type system may be preferred is because of the advantages listed above.
In summary, Invariant Driven Development is about viewing bugs as they really are. A bug is an application guarantee that isn’t being enforced.
It’s about approaching development through the lens of identifying a guarantee first and enforcing that guarantee. Earlier enforcement is generally preferable, such as leveraging the type system to get compile-time enforcement.
Later enforcement may be more practical and this can be done by using Test Driven Development for that particular guarantee. Lastly, refactor the code when necessary.