Someone hands you a repository. The person who wrote it is gone. There is no documentation, the tests are a folder with two files in it, and there is a production database with real customers behind it.
You have to answer one question before you can do anything else: what is actually in here?
This is the exact sequence I run. It takes about two working days on a mid-size NestJS and PostgreSQL codebase, and it ends with an architecture map, a ranked risk register, and a list of the things that will hurt you first.
Hour 0: do not read the code yet
The instinct is to open src/ and start reading. Resist it. On a codebase of any size you will spend three days building a picture that the repository can tell you in twenty minutes.
Start with what the project says about itself.
# What is this, and what does it think it runs on?
cat package.json | jq '{name, version, engines, scripts}'
# What is the actual shape of the thing?
tokei . # or: cloc .
git log --oneline | wc -l # how much history is there
git log -1 --format=%cd # when did work actually stop
That last one matters more than it looks. A repository whose last commit is four months old is a different problem from one that was being changed last week. The first is abandoned; the second has someone still touching it, and you need to find out who.
# Who worked on this, and when did each of them stop?
git shortlog -sne --all
# Which files changed most? That is where the risk lives.
git log --format=format: --name-only \
| grep -v '^$' | sort | uniq -c | sort -rn | head -30
Churn is the best free signal you will get. The files that change constantly are either the core of the business or the part nobody got right. Usually both.
Hours 1–4: the boundaries
A backend is easier to understand from its edges than from its middle. There are only four edges that matter, and each one is a place where the system touches something it does not control.
Every way in. Routes, event consumers, cron jobs, webhooks.
# NestJS controllers and their routes
grep -rnE "@(Get|Post|Put|Patch|Delete)\(" src --include=*.ts | wc -l
grep -rln "@Controller(" src
# Express, if it is that kind of codebase
grep -rn "app\.\(get\|post\|put\|delete\)\|router\.\(get\|post\)" src
# Scheduled work — these are the things that will surprise you at 3am
grep -rn "@Cron\|node-cron\|setInterval\|Bull\|Agenda" src
# Event consumers
grep -rnE "@(Event|Message)Pattern|consumer\.subscribe" src
grep -rn "\.on('message'" src
Every way out. Outbound HTTP, queues, third parties.
grep -rnE "axios\.|fetch\(|got\(|http\.request" src \
| grep -v test | head -40
grep -rlniE "stripe|twilio|sendgrid|s3|ses|firebase|segment" \
src --include=*.ts
The data. The schema is the truest description of the business that exists in the repository, because unlike the code, it cannot lie about what it stores.
# Prisma
npx prisma validate && wc -l prisma/schema.prisma
grep -c "^model" prisma/schema.prisma
# TypeORM / Sequelize
grep -rln "@Entity(\|sequelize.define" src | wc -l
# Or just ask the database
psql "$DATABASE_URL" -c "\dt+"
psql "$DATABASE_URL" -c "
SELECT relname, n_live_tup
FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 20;"
That last query is one of the most useful things you can run on an inherited system. Row counts tell you which tables are load-bearing and which were somebody's abandoned idea. A table with eleven rows and a table with four million rows deserve very different amounts of your attention.
The configuration. Every environment variable the code reads is a dependency on something outside the repository.
grep -rhoE "process\.env\.[A-Z_0-9]+" src | sort -u
Compare that list against .env.example. The gap is the set of things nobody wrote down — and the reason the next engineer cannot run the project locally.
Then compare it against what the host actually has set, which is the more revealing of the two diffs. Variables that exist in production but appear nowhere in the repository are undocumented integrations: a second payment provider, a feature flag somebody toggled once and never removed, a service that only exists in one environment.
# Code side
grep -rhoE "process\.env\.[A-Z_0-9]+" src \
| sed 's/process\.env\.//' | sort -u > /tmp/code-env
# Host side — whatever your platform exposes
heroku config -a your-app-name \
| awk 'NR>1 {print $1}' | tr -d ':' | sort > /tmp/host-env
# fly secrets list | awk 'NR>1 {print $1}' | sort > /tmp/host-env
# docker inspect your-container \
# | jq -r '.[0].Config.Env[]' | cut -d= -f1 | sort > /tmp/host-env
comm -3 /tmp/code-env /tmp/host-env
Left column: read by the code, missing from the host — the app is running on defaults nobody chose. Right column: set on the host, read by nothing — either dead configuration, or an integration living in a part of the codebase you have not found yet.
The schema in the repository is not the schema in production. Assume they disagree until you have checked, because in an inherited system somebody has almost always run a fix by hand at some point and never written the migration.
# Prisma 6 and earlier
npx prisma migrate diff \
--from-url "$DATABASE_URL" \
--to-schema-datamodel prisma/schema.prisma \
--script
# Prisma 7+ — --from-url and --to-schema-datamodel were removed
npx prisma migrate diff \
--from-config-datasource \
--to-schema prisma/schema.prisma \
--script
Anything that command prints goes straight to the top of the risk register, because the danger is not the drift itself — it is the next migration trying to reconcile it, and dropping a column someone added by hand at two in the morning during an incident.
Two cautions. Run it against a read replica or a restored snapshot rather than primary: migrate diff may want a shadow database depending on the connector, and that is not a surprise you want on someone else's production in your first week. And if you are not on Prisma, the ORM-agnostic version of the same check is to dump production's schema, run the repository's migrations into a scratch database, and diff the two:
pg_dump --schema-only --no-owner "$DATABASE_URL" > /tmp/prod.sql
createdb scratch
DATABASE_URL=postgres://localhost/scratch npm run migrate
pg_dump --schema-only --no-owner \
postgres://localhost/scratch > /tmp/repo.sql
diff /tmp/repo.sql /tmp/prod.sql
Both of these — the host-side env diff and the migration drift check — came from @launchgatecheck in the comments. Both belong here.
Hours 4–6: where AI actually earns its place
This is the part that used to take a week, and it is the specific reason a rescue can now be priced at three weeks instead of three months.
I run Claude Code over the repository to produce a first-pass architecture description: the module graph, the flow of a request from route to database and back, and a plain-language summary of what each module appears to be for.
What matters is how you treat the output. It is a draft, not a finding. An LLM reading an unfamiliar codebase is roughly as reliable as a smart contractor walking a building for the first time — very good at telling you where the rooms are, not qualified to tell you the wall is load-bearing.
So the rule I work by:
- Anything it says about structure — what calls what, which modules exist, where the boundaries are — I accept provisionally and verify by reading the file.
- Anything it says about behaviour — what this actually does at runtime, whether this is safe, why this exists — I verify against the code or a test before it goes anywhere near a document.
- Anything that becomes a finding in the risk register gets a file and a line number, and I have read that line myself.
The speedup is real and it is large. It is also entirely in the reading, not in the judgment.
Hours 6–12: the risk register
Now you go looking for what will hurt. Tooling first, because it is cheap:
npm audit --omit=dev
npx depcheck # dependencies nobody uses
npx ts-prune # exports nobody imports
npx semgrep --config=auto src # a genuinely good free security pass
Then the things tools do not catch, which are the ones that matter. I go looking for five specific failures, in this order:
1. Authorisation applied per-route instead of by default. Find every route, then find the ones with no guard. This is the single most common serious finding in an inherited Node codebase.
grep -rn "@UseGuards" src | wc -l # compare against your route count
If you have 140 routes and 96 guards, you have 44 questions to answer.
2. Secrets in the history. Rotating a key is easy. Knowing it leaked is the hard part.
npx @trufflesecurity/trufflehog git file://. --only-verified
git log -p -S 'sk_live' --all | head -50
3. Idempotency on anything that costs money. Payment webhooks, billing jobs, anything a provider will retry. Find the handler and ask what happens if it runs twice, because eventually it will.
4. Message handling that can lose data silently. In Kafka, the classic is committing the offset before the work succeeds — the message is marked done, the work never happened, and nothing anywhere reports an error. I have found this in production systems more than once. Check the order:
grep -rn "commitOffsets\|autoCommit" src
5. What the logs capture. Log a whole request body once and you are storing customer emails, addresses and sometimes card metadata in a system nobody treats as sensitive.
grep -rnE "console\.log|logger\.(log|info|debug)" src \
| grep -iE "req|body|payload" | head
Every finding gets four fields and nothing else: what it is, where it lives, how bad it is, and how long it takes to fix. A risk register without effort estimates is not a plan, it is an anxiety list.
| Ref | Finding | Severity | Fix |
|---|---|---|---|
| R-01 | Three admin endpoints reachable with a valid token but no role check | critical | 4 h |
| R-02 | Database credentials in git history, still valid | critical | 2 h + rotation |
| R-03 | Deploys overwrite in place; no path back to the previous version | critical | 1 d |
| R-04 | Payment webhook has no idempotency guard — retries double-charge | critical | 6 h |
Hours 12–16: can you run it, and can you undo it
Two questions, and they are the ones that decide whether the next three weeks are pleasant or miserable.
Can a new engineer run this locally from a clean machine? Actually try it. Fresh clone, follow whatever instructions exist, and write down every single thing you had to work out yourself. That list, cleaned up, is the onboarding guide — you get it for free by paying attention the first time.
What happens when a deploy goes wrong? Find the deploy path. Then find the way back. In a surprising number of inherited systems the answer to the second question is that there isn't one, and nobody had noticed because nobody had needed it yet.
Untested rollback is not rollback. It is a plan to find out.
What you have at the end
Two days in, you should be able to hand someone:
- An architecture map that matches reality, not intentions
- Every entry point, every external dependency, every environment variable
- Row counts showing which tables carry the business
- A risk register with severity, location and effort
- An honest answer on whether this is a rescue or a rewrite
That last one is the point. Most inherited backends are worth saving — they are undocumented rather than broken, and undocumented is a solvable condition. But some are not, and a founder is far better served hearing that on day five than on week eight.
The mapping is not the work. The mapping is what lets you tell the truth about the work.
I do this for teams who inherited a Node.js or NestJS backend and lost the person who built it — fixed price, 21 days. Details at rescue.jadtechlabs.com.
Top comments (2)
Two steps I'd add to hour 1-4, because both have bitten me on inherited Node apps:
An env var inventory.
grep -rhoE "process\.env\.[A-Z0-9_]+" src | sort -uand diff that against.env.exampleand whatever the host has set. The vars that are read in code but missing from the example file are usually the undocumented integrations (a second Stripe key, a feature flag someone flipped in prod once).Migration drift. The schema in the repo and the schema in production often disagree, because someone ran a hotfix by hand. With Prisma,
prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --scriptshows it in one command. Anything that shows up there goes straight to the top of the risk register, since the next migration may try to "fix" it.Both good additions — thank you.
On the env vars: diffing against
.env.exampleis in the post, but diffing against what the host actually has set is the part I left out, and it's the better half. The vars that exist in production but not in the repo are exactly the undocumented integrations you describe. I've found a second payment provider that way.The migration drift one I hadn't thought to include, and it belongs.
The repo schema and the production schema disagreeing because somebody ran a hotfix by hand is common enough in inherited systems that it deserves its own line in the risk register — and you're right that the real danger is the next migration trying to reconcile it.
One caveat worth adding for anyone copying that command:
--from-urland--to-schema-datamodelwere removed in Prisma 7. On v7+ it's:And on an inherited system I'd run it against a read replica or a restored snapshot rather than primary —
migrate diffcan want a shadow database depending on the connector, and that's not a surprise you want on someone else's production in week one.Adding both to the post. Appreciated.