Senior Engineering Interview Handbook / Chapter 57
Working in an Unfamiliar Codebase
A practical method for navigating unfamiliar code under interview conditions: orient from observable behavior, trace entry points and dependencies, read tests as evidence, preserve contracts, and make an ownership-correct change.
Preparing audio…
Audio edition
Working in an Unfamiliar Codebase
Page tools
Make the repository smaller
The prompt sounds almost trivial:
Add
displayNameto the account-summary response. Use the preferred name when it exists; otherwise use the legal name.
Then the repository opens. It has several account models, two API versions, a
generated client, background consumers, snapshots, and a directory named
services large enough to conceal every wrong turn. The requested patch may
be one line. Finding the line that owns the behavior is the exercise.
An unfamiliar-codebase round does not reward instant knowledge of a system you have never seen. It reveals how you reduce uncertainty. Can you find where outside behavior enters? Can you follow the data without reading the whole repository? Can you distinguish a public contract from an implementation detail? Most important, can you make a small change without being careless about what already works?
Begin with an observable behavior and keep narrowing until you can say:
entry point -> rule owner -> dependency -> output -> focused proof
That path is your working codebase. Everything else can remain unfamiliar for now.
Orient toward feedback
Before tracing account summaries, establish how this project tells you whether you are wrong. Read enough of the root documentation and package scripts to identify the language, framework, test command, formatter, and generated or vendored directories. Find the application or test entry point. This should be a brief orientation, not an architecture tour.
For the account task, you might learn that the service is TypeScript, API tests
run through npm test -- <path>, and src/generated/ is rebuilt from a schema.
Those facts immediately constrain the work: a generated response type is
evidence, but probably not an editing target; an API test can give you a short
feedback loop before the full suite.
Say what you are doing at the level of intent:
I have the test command and the repository shape. I am going to anchor on the account-summary behavior, find its entry point and nearest test, and run that test before I edit.
Running the focused test now establishes a baseline. A passing test tells you the working copy is healthy before your change. A failing test changes the task: you must determine whether the failure is pre-existing, environmental, or part of the requested behavior rather than quietly claiming it as your result.
Find the door the behavior uses
Filenames that sound relevant are guesses. Routes, exported functions, commands, event types, UI labels, error strings, and fixture values are addresses.
Start with the language visible at the system boundary:
rg "account summary"
rg "GET /accounts"
rg "preferred_name|legal_name|display_name"
rg "AccountSummary"
Suppose the search returns these files:
src/routes/accounts.ts
src/serializers/accountSummarySerializer.ts
src/domain/accountName.ts
src/generated/api.ts
test/api/accountSummary.test.ts
Do not open them all and hope relevance emerges. Start at the route or the API test, because either can show how the requested behavior enters the system. Then follow calls. In a CLI, the corresponding door might be the command registry; in a background system, a queue consumer; in a library, the exported function; in a UI, a route and event handler. The shape changes, but the question does not: where does an external action become this program’s work?
If the prompt’s words produce hundreds of matches, add an exact route, fixture
value, response key, or error message. If they produce none, search the
observable output rather than inventing an internal name. Broad nouns such as
account, service, and helper become useful only after a stronger address
has given them context.
Draw one path through the system
The route leads to a service, and the service passes an account to a serializer. The serializer produces the public JSON. A nearby domain helper already chooses the name shown elsewhere. The smallest useful map is:
GET /accounts/:id/summary
route parses id and calls account service
service loads account and current balance
accountSummarySerializer builds the response
accountName.displayName(account) owns preferred-name fallback
API test asserts the response contract
This scratch trace is valuable because it makes ownership visible. The route owns HTTP entry and orchestration. The serializer owns response shape. The name helper owns the fallback rule. The generated type describes a contract derived elsewhere. Once those roles are clear, adding fallback logic directly to the route begins to look wrong even though it would produce the requested JSON.
Trace only far enough to understand the behavior and its risks. For a response field, you need the source value, naming rule, serializer, schema or type, and contract test. For a validation change, follow input parsing to the invariant owner and error mapping. For a notification change, continue through the queue, preference rule, and delivery adapter; the controller that started the request is not the whole path.
Dependencies deserve attention when they can alter or outlive the behavior: database repositories, caches, HTTP clients, queues, clocks, feature flags, and side effects. Record them in the trace where they occur. A dependency map is useful because it exposes consequences, not because the interview requires an architecture diagram.
Name the promise before changing it
The current account response is:
{
"id": "acct_42",
"status": "active",
"balance": 7300
}
The request is additive: include displayName. That does not authorize a
cleanup of field names, a new null convention, or exposure of legal_name.
Before editing, state what remains true:
- existing keys and status values remain unchanged;
- the new key uses the repository’s established JSON casing;
- preferred name wins when present;
- legal name is the fallback already defined by the domain helper;
- no generated file is edited by hand.
Contracts appear in more places than public JSON. Function signatures, error codes, serialized events, database constraints, ordering, retry identity, and fixture meaning can all be promises. Tests are strong evidence of those promises, but not the only evidence. Read schemas, callers, migrations, and nearby implementations when the changed value crosses their boundary.
This is where experienced engineers resist the smallest textual patch. The following line inside the route is short:
displayName: account.preferred_name || account.legal_name
It also duplicates a rule, chooses JavaScript truthiness as missing-value policy, and teaches one caller how to name an account while other callers use the domain helper. A slightly different one-line patch belongs in the serializer:
displayName: accountName.displayName(account)
The safest small change is the one made at the narrowest correct owner, not the one with the fewest keystrokes.
Let tests answer specific questions
Read the closest tests before changing them. Their fixtures may reveal that an empty preferred name is treated differently from a missing one, that response keys are checked by snapshot, or that the serializer is shared by both API versions. A test is both a map of intended behavior and a guardrail against damaging it.
For this change, the proof should demonstrate three things:
existing account-summary fields remain present
preferred_name becomes displayName
missing preferred_name falls back to legal_name
If the domain helper already tests the fallback thoroughly, the API test does not need to reproduce every naming edge case. It needs to prove that the serializer delegates to the rule and exposes the result under the right public key. If no focused test exists, add the smallest one that fails for the missing behavior before implementing the change.
Run the account-summary test first after the patch. Then run the nearest group that covers the shared serializer or API contract. A full test suite may be an appropriate final check, but repeatedly launching it while you are still locating the behavior is slow feedback disguised as diligence.
Your closing claim should match the evidence you actually obtained:
The focused API test covers both name cases and preserves the existing response assertions. Because this serializer is public and shared, the next validation is the wider API contract suite.
“It should work” is not a verification result. Neither is claiming the whole system safe because one focused test passed.
Follow local boundaries without worshipping them
An unfamiliar repository has conventions that are not obvious from its folder names: where validation lives, how errors cross layers, how dependencies are injected, whether serializers are explicit, and which files are generated. Nearby code and tests usually reveal these conventions more reliably than a speculative redesign.
Fit the change to the system unless the local pattern is the cause of the problem. A codebase-navigation exercise is rarely permission to rename a module hierarchy or replace its dependency model. Opportunistic cleanup increases the proof burden and makes it harder to distinguish the requested behavior from your preferences.
There are two important exceptions to blindly following the nearest example. First, never hand-edit generated, vendored, or build-output files when a schema or generator input owns them. Follow the provenance to its source. Second, do not copy a local special case when a deeper rule owner already exists. Similar code can be evidence of convention or evidence of duplication; callers and tests help you tell which.
Async and side-effect boundaries require one more step of tracing. A request may return before a queued job runs. An event may have consumers outside the directory you are reading. A cache may preserve an old representation after the serializer changes. When the task touches those boundaries, search the event type, queue name, cache key, or client interface and state which work is synchronous, persisted, retried, or externally visible.
Keep the interviewer aligned with the trace
You do not need to read code aloud. Speak when your model changes.
At the beginning, name the anchor and feedback loop. When a search result matters, explain the ownership it reveals: “This serializer owns the public shape; the route is only a caller.” Before editing, state the preserved contract. After testing, distinguish proven behavior from remaining risk.
Uncertainty is useful when it leads to the next piece of evidence:
I do not know yet whether the fallback rule already exists. I am checking domain helpers and their tests before writing another version of it.
That is stronger than either silence or constant narration. It gives the interviewer your current model, the gap in it, and the action that will close the gap.
When the first trace goes nowhere, change evidence sources rather than poking the same file. Search the route table from a test name. Search callers from an exported symbol. Use a fixture value to find serialization. Inspect the schema that generated a type. If ownership remains ambiguous, compare one nearby behavior that already works.
Ask for a pointer when you can make the question precise:
I found the response type in generated code and the serializer that uses it, but not the schema that owns generation. Is that schema in this repository or supplied by another package?
Getting stuck is ordinary in unfamiliar code. Unbounded searching without changing your question is the failure mode.
Practise changing the path, not reciting a loop
Repeat the account-summary exercise with constraints that force the trace to deepen.
First, make displayName nullable in storage but non-null in the API. Decide
which layer owns the final fallback and prove it. Next, make the serializer
shared by two API versions, only one of which may add the field. Find the
version boundary before editing. Finally, emit account summaries through a
queue as well as HTTP. Determine whether the event contract should change and
how an older consumer would behave.
For each variation, leave a small artifact before writing code:
Observed behavior:
Entry point:
Rule owner:
Dependencies and side effects:
Contract preserved:
Files to change:
Focused proof:
Residual risk:
The artifact is not a script to perform during every interview. It trains the habit of making a repository legible through one consequence-bearing path.
The account patch becomes straightforward only after the reading has done its work. You entered through observable behavior, followed the value to the serializer, found that the naming rule already had an owner, preserved the public response, and chose tests that could prove the addition without claiming more than they observed.
You still do not understand the whole repository. You understand the part you are changing, the promises attached to it, and the evidence that will expose a mistake. That is enough context to make one safe change—and enough judgment to know when it is not.
Continue reading
Full table of contents