On Complex Software
Tracer Bullets
One of the most common failure modes in software development is building large pieces of a system in isolation, only to discover late in the process that they don’t quite fit together or they don’t actually solve the problem that you set out to solve.
An idea that helps avoid this comes from The Pragmatic Programmer: tracer bullets.
I’ve read the book several times over the years, and on a recent reread this concept stood out to me again because it maps closely to a development style I’ve found increasingly valuable in practice — building small end-to-end slices of a system rather than large isolated layers.
The principal logic behind tracer bullets is to get some small, usable, testable chunk of the final system implemented quickly and visbily. A rather agile approach to development, one might think. It is generally agreed that complex systems evolve from simple systems that work, Gall’s law, and as such when developing software we should be aiming to implement small working chunks that eventually evolve into more complex features or workflows.
Vertical Slices
When building software we typically have various layers that require implementing:
- User interface
- Authorization
- View logic
- Application logic
- Data model
- Database
The difference becomes clearer if you visualise how work moves through the system. One way to structure work is to build these layers independently. For example:
- PR 1: Data models
- PR 2: Application logic
- PR 3: View logic
This is effectively horizontal development — building out layers of the system in isolation.
I would argue that this approach is often unnecessarily restrictive. Feedback loops become longer and code reviews become harder, because the reviewer cannot see how the data model will actually be used or how application logic ties into a view. Everything only really makes sense once the final PR “turns on” the feature.
A more pragmatic approach is to build a vertical slice instead: a small, working, end-to-end flow that passes through each layer of the system. It may be simple and incomplete, but it works.
flowchart LR subgraph PR1["PR 1: Minimal End-to-End Slice"] A[Database Model] B[Application Logic] C[API / View] D[Simple UI] end A --> B --> C --> D subgraph PR2["PR 2: Extend Behaviour"] E[Validation] F[Permissions] end C --> E B --> F subgraph PR3["PR 3: Improve Feature"] G[Edge Cases] H[Better UX] end E --> G D --> H
Users, QA, and product can start interacting with it immediately, and smaller, more easily reviewed subsequent PRs can incrementally introduce more complex behaviour on top of a tested base. This is pretty close to the idea of tracer bullets outlined in “The Pragmatic Programmer”: establish a working path through the system early on and then refine and expand over time.
So what?
At Ben some developers were already working in this way, however many PRs would still fall into one of two categories:
- Layered development
- PR 1: Models
- PR 2: Application logic
- PR 3: View logic
- Large feature PRs
- A 1–2k line PR containing an entire complex feature with partially tested edge cases.
The problem with layered development is that it’s difficult to see how the different pieces link together until the final PR. The problem with very large PRs is that it’s hard to hold all the relevant context in your head at once. This makes reviewing more difficult and makes it easier for edge cases to slip through.
Large PRs also take longer to create, longer to review, and ultimately delay getting the feature into the hands of the person it is supposed to help.
Now, we are trying to instill the mentality of vertical slice development into the engineering team. Even within a couple of weeks we have seen noticeable impact on our productivity:
- Lower average PR size
- Shorter time spent in review
- Lower regression rate
- Faster feature uptake
In practice, tracer bullets are less about methodology and more about mindset. Instead of trying to design and implement large parts of a system in isolation, aim to get something small working across the entire stack as early as possible. You’ll get a better read on whether your idea will actually solve the problem that needs addressing before going too far down the rabbit hole. Once that thin slice exists, the rest of the system can grow around it in small, understandable increments.
Whenever I’m unsure how to approach a new feature or untangle some legacy code, I try to fall back on a simple question: What is the smallest end-to-end slice of this system that could actually work?
More often than not, building that slice first leads to better architecture, faster feedback, and far simpler code.
Or, to paraphrase John Gall: complex systems evolve from simple systems that work.