Design

Practical Decoupling Techniques Applied to a C-based Radio Driver

We advocate for spending time decoupling your firmware from the underlying hardware to enable portability and reusability. We often have developers tell us that they see the value in these outcomes, but they could use practical examples for how to effectively decouple firmware from the underlying hardware. Others have concerns about whether we can truly create portable APIs.

While preparing for a new client project, we discovered an open-source driver for the ON Semiconductor AX5043 radio IC. This driver serves as an excellent example of simple techniques you can use to achieve decoupling.

The example driver's author built the hardware interactions on top of a single interface abstraction (representing a SPI transfer), enabling you to quickly migrate the driver from one platform to another. The API design also enables you to support multiple radios on a single device. We'll take a look at these two techniques in greater detail.

Table of Contents:

  1. Framing the Problem
  2. Ambitious Abstraction
  3. Thinking About Responsibility
  4. Decoupling with Minimalistic APIs
    1. Handling Configuration
  5. Supporting Multiple Devices
  6. Putting it All Together
  7. Further Reading

Framing the Problem

The AX5043 supports two communication methods: SPI and "wire mode". This library focuses only on the SPI interface, which is the standard operating mode.

The typical implementation approach (taken on most projects we've encountered) would be to define a set of radio interfaces which talk directly to AX5043 device using existing SPI APIs. The SPI APIs may be provided by a processor vendor SDK, an SDK such as MyNewt or Zephyr, or a proprietary internal implementation. If the bus isn't shared with other devices, we would likely find that SPI information is hard-coded in the driver. We would also probably find that the driver is responsible for configuring its SPI peripheral with the settings it needs.

The approach described above would work just fine until one of these scenarios forces a change:

  1. Migrate to a new SDK (due to a processor change or underlying framework change)
  2. Support multiple hardware revisions which have different SPI/driver configurations
  3. Support multiple devices on a single SPI bus
  4. Support multiple driver instances on a single board

At this point the driver is ripped apart in order to support a new SDK or to enable flexibility through extra function parameters. The status quo often serves a team until the next major requirements change occurs, and another rewrite happens.

Ambitious Abstraction

More ambitious teams take a heavier approach and define generic APIs for each driver type. Modules which need to interact with a given driver type only know about and use the generic APIs. With C++, this type of abstraction is achieved with inheritance + virtual functions or templates + concepts. Here's an inheritance-based example:

// SPI Master Abstract Class
class SPIMaster
{
  public:
    virtual void configure(spi::baud_t baud) noexcept = 0;
    virtual spi::mode mode() const noexcept = 0;
    virtual spi::mode mode(spi::mode mode) noexcept = 0;
    virtual void start() noexcept = 0;
    virtual void stop() noexcept = 0;
    virtual comm::status transfer(void* data, size_t length) noexcept = 0;
};

// A derived class, which is a SPI peripheral driver that 
// implements the abstract class interfaces
class SomeSPIDriver : public SPIMaster
{
    // Implementation of each abstract function 
    // + possibly some extras that won't be in the generic interface
};

// Driver which talks over SPI
class AX5403 {
  public:
    // When we construct an AX5403 object, we take in a reference
    // to the ***generic*** SPI Interface
    AX5403(SPIMaster& spi) : spi_(spi) {}

    // Function implementations would talk using SPIMaster interfaces, 
    // and could work with any SPI driver derived from that base class
    void send(void* data, size_t size)
    {
        spi_.transfer(data, size);
    }

  private:
    SPIMaster& spi_;
};

For C, it is achieved using structures of function pointers:

struct spi_intf
{
    void (*configure)(spi_baud_t);
    spi_mode_t (*get_mode)();
    void (*set_mode)(spi_mode_t);
    void (*start)();
    void (*stop)();
    comm_status_t (*transfer)(void*, size_t);
};

struct spi_intf spi0 = {
    &configureSPI0,
    &getModeSPI0,
    &setModeSPI0,
    &startSPI0,
    &stopSPI0,
    &transferSPI0
};

void ax5403_send(struct spi_intf* spi, uint8_t* data, size_t size)
{
    assert(spi && spi->transfer);

    // Uses a generic function pointer, so different implementations 
    // can be supplied with no change to the driver
    spi->transfer(data, size);
}

This C-style approach operates on the same principal: consumers only know about the function pointers, not the details of the functions being pointed to.

Thinking About Responsibility

We should take a step back and think about responsibility. What are the core responsibilities of an AX5043 radio driver?

  • Communication with the radio
  • Initialization of the radio
  • Adjusting radio settings
  • Sending and receiving data over the radio

Notice that our driver doesn't need to be responsible for initializing the SPI bus, changing SPI operating modes, setting bit ordering, or starting/stopping the SPI driver. All our radio driver needs to know about SPI is how to transfer data over the bus it's connected to. It would be reasonable for the driver documentation to say: "We need the SPI bus to be initialized and operating in Mode 3 and transmitting LSB First", leaving it to the application programmer to ensure these requirements are met.

I bring this up because I'm commonly guilty of the "ambitious abstraction approach" shown above. The strategy works well, but it presents some problems.

For one, we've given the radio driver more control over the SPI driver than it actually needs. It's easy to misinterpret the availability of an API as it being appropriate to use in the current context. If our drivers are reaching beyond their responsibilities and configuring the SPI bus (or stopping/starting the SPI peripheral), we can easily introduce problems into our system - especially if drivers start changing configurations without the application developer knowing his settings are being overridden.

The second problem is that we haven't really decoupled anything. Our radio driver, for better or worse, is dependent upon the SPI driver abstract interface definition. We are not omniscient nor omnipotent, and eventually we will need to modify our abstract interfaces. The unfortunate impact is that every module which is dependent upon those abstractions must change. For something as fundamental as a SPI driver, the impact can be significant.

If we stay focused on the responsibilities of our driver, we can come up with a minimalistic decoupling strategy that avoids these problems.

Decoupling with Minimalistic APIs

Now that we've explored the problem space a bit more, let's get back to this wonderful example of a decoupled driver.

As we mentioned above, the AX5043 radio driver only needs to know how to send and receive data over the SPI bus it's connected to. Since SPI send and receives data at the same time, we can use a single function to handle a SPI transfer. In C, we can define our abstract interface using a function pointer:

void (*spi_transfer)(unsigned char*, uint8_t);

The driver devices this function pointer inside of the ax_config struct as a member variable. We'll look more at the configuration struct next.

The driver defines many hardware-interaction APIs for internal use, such as:

uint8_t ax_hw_read_register_8(ax_config* config, uint16_t reg);

uint16_t ax_hw_read_register_long_bytes(ax_config* config, uint16_t reg,
                                        uint8_t* ptr, uint8_t bytes);

uint16_t ax_hw_write_fifo(ax_config* config, uint8_t* buffer, 
                             uint16_t length);
uint16_t ax_hw_read_fifo(ax_config* config, uint8_t* buffer, 
                             uint16_t length);

Each of these functions references the abstract spi_transfer function pointer in the config struct to talk to hardware:

uint16_t ax_hw_read_fifo(ax_config* config, uint8_t* buffer, 
                             uint16_t length)
{
  /* read (short access) */
  buffer[0] = (AX_REG_FIFODATA & 0x7F);

  config->spi_transfer(buffer, length);

  status &= 0xFF;
  status |= ((uint16_t)buffer[0] << 8);

  return status;
}

Internally, the radio driver knows nothing about the details of the SPI driver; it only knows that this one function pointer exists.

The next detail to handle: how do we supply the instance of the spi_transfer function to the radio driver?

Handling Configuration

Somewhere else in the program, we need to initialize the SPI device:

if (wiringPiSPISetup(SPI_CHANNEL, SPI_SPEED) < 0) {
    fprintf(stderr, "Failed to open SPI port. "
                     "Try loading spi library with 'gpio load spi'");
}

We'll also declare and populate the radio configuration struct. The contents of this structure are referenced by the various radio APIs. One of the fields that must be populated is the spi_transfer function pointer.

ax_config config;
memset(&config, 0, sizeof(ax_config));

config.clock_source = AX_CLOCK_SOURCE_TCXO;
config.f_xtal = 16369000;
config.synthesiser.A.frequency = 434600000;
config.synthesiser.B.frequency = 434600000;

config.spi_transfer = wiringpi_spi_transfer;

config.pkt_store_flags = AX_PKT_STORE_RSSI | AX_PKT_STORE_RF_OFFSET;

Where does the wiringpi_spi_transfer function come from? It's actually a local function which matches the spi_transfer API and makes the proper call to the underlying SPI driver:

void wiringpi_spi_transfer(unsigned char* data, uint8_t length)
{
    wiringPiSPIDataRW(SPI_CHANNEL, data, length);
}

This approach, defining a local function which implements the necessary abstraction, will be the most common. It's doubtful that your SPI driver will actually have an interface that matches the radio driver's requirements. You need to create a mapping between the two. The actual coupling details of the two is kept outside of the drivers themselves and in a contained area.

For my designs, it is common for this kind of work to happen in a "board" module, which defines the various drivers, hooks them up to each other, and handles hardware initialization. This approach is an example of the Mediator software architecture pattern, since we've defined a single module which manages the coupling between other interacting modules.

Supporting Multiple Devices

The creation of a configuration struct and its use in all of the AX5043 APIs enables us to support multiple radios at once. This is common for C APIs, as we need some way to store information outside of the function itself.

In order to enable C APIs to be used with multiple objects, we need to:

  • Store all mutable configuration options and context-specific information in the struct
  • Store function pointer abstractions in the struct
  • Pass the configuration struct to the API (often done via pointer to avoid a copy onto the stack)

In this specific instance, we just need to declare two different ax_config instances, populate the settings for each radio, and supply a spi_transfer function pointer for each instance. We would expect the spi_transfer function implementation to differ for the two ax_config instances, as you need to differentiate between the two hardware devices.

#define SPI_CHANNEL_RADIO_0 0
#define SPI_CHANNEL_RADIO_1 3

void radio0_spi_transfer(unsigned char* data, uint8_t length)
{
    wiringPiSPIDataRW(SPI_CHANNEL_RADIO_0, data, length);
}

void radio1_spi_transfer(unsigned char* data, uint8_t length)
{
    wiringPiSPIDataRW(SPI_CHANNEL_RADIO_1, data, length);
}

int main(void)
{
    // Some stuff happens...

    ax_config radio0_config;
    ax_config radio1_config;

    // Configure some stuff...

    radio0_config.spi_transfer = radio0_spi_transfer;
    radio1_config.spi_transfer = radio1_spi_transfer;

    ax_init(&radio0_config);
    ax_init(&radio1_config);

    // Useful stuff happens next...
}

Because the APIs reference the spi_transfer function pointer in the configuration structure, and because each radio has a different configuration, our driver will talk to two different devices with no modifications.

We can see the utility of this approach by analyzing a function in the repository which does not accept an ax_config* input:

/**
 * Returns the status from the last transaction
 */
uint16_t ax_hw_status(void)
{
  return status;
}

This status value is set by various calls, such as:

uint16_t ax_hw_read_fifo(ax_config* config, uint8_t* buffer, uint16_t length)
{
  /* read (short access) */
  buffer[0] = (AX_REG_FIFODATA & 0x7F);

  config->spi_transfer(buffer, length);

  status &= 0xFF;
  status |= ((uint16_t)buffer[0] << 8);

  return status;
}

If we have two radios, we have no guarantee that the status value we're reading actually corresponds to our radio's last transfer. Instead, this API could be adjusted to take in an ax_config* with the status kept in the struct itself.

Putting it All Together

Both of these techniques can be easily applied to your C device drivers and libraries. Take a step back and think about the APIs your drivers actually require from other modules. Are you creating too many dependencies, or exposing too much information? Can you limit that exposure by requiring a minimal HAL, such as this example driver does?

Take a look through the AX project to see how much is implemented on top of one one abstract function. You're not limited to one abstraction, either. Simply define the minimal set that you need for your specific module.

Happy Hacking!

Further Reading

Embedded Systems Architecture Resources

Updated: 20190717

After a decade spent building and shipping hardware products, I became convinced that many of the problems and schedule delays I experienced could have been avoided with a little bit of planning and thought. Repeatedly, we painted ourselves into corners with code that seemed to work well initially but caused problems months later when we finally started end-to-end system testing. Serious problems resulted in major software rewrites, changes in the technology stack, and delayed the ship date. Even worse, as I migrated from one product team to another, I noticed that we were repeating the same basic mistakes.

I started pondering this situation. Why were we dealing major design problems and risk areas at the end of the project instead of the beginning? How could we describe ourselves as "agile" if we weren't able to quickly adapt our programs to change? Why did none of the teams I was on use anything resembling design activity before starting to build a system?

These questions led me to a deep immersion in the topics of software architecture, systems thinking, and system design. I've applied countless lessons to our internal projects, client projects, and business development efforts. Value exploration, visual modeling, and minimalistic architecture efforts have significantly improved our work quality and derisked many projects.

"Architecture" and "design" seem to be words that send programming teams running for the hills. However, I've had multiple embedded developers share their frustrations with me - the same that started me on my journey - and expressed their interest in learning more about software architecture but not knowing where to start. So, here are all the resources I've collected on software architecture. I hope they help guide you in your own journey.

Table of Contents:

Where to Start?

There's a lot of material here! You don't need to read all of it to get started with architecture.

For general architecture exposure, I recommend picking 1-2 books from this list:

If you are focused on embedded systems, I highly recommend Real-Time Software Design for Embedded Systems. This book provides a blueprint for modeling and architecting embedded systems. You will be introduced to UML and a variety of modeling approaches that you can use when architecting an embedded system.

The next step is to actually practice! There is no need for a long, drawn-out architecture stage. Allocate 2-4 weeks for value exploration and architecture efforts before starting any new project. Perform stakeholder interviews and explore the value you expect the system to provide. Then focus on answering core questions, like:

  • What qualities and behaviors are most important?
  • What requirements do they place on the design?
  • What are the biggest risk areas?
  • How can we reduce risk?
  • What are we unsure about that might change?
  • How can we make sure to support those changes without requiring a system redesign?
  • What parts of the system will we buy, license, outsource, and build in house?

Those questions will inform the architecture effort. Model the system and begin prototyping the riskiest areas. As you develop the system, you will explore and refine the system architecture.

General Software Architecture

Before diving into embedded systems specifics, it is helpful to have a solid foundation in general software architecture techniques.

We've broken down our reading recommendations into the following categories:

What is Architecture?

Before diving into the how of architecture, it's helpful to know what it is.

Why Should We Architect?

Perhaps you're not convinced that architecture is valuable. Or perhaps you need to prepare yourself to advocate for architecture efforts on your projects. These articles will give you some insights into why we architect.

The Architect Role

These articles discuss the architect role itself, particularly the qualities and skillsets that are valuable to an architect.

Architecting

We recommend the following architecture books:

These articles from around the web provide countless insights into the practice of software architecture:

Phil Koopman has a selection of lectures which are generally applicable to architecture and design:

Additionally, the slides and course notes from Hassan Gomaa are a useful introduction:

Here are talks which relate to the subject of architecture:

Techniques

Here are some practical technique guides related to the architecture process, ideation, brainstorming, and value exploration.

Documentation

Architecture work and documentation go hand in hand. Here are valuable resources on the that discuss architecture documentation:

Visual Architecture Process

These guides relate to Bredemeyer Consulting's Visual Architecture Process. They provide a practical blueprint for architecting your systems.

C4 Process

Simon Brown created the C4 architecture model, which focuses on four areas of architecture: Context, containers, components, and code. This is another practical blueprint for architecting your system.

Embedded Systems Architecture

Even just a little exposure to software architecture will reveal how deep the rabbit hole goes. We're focused on embedded systems, so here are embedded-specific resources.

Our favorite books on the subject of embedded systems architecture are:

Hassan Gomaa, a professor at George Mason University, published course notes for two courses which discuss embedded systems architecture and modeling:

Phil Koopman published the following course notes which are useful for embedded systems architects:

Safety and Critical Systems

Here are lectures, coures notes, and essays related to architecting for safety and for critical systems:

Security

Here are lectures, coures notes, and essays related to architecting for security:

Systems Thinking

I would be remiss to talk about architecture without mentioning systems thinking. These two topics are intertwined: we must develop a habit of thinking about the system as a whole if we are to work at an architectural level.

Here are some of my favorite books and essays on systems thinking:

Design Patterns

Design patterns are extremely useful to learn and familiarize yourself with. These are non-obvious solutions to common scenarios and problems. For generally useful software architecture patterns, see:

Embedded systems often work well with event-driven architectures and/or state machines. For more information, see:

Embedded systems are often under tight memory constraints. A useful reference for embedded developers is:

Layered or Hexagonal architectures are common abstractions that work well for embedded systems. Here are some links on both types of design:

Here are design patterns related to safety and critical systems:

Here are anti-patterns to avoid:

Visual Modeling

UML

UML is frequently trashed by development teams (even those with no experience using it), but I find "UML-light" to be extremely useful for documenting and modeling my systems.

These books are wonderful resources for learning and applying UML:

Here are lectures related to UML:

As far as UML tools go, there are many options. We recommend three:

  • Visual Paradigm is our tool of choice due to its support of SysML and the ability to tweak the models to support our needs
  • StarUML is a UML modeling tool recommended to us by Grady Booch, who says he uses this tool on a regular basis
  • PlantUML is a great tool which generates UML diagrams from textual descriptions, enabling you to store UML diagrams under revision control and to include them in source-code comments

C4

If you prefer the C4 model, we recommend the following:

Who to Follow

You've already seen these names quite a bit throughout the article. I recommend keeping up with these folks:

Architecture on Embedded Artistry

We publish articles related to Architecture and Systems Thinking on on this website.

Architecture Articles

Systems Thinking Articles

Books Mentioned Above

Documenting Software Architectures: Views and Beyond (2nd Edition)
By Paul Clements, Felix Bachmann, Len Bass, David Garlan, James Ivers, Reed Little, Paulo Merson, Robert Nord, Judith Stafford
Design Patterns: Elements of Reusable Object-Oriented Software
By Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides
Pattern-Oriented Software Architecture Volume 1: A System of Patterns
By Frank Buschmann, Regine Meunier, Hans Rohnert, Peter Sommerlad, Michael Stal, Michael Stal

Change Log

Documenting Architectural Decisions Within Our Repositories

I recently discovered Michael Nygard's article on the subject of Documenting Architecture Decisions. I immediately became interested in using Architecture Decision Records (ADRs) with my projects.

I will provide a brief ADR summary, but I recommend reading Michael Nygard's article before continuing.

Table of Contents:

  1. An Overview of Architecture Decision Records
  2. Using ADRs in Your Projects
    1. Installation
    2. Initialization
    3. Creating a New ADR
    4. Linking ADRs
    5. Superseding ADRs
    6. Other adr-tools Tricks
      1. Listing ADRs
      2. Generating Summary Documentation
      3. Upgrading the ADR Document Format
  3. Putting it All Together
  4. Further Reading

An Overview of Architecture Decision Records

The motivation for using ADRs comes from a common scenario that all developers become familiar with:

One of the hardest things to track during the life of a project is the motivation behind certain decisions. A new person coming on to a project may be perplexed, baffled, delighted, or infuriated by some past decision. Without understanding the rationale or consequences, this person has only two choices:

1. Blindly accept the decision
2. Blindly change it.

Instead of leaving developers to operate blindly, we should record significant decisions affecting the structure, dependencies, interfaces, techniques, or other aspects of our code.

Rather than maintain a large document which nobody will read, we'll house these decisions within our repositories so they are easily accessible.

The ADR format summarizes decisions in five parts:

  1. Title
  2. Context
  3. Decision
  4. Status (e.g. proposed, accepted, deprecated, superseded)
  5. Consequences (good, bad, neutral)

ADR records should be kept short (maximum of two pages) so they are easily digestible by developers.

One ADR will document one significant decision. If a decision is reversed, amended, deprecated, or clarified, we'll keep the corresponding ADR. We'll generate a new ADR, link the related decisions together, and mark the previous decision with a relevant status note.

By keeping a full history of decisions, we help developers see the evolution of our decisions through time and provide the full context for each decision.

Now that we have a basic understanding of what an ADR is, let's see how we can use them in our projects.

Using ADRs in Your Projects

The free adr-tools project allows you to create and manage architecture decisions directly within your projects. No need to worry about managing yet-another-document in some-other-place-we-can't-remember.

ADRs are numbered in a sequential and monotonic manner (0001, 0002, 0003, …). The records are created as Markdown files so they can be parsed by GitHub and documentation tools.

Installation

adr-tools can be installed by adding the git project or a packaged release to your PATH.

Alternatively, OS X users can install adr-tools with Homebrew:

brew install adr-tools

Initialization

Once adr-tools is installed, you will need to enable support inside of your repository using the adr init command. The command takes an argument which specifies where the ADRs should live. For example:

adr init doc/architecture/decisions

The adr init command will create the first ADR in your repository, which notes that you have decided to record architecture decisions: 0001-record-architecture-decisions.md.

# 1. Record architecture decisions

Date: 2018-03-20

## Status

Accepted

## Context

We need to record the architectural decisions made on this project.

## Decision

We will use Architecture Decision Records, as described by Michael Nygard in this article: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions

## Consequences

See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's _adr-tools_ at https://github.com/npryce/adr-tools.

Creating a New ADR

To create a new ADR, use the adr new command:

adr new Title For My Decision

This will create a new decision record in the form of XXX-title-for-my-decision.md.

If the VISUAL or EDITOR environment variables are set, the editor will automatically open the file. Otherwise you will need to manually open the file for editing.

Linking ADRs

You can link two ADRs together using the adr link command:

adr link SOURCE LINK TARGET REVERSE-LINK

The SOURCE and TARGET arguments are references to an ADR, which can be either a number or partial filename. The LINK argument is a description that will be added the SOURCE ADR, and the REVERSE-LINK option is a description that will be added to the TARGET ADR.

For example, here is a link which indicates ADR 12 amends ADR 10:

adr link 12 Amends 10 "Amended by"

You can also link ADRs when creating a new one using the -l argument:

-l TARGET:LINK:REVERSE-LINK

Similarly to the arguments for the adr link command, TARGET references the ADR which we are linking to, LINK is the description that will be added to our new ADR, and REVERSE-LINK is the description which will be added to the TARGET ADR.

To use our amendment example above:

adr new -l "12:Amends:Amended by" Brand New Decision

You can provide multiple -l options when creating a new ADR to enable linking to multiple existing records.

Superseding ADRs

When creating a new ADR, you can indicate that it supersedes an existing adr using the -s argument:

adr new -s 12 Brand New Decision

The status of the superseded ADR (0012 in the example above) will be updated to indicate that it superseded by the new ADR. The newly created ADR will also have a status which indicates the ADR that it is superseding.

You can provide multiple -s options when creating a new ADR.

Other adr-tools Tricks

While creating, linking, and superseding ADRs is primarily how we will interact with adr-tools, other options are available.

Listing ADRs

The adr list command will provide a list of all ADRs in your project:

$ adr list
docs/architecture/decisions/0001-record-architecture-decisions.md
docs/architecture/decisions/0002-remove-simulator-from-project.md
docs/architecture/decisions/0003-meson-build-system.md
docs/architecture/decisions/0004-link-with-whole-archive.md

Generating Summary Documentation

The adr generate command can be used to generate summary documentation. Two options are currently provided: toc and graph.

The toc argument will generate a Markdown-format table of contents:

$ adr generate toc
# Architecture Decision Records

* [1. Record architecture decisions](0001-record-architecture-decisions.md)
* [2. Remove simulator from project](0002-remove-simulator-from-project.md)
* [3. Meson Build System](0003-meson-build-system.md)
* [4. Link With --whole-archive](0004-link-with-whole-archive.md)

The graph argument will generate a visualisation of the links between decision records in Graphviz format. Each node in the graph represents a decision record and is linked to the decision record document.

$ adr generate graph
digraph {
  node [shape=plaintext];
  _1 [label="1. Record architecture decisions"; URL="0001-record-architecture-decisions.html"]
  _2 [label="2. Remove simulator from project"; URL="0002-remove-simulator-from-project.html"]
  _1 -> _2 [style="dotted"];
  _3 [label="3. Meson Build System"; URL="0003-meson-build-system.html"]
  _2 -> _3 [style="dotted"];
  _4 [label="4. Link With --whole-archive"; URL="0004-link-with-whole-archive.html"]
  _3 -> _4 [style="dotted"];
}

The link extension can be overridden with the -e argument. For example, to generate a PDF visualization which links to ADRs with PDF extensions:

adr generate graph -e .pdf | dot -Tpdf > graph.pdf

Upgrading the ADR Document Format

If the ADR format changes in a future adr-tools version, you can upgrade to the latest document format using the adr upgrade-repository command.

Putting it All Together

If you're curious about what ADRs look like in practice, I recommend reviewing the adr-tools decision records.

After trying out adr-tools and documenting my architecture decisions, I'm hooked. As a consultant, I frequently work on a variety of projects and am frustrated by the lack of documentation. I hope to leave other developers with the context for my decisions and prevent that frustration from spreading.

I encourage you to give ADRs a try. Keeping a list of running decisions in a simple and digestible manner is much easier than maintaining large specification documents.

Further Reading