Taking over software you didn't write is disorienting and easy to do badly. Field notes on understanding a system before you touch it — and on resisting the rewrite that feels so obviously right and so often isn't.
- Your first reaction to unfamiliar code is "this is a mess", and it's nearly always misleading — you're seeing the result without the history.
- Get it running before you read a line, then map the whole before zooming into any part. Follow the data and follow the errors.
- Resist the rewrite. The ugly system works and has been tested against years of edge cases you haven't met yet — usually the right move is to stabilise and improve in place.
The comment read // DO NOT REMOVE. yes it looks wrong. it isn't. and it was sitting above six lines of genuinely baffling date arithmetic.
I'd inherited the system the previous week — a composite of a familiar kind of engagement, where a lone developer had built a business-critical tool over several years and then moved on, leaving behind software that ran the operation and a founder who couldn't read a line of it. There was, of course, a bug to fix and a small feature to add, both wanted before I'd had any real chance to get my bearings. That's the usual shape of it: you become responsible for software you don't understand, under time pressure, before understanding is available to you.
This is one of the more stressful situations in software work, and one of the most common. I do it regularly, because inheriting and stabilising other people's systems is a large part of consulting. Over time I've settled into a calm, deliberate method that resists the two temptations which wreck these projects: touching things before you understand them, and declaring the whole thing rubbish and rewriting it. That comment above the date arithmetic ended up being a small lesson in exactly why the method matters.
First, resist the urge to judge
Open an unfamiliar codebase and your first reaction will almost certainly be some flavour of this is a mess. The naming is odd. The structure is unfamiliar. There are things done in ways you'd never have chosen. This reaction is close to universal and close to always misleading.
The reason is simple: you're seeing the result without the history. That strange workaround might be papering over a bug in a third-party system that's still there. That awkward structure might reflect a business constraint you haven't met yet. That duplicated logic might exist because the two copies genuinely diverged for a reason someone learned the hard way. Code accumulates history, and from the outside you can read the code but not the years of decisions that shaped it.
So the first discipline is humility. Assume the previous developer was reasonably competent and working under constraints you can't yet see. Sometimes that assumption proves too generous — some code really is just poor. But starting from respect makes you a better reader, because it makes you ask "why might this make sense?" instead of "how could anyone do this?" The first question teaches you something about the system. The second only flatters you, and flattery doesn't fix bugs.
Get it running before you read a line
Before trying to understand anything by reading, get the system running. This sounds obvious and gets skipped constantly, and skipping it is a mistake that compounds.
A running system is one you can poke at, observe, and experiment with. You can change something and watch what happens. You can follow what actually executes rather than guessing from static text — and static text lies, or at least misleads, because the code path you assume is taken often isn't. Reading code you can't run is like studying a machine from a photograph: you'll learn plenty, but you can't watch it move, and the movement is where the understanding lives.
Getting it running also front-loads the discovery of the knowledge that was never written down. What does it depend on? What configuration does it need? Which external services does it quietly assume exist? These are precisely the things absent from any README, and the act of getting the thing to start drags them into the light one error message at a time.
# The real onboarding doc for an inherited system is the sequence of
# errors you hit trying to start it. Each one is undocumented knowledge.
$ npm start
Error: connect ECONNREFUSED 127.0.0.1:5432 # → needs a local Postgres
$ npm start
Error: SENDGRID_API_KEY is not defined # → needs a mail provider secret
$ npm start
Error: migration 0042 not applied # → schema state matters, run migrations
$ npm start
Listening on :3000 # → and now I know four things I didn'tAnd if you can't get it running at all, that too is vital information: it tells you the setup knowledge left with the previous developer, which is now a live risk you have to manage rather than a curiosity. Better to learn that on day one than during an urgent production incident.
Map the territory before exploring the detail
Once it runs, resist diving into individual files. Build a high-level map first. You want the shape of the whole system before you understand any one part of it, because a part only makes sense in the context of the whole.
A few questions orient you quickly:
- What are the main parts, and how do they relate? Most systems have a handful of major pieces. Name them before drowning in the detail of any single one.
- Where does data come in, and where does it go out? Following data through a system reveals its real structure better than almost anything else. Trace it end to end.
- What does it depend on? The external services, libraries, and databases it leans on tell you a great deal about what it does and where its risks live.
- How does a typical request or task flow through it? Pick one common operation and follow it all the way through. That single trace teaches more about how the system fits together than hours of reading files at random.
For the inherited tool, this produced a rough sketch on paper before I'd read any single file closely:
┌───────────┐ ┌──────────────┐ ┌───────────────┐
│ web form │ ─▶ │ API server │ ─▶ │ Postgres │
└───────────┘ └──────┬───────┘ └───────────────┘
│
▼ nightly
┌──────────────┐ ┌───────────────┐
│ batch job │ ─▶ │ 3rd-party API │ ◀── the timezone culprit
└──────────────┘ └───────────────┘With that map in hand, individual pieces of code made sense in context. Without it, I'd have been reading fragments and guessing how they connected — which is how you build a confident, wrong mental model that costs you a fortnight later.
Follow the data, follow the errors
Two threads pull you through an unfamiliar codebase faster than anything else: the data and the errors.
Following the data means picking one piece of information — an order, a user, a transaction — and tracing its entire journey. Where does it enter? How is it transformed? Where is it stored? Where does it leave? This walks the real spine of the system and connects the parts into a whole, because moving and transforming data is fundamentally what the system is for.
Following the errors means studying how the system handles things going wrong. Error handling is wonderfully revealing, because it's a map of past pain. Where the original developer added careful checks, they had been bitten before. Every defensive try/catch, every oddly specific guard clause, marks a spot where reality once bit hard enough to leave a scar in the code:
// Read this as archaeology. Someone wrote each guard the day after an incident.
try {
const rate = await fetchExchangeRate(currency);
// The provider returns 200 with an empty body during their nightly maintenance.
// Learned this the expensive way — treat empty as "retry later", not "rate is 0".
if (!rate) throw new RetryableError("empty rate response");
return convert(amount, rate);
} catch (err) {
if (err instanceof RetryableError) return queueForLater(amount, currency);
throw err;
}The defensive code is a more honest history of the system's fragilities than any documentation would be, because it was written in response to real events rather than good intentions.
Change something small and safe
At some point reading gives diminishing returns and you need to make a change to understand for real. The trick is to start with something small and low-risk — a minor fix, a tiny addition — and use it to learn the entire loop of working here.
That first small change teaches you an enormous amount that reading never will. How do you run the tests, if there are any? How do you deploy? What breaks when you touch things, and how does it break — loudly, or silently three days later? You're not merely making the change; you're measuring the blast radius of working in this codebase at all. Far better to discover there are no tests and deploying is terrifying while fixing a typo than to discover it halfway through an urgent production fix with everyone watching.
Write down what you learn
As understanding accumulates, write it down. Not elaborate documentation — just notes capturing what you've worked out. How the parts fit. Where the risky bits are. What surprised you. The four setup steps that weren't written anywhere and that you reconstructed from error messages.
This matters twice over. It cements your own understanding, because writing something down forces you to actually understand it rather than vaguely sense it. And it means the knowledge you're painstakingly rebuilding doesn't walk out of the door again with you. Part of why you're in this situation is that the last person's knowledge left when they did. Breaking that cycle is a small, cheap kindness to whoever inherits this next — quite possibly a future version of you who has forgotten every word of it.
Resist the rewrite
And now the biggest temptation of all. Once you understand an inherited system well enough to see its flaws, you will want to rewrite it. It's almost irresistible: the existing code is imperfect, you can see exactly how you'd do it better, and a clean slate is intoxicating.
Resist it, at least at first. That imperfect, ugly system has one decisive advantage over your imagined rewrite: it works, and it has been tested against years of real-world edge cases you don't yet know exist.
Every strange bit of it that made you wince might be handling a real case you'll otherwise rediscover the hard way — by breaking it, in production, in front of the customer it protects.
The DO NOT REMOVE date arithmetic was exactly this. A clean rewrite by someone who hadn't learned about the broken downstream timezone would have "tidied it away" and corrupted every date in the system. Rewrites routinely take far longer than expected and reintroduce, one painful incident at a time, bugs that were quietly fixed years ago. The knowledge encoded in ugly working code is real, and a rewrite discards it wholesale.
Usually the better path is to understand the system, stabilise it, and improve it incrementally — refactoring in place where it genuinely helps, replacing pieces only when there's a clear case for that specific piece. Sometimes a full rewrite really is the right call, but that should be a considered decision made after you fully understand what you have, not a reflex triggered by the discomfort of unfamiliar code.
Inheriting a codebase is uncomfortable. It's worth remembering that the discomfort is not evidence the code is bad. It's just the feeling of not understanding it yet. Do the patient work of understanding — get it running, map it, follow the data and the errors, change something small, write it all down — and most inherited systems turn out to be far more sensible, and far more salvageable, than they first appear. The ones that made you wince on day one are often the ones quietly holding the business together.