DevOps #6: Compile Independently With a Forward-compatible Component
I’m Waiting For the Build…Again!
Continuing our case study from the last two articles, let’s focus on one of the 130 teams who were trying to break free of the monolith.
At this point this team had created their component. Actually, there were several components, but each was just a namespace in the monolith. Only one team changed each component, but every change still forced them to recompile the monolith. And every team had to recompile the monolith and every component after every pull — even if every change was in a component.
The most common question I received from these teams was:
How should I design the APIs for the components that I change often?
While extracting the code to compile separately is a fair amount of work, it is straightforward and we can figure it out.
The hard part ‘../../post/compile-independently’is to minimize how often a component API change forces a monolith recompile, or a monolith change forces a component change. How should we design our component to minimize this churn?
Physical and Logical Separation
They had correctly identified that physical separation does not need special tricks — it just takes effort. They just needed to define an API, create a shared header (this was C++), and compile as a library.
The harder problem is to prevent cascading changes. They wanted to freely change either side — the component or the monolith — without forcing a recompile or re-edit of the other side. That requires logical separation, and different API choices offer different degrees of logical separation.
In other words, the goal was to create a forward-compatible API for their specific design. It needed to allow any future change to either the library or the client while:
- maintaining all existing execution flow,
- maintaining all existing signatures, and
- not growing new warts with every change.
Universal API Approach
While every design is unique, there is a universal approach to find such an API for your design.
A forward-compatible API minimizes:
- special cases, and
- fixed points.
The good news is that if you generated your component using the techniques of the two newsletters on gathering scattered code (Part 1, Part 2) then you have already minimized chattiness. You have a decent core design, and now you need to find a way to express it that minimizes fixed points and special cases.
Next month, we will minimize special cases using Ports and Adapters.
That leaves only fixed points. So how do we reduce fixed points in our design?
Choose Code Constructs to Minimize Fixed Points
Different code constructs provide different degrees of coupling. Intuitively, we know an inheritance relationship is high-coupling while a message bus is low-coupling. However, those are obvious examples. We need more precision if we are to make more nuanced design choices. This is possible by counting fixed points.
Design point: one aspect of a design. It may be either fixed or variable.
Fixed point: a design point which, if changed, would trigger a cascade of changes (edits or recompiles) in other parts of the system.
Variable point: a design point that is intentionally allowed to vary. One part of the design may vary in its implementation without impacting changes in other parts of the system.
Comparing 2 Design Options
Let’s consider two options for dispatching a call to unknown code.
- Interface inheritance
- Message bus
Specifically, let’s consider a UI that is offering auto-complete suggestions while the user types. The suggestion provider is in a different component, and we are either going to communicate with that component by making a call into an interface or by sending and receiving messages on a bus.
Interfaces lock many more design points than message busses:
|Message Bus Fixed Points||Interface Fixed Points|
|Message name||Method name|
|Minimum data (does not prevent adding optional data)||Number of args|
|Name of return message||Type of each arg|
|Return data variable name||Return type|
|Return data format||Structure of data within return type|
|All other methods on interface (names, arguments, types)|
|Inheritance relationship itself|
|Implementation lifetime control|
|How you get an instance|
Interface vs message bus fixed points
Which is Better?
Each fixed point locks the API against one kind of change. Changes of that kind will cascade across the boundary. Thus, to design a forward-compatible API we want to minimize the number of fixed points.
Fixed points are not always bad. They also make it easier to follow code. For example, it is a lot easier to reason about a direct call to a static method than it is to reason over an asynchronous message post - and that’s exactly because the static method call has more fixed points. Each fixed point locks down a potential variable, simplifying reasoning. Thus high-fixed-point constructs can be extremely useful within a component.
Inside a component, use as many fixed points as you can. Use variable points only when today’s problem requires abstraction.
At a component API, minimize fixed points.
This month’s recipes will show some common, high-fixed-point code constructs and lower-fixed-point alternatives for each. They will also show you how to refactor one into the other without introducing errors.
Keep your API forward-compatible by reducing fixed points and special cases.
Access the recipe to minimize fixed points in your API.
Change your Component Without Consulting Other Teams
Your API will allow great design flexibility to both you and the monolith. Very few of your changes will ever require updating the monolith. You will not even have to compile the monolith very often, as very few changes there will impact you at all. You can code and test against any recent monolith build, as your changes don’t require anything from the monolith. You will usually just use the last good monolith binaries from the build machine, avoiding compilation entirely.
- Ship features without cross-team dependencies by changing your component without consulting other teams.
- Save several hours per dev-day by stopping waiting for builds.
- Improve concentration and error checking by reducing your inner-loop compile time.
- It is more difficult to debug across the boundary between your code and the monolith.
- You need to reduce expectations that the monolith makes on your component, particularly related to execution sequence.
Demo the value to team and management…
Show three things at your sprint demo:
- Example: one API change.
- Progress: prioritized API improvement list.
- Impact: team total daily build time & minimum compile time.
Example: One API Change
Your goal is simply to show what a change looks like and both its positive and negative consequences.
Pick one part of your API and show the before and after. Describe the design points that used to be fixed and are now variable. Then describe what those mean - what kinds of changes used to cross the boundary between monolith and component and are now blocked.
Progress: Prioritized API Improvement List
Analyze your components’ APIs. Prioritize them as follows:
- By component. Focus on the ones that either change frequently or are frequently impacted by monolith changes.
- Then by API part. Again focus on those with the most changes and impacts.
- Then by number of problematic fixed points.
You don’t need a complete list. Just identify enough of the most impactful items to match available capacity for your next few sprints.
Impact: Total Build Time + Minimum Compile Time
There are two important impacts from logical separation.
- Improve efficiency: Save time by building less code. Spend that time on feature work.
- Improve flow: Get lightning-fast compiles for single changes. Work in smaller chunks, compiling more often and finding errors faster. It also allows more frequent commits.
To demonstrate this, measure two data points.
First, measure the total build time spent by the team per week. Get this by adding a timer to your team’s build script and appending the build time for each compile to a simple local CSV file. Import them all to a spreadsheet at the end of the week and compute total build minutes, total builds, and median build time. Record just those 3 values and chart them over time.
Second, measure the minimum compile time for each component you change relatively often. Change one file in a common way — like extracting a method’s body to be a new public method. Measure the compilation time. Record the compilation times for each component each week and chart them over time.
Add a threshold to the minimum compile time chart at 3 seconds, because this is the amount of waiting at which people start to change their behavior. Any component above this threshold will cause developers to collect work into larger chunks and check their work less frequently, leading to errors and disrupting flow. Keep and share a list of the good flow components and the flow blocking components.
In your demo, show 3 things:
- Efficiency chart: number of dev-hours spent waiting for builds each week, charted over time.
- Flow chart: minimum compile time per component shown over time, with flow threshold marked.
- Flow-blocker list: list of components that exceed the threshold. These will block flow on any stories that touch them.