The hardest case: no tests, no architecture decision records, no diagrams. The tactics shift from consuming documentation to producing it as a byproduct of exploring.
Here’s what that actually looks like, walked through on one example: you’ve just joined a team maintaining Ledger, an internal core-banking service written in Go. Nobody’s written a README beyond make dev. You have a week before you’re expected to ship anything.
Start outside the code
Use the product as a user before you read a line of source. Check the error monitoring dashboards, Sentry, Datadog, early: they show where the system actually breaks, which tells you more than reading happy-path code ever will. The production error log is an honest account of what the system struggles with. The codebase only shows you what the engineers thought would happen.
You log into Ledger’s staging environment and submit a test transfer. It works. Then you open Sentry and sort by volume, and the top issue is TransferHoldExpiredError, fired about 40 times a day. Nobody mentioned this in onboarding. You don’t know what it means yet — but you now have a specific, real question to carry into the code instead of reading it cold: what puts a hold on a transfer, and why does that hold expire?
Find the skeleton first
Read the router and entrypoint files; they’re the table of contents. Then read the data model, schema, migrations, entity types, before you read any logic. A schema with transfers, holds, and settlements tells you more about the business than a thousand lines of service code. The schema is the domain model. Everything else is mechanics.
cmd/ledger/main.go mounts three route groups: transfers, holds, settlements. That’s the table of contents — three nouns, in that order, is probably the shape of the workflow. Then migrations/0007_create_holds.sql shows a transfers table with a status enum, a holds table with expires_at and a status of its own, and a settlements table linked to a transfer_id. You didn’t know holds were a separate concept from transfers until you read the schema. Now the Sentry error from step one has a home: something owns holds.expires_at, and something checks it.
Trace one complete journey
Pick the most important user action and follow it from UI event to storage write and back. Not two paths, one. Going deep on a single thread gives you the shape of the system: what layers exist, how they talk to each other, what the conventions are. Once you have that shape, every other path reads faster.
You follow “submit a transfer” end to end: POST /transfers in internal/transfers/handler.go calls TransferService.Create(), which calls HoldService.Place() before it ever writes the transfer row. Place() creates a Hold with ExpiresAt: time.Now().Add(15 * time.Minute) and flags the funds as unavailable. Nothing in this path explains what happens after 15 minutes — that’s outside this one journey, which is fine. You’ve learned the shape: transfers don’t move funds directly, they go through a time-boxed hold first.
Extract decisions from git history
When ADRs don’t exist, the git log is often the only record of why things are the way they are.
git log --oneline --follow -- internal/holds/service.go
git show <commit-hash>
The commit that introduced a design choice usually holds the most valuable context in the whole codebase. Engineers explain their reasoning while making a change, rarely after. Read the last twenty or thirty merged PRs too; they show current direction, active conventions, and what kind of change counts as routine versus exceptional.
The log on internal/holds/service.go has one commit that answers everything: “Add 15-min hold timeout so flagged transfers don’t block funds indefinitely — see INC-204.” Now you know the 15-minute window isn’t arbitrary, it’s a scar from a specific incident, and TransferHoldExpiredError is exactly what’s supposed to happen when a hold’s review window lapses. The open question shifts from “what is this error” to “is this error rate of 40/day normal, or is something failing to clean up after itself.”
Draw your own diagram
Drawing forces synthesis. Whatever you can’t draw is whatever you don’t understand yet. These sketches become the missing architecture diagrams, and you’re the best-positioned person to write them right now, because you just experienced not having them. Write what you needed.
You sketch it: Transfer (pending) → Hold (active, 15min TTL) → [cleared → Settlement] or [expired → funds released]. Drawing it forces the question you’d been skating past: what actually releases the funds when a hold expires? Nothing you’ve read so far does that. That gap is the most useful thing the diagram produced.
Mine the invariants from undocumented code
They exist even unstated: database constraints (NOT NULL, UNIQUE, foreign keys), validation at system boundaries, transaction boundaries (what operations always get grouped together), guards at the top of functions, assertions buried in a sparse test suite. These are the system’s real guarantees. They’re just not written down as such.
The migration has holds.expires_at TIMESTAMPTZ NOT NULL and a CHECK constraint tying status = 'active' to expires_at > created_at. That’s the database asserting a hold can’t be active without a real expiry — but nothing in the schema enforces that an expired hold’s status actually flips to 'expired' or that its funds get released. The invariant “expired holds release their funds” exists only as an assumption in someone’s head, not as a constraint anywhere. That’s usually where the bug lives.
Interview the data
With read-only production access, run queries against real data and look at the actual shapes. This surfaces assumptions baked into the model that never show up in code: nulls in columns that were supposed to be non-null, states that were meant to be temporary, foreign keys pointing nowhere.
SELECT count(*) FROM holds
WHERE status = 'active' AND expires_at < now();
327 rows. Holds that are long past their expiry, still marked active, still blocking funds. There’s no cron job or background worker anywhere in the codebase that transitions expired holds — the code assumed something would flip that status and nothing does. Those 327 phantom holds are quietly locking up real customers’ money, and the Sentry error from day one was the visible symptom of customers hitting funds that should have been released.
Do something small
Find a small bug or a documentation gap and fix it. Making a change and watching what breaks teaches the codebase faster than reading alone. The friction you hit tells you exactly where the system resists change.
You write a small job that runs every minute, finds active holds past ExpiresAt, flips them to expired, and releases their funds — then a characterization test capturing exactly how available balance behaves today, before you touch the release logic:
func TestReleaseExpiredHolds_ReleasesFundsAfterExpiry(t *testing.T) {
hold := seedHold(t, HoldParams{Status: "active", ExpiresAt: time.Now().Add(-time.Minute)})
releaseExpiredHolds(context.Background(), db)
got := getHold(t, hold.ID)
if got.Status != "expired" {
t.Errorf("hold status = %q, want %q", got.Status, "expired")
}
}
It’s a two-hour fix. It also means that by the end of week one, you understand Ledger’s core domain model better than most of the reading-only onboarding could have taught you, because every tactic above pointed at the same gap from a different angle before you ever wrote a line.