Our team broke up with instant-legacy releases and you can too

As developers, we talk a lot about leaving a legacy through our code. But all too often, the "legacy" we end up leaving is a tangled mess of spaghetti that brings nightmares to the poor sap who has to maintain it next. It doesn‘t have to be this way.

At my company, we learned this the hard way after spending the better part of a year untangling a critical application that had become "instant legacy" — a term I use for code that‘s impossible to maintain mere months after it was originally written. The ordeal of refactoring that application was painful enough to force us to re-evaluate everything about how we approach writing and documenting our code.

In this post, I‘ll share our team‘s journey and the key insights we gained about preventing instant legacy code through living documentation. By following the steps our team has taken, you too can break the cycle of impossible-to-maintain code and take more pride in the legacy you leave behind.

The perils of instant legacy code

There are many definitions of "legacy code" out there, but my team‘s definition is simple: legacy code is any code that contains more technical debt than the time it took to originally write it.

By this definition, the codebase for our core internal business application had become "instant legacy" the moment it was deployed. It had hundreds of classes, even more tightly coupled dependencies, zero unit tests, and worst of all: no documentation.

When I joined the team, I was handed the unenviable task of implementing a change request in this application. It took me days to find all the relevant code, and weeks to fully understand it. Every time I thought I had a handle on it, I‘d discover a new wrinkle that broke my mental models and sent me back to square one.

I hacked together a change that I thought worked, but ended up breaking downstream processes in ways I couldn‘t have anticipated. All said and done, a change request that should have taken a week had stretched into a month-long slog.

Over the next year, I watched in growing horror as this scenario played out over and over again. No single developer on my team could hold the entire system in their head, and trying to describe it to each other just left us more confused.

By the time we finally convinced management to let us refactor the thing, my team had at least three dozen open change requests assigned to the application, some of them untouched for months. The refactor itself took 8 excruciating months. We worked seven-day weeks and ten-hour days, combing through incomprehensible logic and bashing our heads against cryptic bugs that sometimes took weeks to diagnose.

When it was finally over, we took a hard look at ourselves. Never again, we vowed. It was time for our team to break up with instant-legacy code for good.

Living documentation to the rescue

So how do you prevent instant legacy code? Many expert developers have proposed variations on the idea of "self-documenting code" — code so clean and well-structured that it explains itself. In our experience, self-documenting code is an ideal to strive for. But in reality, even the most elegantly composed code benefits from explicit documentation.

After our refactoring ordeal, our team decided that rigorous documentation would be our secret weapon against instant-legacy code. But we knew that approaching documentation in an ad-hoc way would leave holes. We needed a systematic approach that would hold up to the product development life-cycle.

After many hours of whiteboarding, we settled on what we call the "four-layer cake." We would maintain three persistent layers of documentation that evolve at different velocities relative to the code itself. A fourth layer would serve as a collaboration space for developers as they worked.

Here‘s how it breaks down:

Layer 1: Inline documentation

The most detailed layer of documentation resides directly in the codebase in the form of inline comments. We use documentation annotations like JavaDoc and XML comments to describe the purpose and functionality of every class, interface, method, and property.

These comments aren‘t just fluff — we use them to add contextual information that isn‘t obvious from the code itself, like:

  • Assumptions about the input data
  • The meaning and allowed values for every input parameter
  • Expected return values, including null and default values
  • Possible exceptions and when they‘ll be thrown
  • Major algorithmic steps for non-trivial logic

Aside from these standard API docs, we also use inline comments more conversationally. If one of us writes a chunk of code that‘s unavoidably hairy, we‘ll often leave a comment explaining the rationale behind it. We also use inline comments to leave fine-grained to-dos for improvements we intend to make later.

Because they‘re kept directly in the codebase, these comments have the highest velocity relative to code changes. The two should always be updated in tandem.

Layer 2: README guides

The second layer of documentation takes the form of a README file in each major code directory. If inline docs are a reference manual, the READMEs are more like user guides. They explain the purpose of the code in that directory at a high level and show how to invoke it through examples.

We write our READMEs in Markdown. This allows us to take advantage of built-in formatting support on platforms like GitHub, where the mere presence of a README file in a directory causes a nicely rendered guide to appear automatically.

Our READMEs typically cover topics like:

  • Configuration and dependencies
  • Common usage examples
  • Error handling and logging
  • Known limitations or outstanding issues

Like inline comments, README guides benefit from living directly in the codebase since they‘re quick to update alongside related code changes. We‘ve also found that they greatly reduce the time it takes new developers to ramp up on a part of our codebase.

