Learn the tracer bullet method for reading a codebase you didn't write: trace one real request end to end, map the layers, ship a change, and get productive in days.
Every developer eventually inherits a codebase they didn't write — a new job, an acquisition, a legacy service nobody's touched in two years. The instinct is to open the repo and start reading files top to bottom, which is roughly as effective as learning a city by reading its phone book. After doing this more times than I'd like — joining teams, absorbing acquired products, and as CTO onboarding engineers into our stack at Shpper — I've settled on a method that reliably gets me productive in days, not months. It's built on one principle: stop trying to understand the whole thing, and trace one real thing all the way through.
I call it the tracer-bullet method, borrowing the term from The Pragmatic Programmer. A tracer round glows so you can see its actual path to the target and correct your aim. Reading an unfamiliar codebase works the same way: you fire one real, user-visible request through every layer of the system, watch where it actually goes, and let that single luminous path teach you the architecture. Everything below is a variation on that one move.
A large codebase is not a book. It has no beginning, and its meaning lives in the connections between files, not the files themselves. When you read linearly you spend your attention budget on code that may be dead, rarely-touched, or irrelevant to what you'll actually work on. You end up with a shallow, uniform familiarity with everything and a deep understanding of nothing — which is exactly the wrong distribution.
Real work concentrates in a handful of hot files. You want your understanding concentrated there too. So the fix is to be aggressively selective. You don't need to understand the codebase. You need to understand the paths you'll change, plus enough of the surrounding shape to not break things. That's a dramatically smaller target than "all of it," and it's the difference between onboarding in a week and flailing for a quarter.
There's also a cognitive-load argument here. Working memory is small. If you try to hold an entire service in your head, you hold none of it well. Trace one path and you build a single, vivid mental model you can actually reason about — then you reuse and extend it for the next area.
Before I read a single business-logic file, I answer three mechanical questions. They cost twenty minutes and save days.
How do I run it, and how do I make a change appear? If I can't build and hot-reload the thing, I can't learn from it — reading without a feedback loop is memorization, not understanding. Get the app running locally first, even if it means stubbing a service or pointing it at a staging backend. In Flutter this means flutter run with a working device or simulator and confirming hot reload actually reflects a trivial edit. On a backend service it means the process boots, connects to its dependencies, and responds to one request. Until you have that loop, everything downstream is guesswork.
What's the shape of the repo? I let the directory structure and dependency manifest tell me the architecture before any human does:
# What kind of project is this, and what does it lean on?cat pubspec.yaml # or package.json / go.mod / build.gradle# Where does the code actually live, and how big are the regions?tokei lib/ || cloc lib/ # lines-of-code per directory = attention map# Who touches what most? Churn points to the hot paths.git log --since="6 months ago" --name-only --pretty=format: \ | sort | uniq -c | sort -rn | head -40
That last command is the one I lean on hardest. Files with high recent churn are where the real work happens — they're your future workplace. Files nobody has touched in a year are either stable infrastructure or a swamp; either way, deprioritize them. Git history is the most honest architecture document in the repo: it can't lie about which files actually change, no matter what the README claims.
Who owns what, and where do decisions live? Skim the CODEOWNERS file, the top handful of contributors per directory (git shortlog -sn), and any architecture-decision records or docs/ folder. You're not reading them for content yet — you're building a map of who to ask when your tracer bullet hits a wall.
This is the core of the method. Pick one concrete, user-visible behavior — "user taps login and lands on the home screen," "webhook arrives and a record gets written," "cron job fires and an email goes out" — and follow it through every layer, top to bottom, refusing to skip a hop.
The trick is to let the tooling walk the dependency graph for you instead of guessing. Start at the entry point and use go-to-definition and find-references relentlessly. In a Flutter app I start at the button's onPressed, jump to the bloc/cubit or controller it dispatches to, into the repository, into the data source, out to the API client, and note where the response gets parsed, mapped, and cached.
// Tracer bullet: the login path, one hop at a time.// UI ──▶ Cubit ──▶ Repository ──▶ RemoteDataSource ──▶ ApiClientonPressed: () => context.read<AuthCubit>().signIn(email, pw);// │ go-to-definition leads here ▼Future<void> signIn(String email, String pw) async { emit(const AuthState.loading()); final user = await _authRepository.login(email, pw); // ── next hop emit(AuthState.authenticated(user));}When you can't find the next hop statically — because it's wired through dependency injection, a message bus, an event stream, or dynamic dispatch — set a breakpoint and run it. The debugger's call stack is the single fastest way to resolve indirection that grep can't see. A live stack trace collapses a day of "who actually calls this?" into thirty seconds, and it shows you the real runtime path rather than the one you assumed. This is also why Step 1 mattered: without a runnable app, this technique is unavailable to you.
A few things to watch for as your bullet travels:
UserDto from JSON becomes a domain User, a form becomes a request body. Note every mapping.By the time your one tracer bullet reaches the database, you've implicitly learned the layering convention, the naming scheme, the error-handling pattern, and the DI wiring — not as abstract facts, but attached to something concrete you can picture. That's the whole point: you traded breadth for a single path you understand deeply enough to safely modify.
Understanding that lives only in your head evaporates by tomorrow. I keep a scratch file open and jot the trace as a chain of hops, plus every question I couldn't answer:
LOGIN FLOWLoginScreen.onPressed -> AuthCubit.signIn [lib/auth/cubit/] -> AuthRepository.login [wired via get_it, see injection.dart] -> AuthRemoteDataSource [Dio; interceptor adds token — where refreshed??] -> POST /v1/auth/login [response cached in secure storage]Q: token refresh — is there a 401 interceptor? (yes: dio_interceptors.dart:88)Q: why two User models, UserDto vs User? (mapping in user_mapper.dart)Q: what invalidates the secure-storage cache on logout?
Those Q: lines are gold. They're the exact things a senior on the team can answer in one Slack message, and they force you to notice the seams — the places where conventions live. Batch them: nobody minds one thoughtful message with five specific questions, but everybody minds five interruptions.
There's a compounding benefit here. Each trace you write becomes a reusable artifact. After three or four flows you'll have a small personal wiki of corridors through the system, and the overlaps between them start to reveal the shared spine — the DI container, the base API client, the error types everything funnels through. When I onboard engineers now, I ask them to produce one of these maps in their first week. It doubles as proof they've actually traced the code and not just skimmed it, and it's genuinely useful documentation for the next new hire.
Reading tops out fast. The moment you have one full trace, make the smallest real change you can and get it merged — fix a copy string, add a log line, tighten a null check on the path you just traced. This forces you through the parts of the system reading never touches: the test suite, the CI pipeline, the linter config, the review conventions, the deploy process. You learn what "done" means on this team, which is half of being productive.
Small changes on a path you understand are also low-risk, which builds the political capital to make bigger ones. And the feedback is educational: a reviewer's comment on a two-line PR ("we don't log user emails" / "put this behind the existing feature flag") teaches you a norm you'd never find by reading. Nobody remembers the new hire who read a lot; they remember the one who shipped in week one.
The test suite is the one piece of documentation guaranteed to be executable and current — if it were wrong, CI would be red. When I want to understand what a module is supposed to do, I read its tests before its implementation. Test names describe intended behavior in plain language, and the setup/mocking reveals the module's real dependencies and boundaries — the mocks are a map of exactly what the code under test talks to.
// A good test name is a spec sentence.test('signIn caches the token and emits authenticated on success', () { ... });test('signIn emits error and does not cache when the API returns 401', () { ... });Read those two names and you already know the happy path, the failure path, and the side effect — before opening the implementation. A well-named test file is often the closest thing to a spec you'll get, and running a single focused test with a breakpoint is one of the fastest ways to trace a hop in isolation.
Although my examples are Flutter, nothing here is framework-specific. The entry point changes — an HTTP handler in a Go or Node service, a message consumer in an event-driven system, a main() in a CLI — but the move is identical: pick one real invocation, follow it hop by hop with go-to-definition and the debugger, map it, and ship a small change on that path. The tooling names differ (LSP go-to-definition, grep/ripgrep for the cases static analysis misses, the debugger for runtime indirection), the method doesn't.
Q: questions — those questions are exactly what a senior can answer in one message, and the map is reusable documentation.Getting productive fast isn't about raw reading speed or intelligence — it's about refusing to read broadly. Trace exactly one real request end to end, write the map down, ship a tiny change on that path, and repeat for each new area you're assigned. You're not trying to hold the whole system in your head — you never will, and neither does anyone else on the team. You're building a set of well-understood corridors through it, and that's all it takes to start doing real work in days.