"There are three things extremely hard: steel, a diamond, and to know one's self." — Benjamin Franklin
TigerBeetle's coding style is evolving. A collective give-and-take at the intersection of engineering and art. Numbers and human intuition. Reason and experience. First principles and knowledge. Precision and poetry. Just like music. A tight beat. A rare groove. Words that rhyme and rhymes that break. Biodigital jazz. This is what we've learned along the way. The best is yet to come.
Another word for style is design.
"The design is not just what it looks like and feels like. The design is how it works." — Steve Jobs
Our design goals are safety, performance, and developer experience. In that order. All three are important. Good style advances these goals. Does the code make for more or less safety, performance or developer experience? That is why we need style.
Put this way, style is more than readability, and readability is table stakes, a means to an end rather than an end in itself.
"...in programming, style is not something to pursue directly. Style is necessary only where understanding is missing." ─ Let Over Lambda
This document explores how we apply these design goals to coding style. First, a word on simplicity, elegance and technical debt.
Simplicity is not a free pass. It's not in conflict with our design goals. It need not be a concession or a compromise.
Rather, simplicity is how we bring our design goals together, how we identify the "super idea" that solves the axes simultaneously, to achieve something elegant.
"Simplicity and elegance are unpopular because they require hard work and discipline to achieve" — Edsger Dijkstra
Contrary to popular belief, simplicity is also not the first attempt but the hardest revision. It's easy to say "let's do something simple", but to do that in practice takes thought, multiple passes, many sketches, and still we may have to "throw one away".
The hardest part, then, is how much thought goes into everything.
We spend this mental energy upfront, proactively rather than reactively, because we know that when the thinking is done, what is spent on the design will be dwarfed by the implementation and testing, and then again by the costs of operation and maintenance.
An hour or day of design is worth weeks or months in production:
"the simple and elegant systems tend to be easier and faster to design and get right, more efficient in execution, and much more reliable" — Edsger Dijkstra
What could go wrong? What's wrong? Which question would we rather ask? The former, because code, like steel, is less expensive to change while it's hot. A problem solved in production is many times more expensive than a problem solved in implementation, or a problem solved in design.
Since it's hard enough to discover showstoppers, when we do find them, we solve them. We don't allow potential memcpy latency spikes, or exponential complexity algorithms to slip through.
"You shall not pass!" — Gandalf
In other words, TigerBeetle has a "zero technical debt" policy. We do it right the first time. This is important because the second time may not transpire, and because doing good work, that we can be proud of, builds momentum.
We know that what we ship is solid. We may lack crucial features, but what we have meets our design goals. This is the only way to make steady incremental progress, knowing that the progress we have made is indeed progress.
"The rules act like the seat-belt in your car: initially they are perhaps a little uncomfortable, but after a while their use becomes second-nature and not using them becomes unimaginable." — Gerard J. Holzmann
NASA's Power of Ten — Rules for Developing Safety Critical Code will change the way you code forever. To expand:
u32 for everything, avoid architecture-specific usize.assert(a); assert(b); over assert(a and b);. The former is simpler to read, and provides more precise information if the condition fails.Splitting code into functions requires taste. There are many ways to cut a wall of code into chunks of 70 lines, but only a few splits will feel right. Some rules of thumb:
Beyond these rules:
if/else branches. Split complex else if chains into else { if { } } trees. This makes the branches and cases clear. Again, consider whether a single if does not also need a matching else branch, to ensure that the positive and negative spaces are handled or asserted.if (index < length) {
// The invariant holds.
} else {
// The invariant doesn't hold.
}This form is harder, and also goes against the grain of how index would typically be compared to length, for example, in a loop condition:if (index >= length) {
// It's not true that the invariant holds.
}"Specifically, we found that almost all (92%) of the catastrophic system failures are the result of incorrect handling of non-fatal errors explicitly signaled in software."
@prefetch(a, .{ .cache = .data, .rw = .read, .locality = 3 }); over @prefetch(a, .{});. This improves readability but most of all avoids latent, potentially catastrophic bugs in case the library ever changes its defaults."The lack of back-of-the-envelope performance sketches is the root of all evil." — Rivacindela Hudsoni
"There are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one errors." — Phil Karlton
snake_case for function, variable, and file names. The underscore is the closest thing we have as programmers to a space, and helps to separate words and encourage descriptive names. We don't use Zig's CamelCase.zig style for "struct" files to keep the convention simple and consistent.VSRState, not VsrState).latency_ms_max rather than max_latency_ms. This will then line up nicely when latency_ms_min is added, as well as group all variables that relate to latency.source and target are better than src and dest because they have the second-order effect that any related variables such as source_offset and target_offset will all line up in calculations and slices. This makes the code symmetrical, with clean blocks that are easier for the eye to parse and for the reader to check.read_sector() and read_sector_callback().main function goes first.At the same time, not everything has a single right order. When in doubt, consider sorting alphabetically, taking advantage of big-endian naming.
replica.pipeline vs replica.preparing. The former can be used directly as a section header in a document or conversation, whereas the latter must be clarified. Noun names compose more clearly for derived identifiers, e.g. config.pipeline_max.*const. This will catch bugs where the caller makes an accidental copy on the stack before calling the function.In-place initializations can assume pointer stability and immovable types while eliminating intermediate copy-move allocations, which can lead to undesirable stack growth.
Keep in mind that in-place initializations are viral — if any field is initialized in-place, the entire container struct should be initialized in-place as well.
Prefer:
fn init(target: *LargeStruct) !void {
target.* = .{
// in-place initialization.
};
}
fn main() !void {
var target: LargeStruct = undefined;
try target.init();
}Over:
fn init() !LargeStruct {
return LargeStruct {
// moving the initialized object.
}
}
fn main() !void {
var target = try LargeStruct.init();
}void trumps bool, bool trumps u64, u64 trumps ?u64, and ?u64 trumps !u64.defer statement, to make leaks easier to spot.index, a count or a size. These are all primitive integer types, but should be seen as distinct types, with clear rules to cast between them. To go from an index to a count you need to add one, since indexes are 0-based but counts are 1-based. To go from a count to a size you need to multiply by the unit. Again, this is why including units and qualifiers in variable names is important.@divExact(), @divFloor() or div_ceil() to show the reader you've thought through all the interesting scenarios where rounding may be involved.zig fmt.zig fmt do the rest.if statement unless it fits on a single line for consistency and defense in depth against "goto fail;" bugs.TigerBeetle has a "zero dependencies" policy, apart from the Zig toolchain. Dependencies, in general, inevitably lead to supply chain attacks, safety and performance risk, and slow install times. For foundational infrastructure in particular, the cost of any dependency is further amplified throughout the rest of the stack.
Similarly, tools have costs. A small standardized toolbox is simpler to operate than an array of specialized instruments each with a dedicated manual. Our primary tool is Zig. It may not be the best for everything, but it's good enough for most things. We invest into our Zig tooling to ensure that we can tackle new problems quickly, with a minimum of accidental complexity in our local development environment.
"The right tool for the job is often the tool you are already using—adding new tools has a higher cost than many people appreciate" — John Carmack
For example, the next time you write a script, instead of scripts/*.sh, write scripts/*.zig.
This not only makes your script cross-platform and portable, but introduces type safety and increases the probability that running your script will succeed for everyone on the team, instead of hitting a Bash/Shell/OS-specific issue.
Standardizing on Zig for tooling is important to ensure that we reduce dimensionality, as the team, and therefore the range of personal tastes, grows. This may be slower for you in the short term, but makes for more velocity for the team in the long term.
At the end of the day, keep trying things out, have fun, and remember—it's called TigerBeetle, not only because it's fast, but because it's small!
You don't really suppose, do you, that all your adventures and escapes were managed by mere luck, just for your sole benefit? You are a very fine person, Mr. Baggins, and I am very fond of you; but you are only quite a little fellow in a wide world after all!"
"Thank goodness!" said Bilbo laughing, and handed him the tobacco-jar.