Layer 3: Architectural docs

Zooming out further, our third documentation layer resides in a wiki that‘s entirely separate from our codebase. This is where we keep higher-level information that evolves more slowly than the code itself. We use it to connect the technical details embodied in the code to the intent behind them.

Our wiki typically houses documents like:

  • Requirements and specifications
  • Architecture diagrams and decision logs
  • Data models and database schemas
  • Environment setup and deployment instructions
  • Testing plans and results

We‘ve found tremendous value in keeping this documentation outside the codebase so that we can involve non-developers in its creation and maintenance. Our business analysts and product owners, for example, can directly update the requirements docs on the wiki when changes come in. This keeps everyone aligned and prevents requirements from drifting out of sync with the implementation.

We also use the wiki as a collaboration space for developers while working on tricky problems. We‘ll often thrash out technical designs on a wiki page before touching the code. This allows us to get feedback from the whole team early and maintains a record of design choices that we can refer back to later.

Layer 4: Release notes

The final layer of our documentation system is the most ephemeral, but also plays a crucial role. As code gets merged into our main branch, we use pull request and commit comments to leave a bread crumb trail explaining what changed, why, and how it affects the application.

During code reviews, we‘ll often have extended discussions in these comments to clarify the tricky parts of a changeset. After the code has been merged, the comments serve as a mini release log that we can skim through to refresh our memory on recent changes.

The transience of these comments is actually a feature. Unlike the other docs layers, we intentionally keep this one lightweight, informal, and not as carefully maintained. We rely on it as an asynchronous communication tool during daily development, and a quick historical reference, not as a source of truth.

The self-documenting code mirage

You might be thinking that if our team put as much effort into making our code clearer as we do documenting it, we wouldn‘t need so much documentation to begin with. I‘ll admit there‘s some truth to this.

Indeed, we constantly strive to make our code as intention-revealing and self-explanatory as possible. We refactor mercilessly towards clean abstractions, give our variables and methods meaningful names, and generally follow the SOLID principles of object-oriented design.

In doing so, we‘ve found that we naturally need fewer inline comments than we used to. But I‘ll also counter that truly self-documenting code is a mirage for any application complex enough to serve a real-world business need. No matter how clean the implementation, the abstract concepts, trade-offs, and historical baggage behind it need to be captured somewhere.

"The Mythical Man-Month" by Frederick Brooks Although Brooks wrote primarily about software project management, his insights on the inevitability of complexity in large systems are also relevant to documentation. (source)

This is where higher layers of documentation like architectural decision records and design docs really shine. They add a conceptual framework that helps future maintainers understand the forces that shaped the code, not just what the code does.

A documentation-first mindset

More than any specific technique or tool, the key to living documentation is a mindset that places documentation on equal footing with code. In our team, docs are a part of our definition of done for any task, no exceptions.

During code reviews, we scrutinize each others‘ doc changes just as closely as we do the code itself. If a reviewer can‘t understand a code change based solely on its associated docs, we treat it as a bug and send it back for revision.

We even go so far as to do "docs-only" reviews, where we walk through the docs for a new feature and provide feedback before a single line of code has been written. This keeps everyone aligned on the high-level approach and catches misunderstandings early.

When new team members join, they‘re given a thorough introduction to our documentation practices as part of their onboarding. And in their first few code reviews, we place special emphasis on making sure they‘re maintaining the docs to our standards.

By instilling these habits early, and continuously reinforcing them, we‘ve made living documentation a core part of our team culture. It‘s not an afterthought, but a primary artifact of our development process.

Breaking the cycle

Replacing a legacy application is always a painful undertaking. But it‘s downright agonizing when that legacy accumulated unintentionally, like scar tissue, through years of rushed changes and neglected documentation.

In our case, we had to learn this lesson the hard way. But by critically examining how we had ended up in that situation, we were able to break the cycle. Nowadays, when we make major changes to our applications, we do so with confidence that those who come after us will understand what we did and why.

More importantly, we‘ve redefined what it means to us to leave a legacy as developers. It‘s not about writing the most clever algorithms or using the shiniest new tech — it‘s about being good stewards of the codebases under our care and setting up future maintainers for success.

Our four layers of living documentation have been a key enabler of this mindset shift. They force us to think about maintenance up front and provide a systematic way to share context with future selves and teammates.

If you only take away one thing from our story, let it be this: the quality of your documentation will be the quality of your legacy. Invest accordingly.

Similar Posts