Architecture Decision Records are a way to keep track of all the significant decisions your team took in the process of designing software applications. These decisions commonly deal with the structure, dependencies, or architecture styles in their broadest sense: your desired level of decoupling, if any (you may decide to build a monolith), expiration rules for database passwords, or the communication mechanisms among functions, are examples of the kind of decisions you should find in a collection of ADRs.
AFAIW, the topic was first introduced by Michael Nygard in 2011. An excellent starting point to learn more about ADRs is this page at Github, adr.github.io. I found this repo architecture_decision_record by Joel Parker Henderson very useful too.
Testing your design
Given the fact that software must implement whatever architecture decisions you took during its design, the next logical step should be to check if the software fulfills that expectation, and that it does so every time it is changed. Here enter architecture tests.
In short, Architecture Tests verify that the software respects the architecture decisions assembled in the ADRs, as much as possible in an automated way. So their purpose is to check that new commits do not break the design, even though the code remains in total compliance to the features it provides. Hence it makes sense to run architecture tests once all other tests passed so that potential mistakes in the code do not conceal breaking in design decisions, for these are the only ones we want to catch with the architecture tests.
Some architecture decisions may be very easy to test, for they define the broadest context wherein the software is going to live. Further steps into more and more specific details will likely make architecture tests harder to write.
For example, if you decided to freeze some dependency in a specific version, it should not be that tough to test that new commits or compiled artifacts keep those exact versions. Typically we will do this via checksums: hashing the file with the expected dependency versions and comparing it with the hash value of the same file with the dependency versions included in the commit under test would do the work. Were these files not be available or easy to produce, you might need to rely on any tool capable of pulling out a list of versioned dependencies to compare with.
Example: checking idempotence
We can think of architecture decisions way harder to test. For example, let’s say we want to check that our software application fully complies with idempotence, i.e., repeated calls to a given function result in no effect after the first call ended successfully.
Idempotence is a feature you usually expect to see when dealing with the persistence of uniquely identified pieces of data. Just imagine the number of requests to a given resource, request calls requesters are going to be charged for, were not counted properly.
These look more like functional features that you may (and should) be checking with regular tests. Yet often you want to ensure idempotence for architectural reasons. For instance, to keep costs at a minimum and so avoid saving any data more than once.
So, architecture tests should check that any given function designed to be idempotent is when implemented.
In a REST scenario, you may implement idempotence using HTTP status codes: the first time a new resource is created, Requesters should receive a 201 (“Created“) Response status code. Additional Requests to create the very same resource should get a 409 (“Conflict“) Response status code instead. Or a simple 200 (“OK“) if this fits best in your context.
If the application architecture allows it, we may sophisticate this a bit more by including the If-None-Match HTTP headers condition. For example, we may hash the new content and sent the value as an ETag to be checked by the server against any indexable persistence mechanism, let’s say a UNIQUE index in a database, the hash value of file contents, etc.
An architecture test of this design should check that PUT or POST Requests without the If-None-Match header are rejected with an HTTP 428 (“Precondition required“) status code, and that, when present, the application Responses are in full accordance to what the design dictates: valid resource creation requests get an HTTP 201 (“Created“) Response status code, and further resource creation requests with the same payload get an HTTP 412 (“Precondition Failed“).
In a message-based asynchronous scenario, we face the same challenges that we saw in the REST scenario, plus additional troubles brought by distributed transactions. The absence of a standard like HTTP status codes does not help either.
The worst-case when dealing with idempotence in an Event-driven application is for services to receive the same message more than once, or messages relating to consecutive changes received in reversed order. As above, you are likely going to test these using regular functional tests.
But what if you decided to implement an Event Storage? And what if you decided to take a step farther and implement Event rewinding to run whenever it is necessary to reconstruct some State out of its related past Events? If your application is consistently wrong, you are likely to implement idempotence in the target (the service in charge of rebuilding that State), but not in the service that stores the Events because you are aware that your services are idempotent.
In this case, you should definitely check that your application is in full compliance with the ADR of storing equal Events only once with some architecture test. Which, if fulfilled, would guarantee that not idempotent services will produce the expected results for free.
The easiest way to do this is to hash the message payload and save it in the Event Storage only if there is no other message already there with exactly the same payload. For this method to work you need to ensure that the same Event is published exactly with the same payload every time, including potential timestamps, and keeping technical data (like any published_at timestamp) off the payload to hash, for example storing that data in a separated place (a header, for instance).
I hope these examples encouraged you to produce and collect your ADRs, if you were not doing it already, and to check them with a suite of architecture tests every time the team commits a new change in the application.
Top image credits: picture taken out of Xebia.blog found via DuckDuckGo search.