Debugging Code You Did Not Write
Find the entry point, follow the data, find the state, find the side effects, understand the boundary. Five questions that turn an unfamiliar failing system into a path you can put observations on — without first understanding all of it.
The situation, the reflex, and why it stalls
Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.
Something is failing in code you have never read and there is no time to learn it all. What do you find first, and in what order?
Payment failed, in a codebase you joined last week. You know where the message is rendered and nothing else. The person who wrote the checkout module has left, and the ticket is yours.
Search the codebase for the error text and start reading around every hit. It feels targeted — the message is the one concrete thing you have — and it gives you a file to open.
The message is rendered in one place and produced by any of a dozen causes; reading around the render site teaches you the UI's error handling and nothing about which failure occurred.
- The message is rendered in one place and produced by any of a dozen causes; reading around the render site teaches you the UI's error handling and nothing about which failure occurred.
- Without the entry point, you cannot say which code runs for a pay request and which is dead, so every file that mentions payments looks equally relevant.
- Without knowing where state lives, you cannot tell whether the failure is in a computation or in a stale row; without knowing the side effects, you cannot tell whether the provider was called; without the boundary, you cannot tell whether the failure is even yours.
- The session becomes "understand the whole checkout module before touching anything", which is reading without a question, and the ticket waits.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- Ask five questions of the code, in an order where each answer narrows the next. Where does execution enter for this request? What data comes in, and where does it go? Where is state read and written? What side effects happen — external calls, writes, messages? Where is the boundary between this code and what it depends on?
- Each answer gives you a place to put an observation. The entry point gives a first log line; the data path gives points to inspect a value; the state gives rows to read; the side effects give dashboards to check; the boundary gives the first place the failure might not be yours.
- Then debug as in Debugging Is Problem Solving: hypothesis, prediction, cheapest distinguishing observation. The five questions do not find the bug; they build the map the debugging loop runs on, and they build only as much of it as the request touches.
- Whatever you learn, write onto the map. Debugging unknown code is reading it with a purpose, and the map is what stays after the ticket closes.
Five questions, in an order that narrows
The order is not arbitrary. The entry point tells you which data to follow; the data path shows you where state is touched; the state and side effects together show where the path leaves the process; and the boundary is where responsibility — and usually the cause — changes hands.
- 1Entry point
The route, handler or job that runs for this request; one log line to prove it.
fails by Starting at the render site of the message, which every cause passes through.
- 2Follow the data
Input → each transformation → output; the assumption at each hand-off.
fails by Reading functions in file order instead of call order.
- 3Find the state
Tables, caches, in-memory structures read or written; the actual rows for the failing case.
fails by Assuming the code is stateless because no variable is named "state".
- 4Find the side effects
Every call that leaves the process, and the evidence each leaves behind.
fails by Checking only the side effects you already suspected.
- 5Understand the boundary
Which steps are yours and which belong to a dependency or an external system; on which side is the data first wrong.
fails by Stopping at "it is the provider" without checking what we sent it.
Each step produces a place to put an observation. The debugging loop then chooses among them by how much each distinguishes.
The path, with the observation points marked
Drawn for the store's pay request, the map is small: an entry, a handful of transformations, two tables, one external system. Every node is somewhere an observation can be made, and the boundary nodes are where the question "is this ours?" gets asked.
Where the reading goes wrong
Each of the five questions has a way of being answered badly, and each bad answer sends the debugging loop to the wrong place. The table pairs them.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Entry point taken to be the render site | Hours in the UI error handling | The message is the last step of every failure, not the first of any | Find the handler for the request; put the first observation there. |
| Data followed by file, not by call | A list of functions with no order | The tree is alphabetical; the data path is not | Start at the entry and follow each call; ignore files not on the path. |
| State assumed from the code | The "impossible" input | The row was written by an older version of the code or by another module | Read the actual rows for the failing case (Source of Truth). |
| Side effects checked selectively | The provider is blamed; the provider was never called | Only the suspected side effect was verified | List every call that leaves the process and check each one's record. |
| Boundary treated as a verdict | Ticket closed as "provider issue"; customers still failing | The far side of the boundary was located and the near side's handling of it was not read | Ask what our code does with the far side's answer (Treating External Systems as What They Are). |
How to do it
Most important first.
- Entry point: find the route or handler for the failing request, not the render site of the message. Put a log line with the request id there and confirm it fires for the reproduction.
- Follow the data: read the input at the entry, then follow it through every transformation until the response. At each hand-off, ask what shape the next function assumes (Follow the Data).
- Find the state: which tables, caches or in-memory structures the path reads and writes. Read the actual rows for the failing request's order and cart (What Information Changes Over Time?).
- Find the side effects: every call that leaves the process — the provider, email, a queue. Check each one's record of whether it was reached.
- Understand the boundary: which of the above are yours and which belong to a dependency or an external system. The first boundary where the data is already wrong is where the reading stops and the cause is near (Where Does My System End?).
Worked on a concrete problem
The move has to produce something. This is what it produced.
- Entry:
/checkout/pay→payHandler. Data: the request carries a cart id; the handler loads the cart, builds order lines, calls the gateway, marks the order. Log at entry confirms it fires. State:carts,orders,payments; the failing request's cart row exists and its items column holds a JSON object keyed by product id. Side effects: the provider call and an email. Provider dashboard: no attempt. Boundary: the ORM is loading the cart; the gateway wraps the SDK. The data is already the "wrong" shape when it leaves the ORM — so the question moves to whoever writes the cart, which is the cart module, which was refactored last week. - The same five questions on a different failure: "Payment failed" only for customers with a saved card. Entry: same handler. Data: a saved-card token replaces the card fields. State:
payment_methodstable. Side effects: the provider is called and answers with a token-expired code. Boundary: the provider's tokens expire and ours are never refreshed. The bug is on our side of the boundary, but it is a policy gap rather than a code defect, and the fix is a decision about refreshing, not a patch. - What was not read in either case: the cart UI, the admin, the catalog, the rest of the checkout module. Two tickets closed in a codebase still mostly unread, with the map two entries longer.
How you know it worked
What now exists that did not before, and what question you can now ask.
- You can name the entry point, the data path, the state, the side effects and the boundary for the failing request, and you have an observation at each.
- You know which parts of the path are yours and which belong to a dependency or a provider.
- The debugging loop is running on a map rather than on a file tree.
- The parts of the codebase you did not read are named as not read, not assumed understood.
The questions you can now ask
The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.
- ?Where does execution enter for this request, and can I confirm it with one observation?
- ?What data comes in, what is it transformed into, and what does each step assume about its input?
- ?Where is state read and written on this path, and what do the actual rows say for the failing case?
- ?Which side effects should have happened, and did each one?
- ?Where is the boundary between my code and what it depends on, and on which side is the data first wrong?
What can go wrong
- Answering the five questions for the whole module instead of for the failing request. The questions are about one path; the module is context you may never need.
- Skipping the state question because the code "looks stateless". A handler that reads a cart row is stateful at the row, and the row is where a stale shape hides.
- Stopping at the boundary. "The data is wrong when it comes out of the ORM" is a finding, not a cause; the cause is on the writing side, and the reading continues there.
- Not recording. Two tickets later the same five answers are rediscovered from scratch by the next person, or by you.
- Five questions asked properly take longer than one lucky search. When the message is specific and the hit is the cause, the search wins.
- The map built this way is shaped by the tickets you happened to get; it is accurate where it exists and blank elsewhere, and it never becomes the overview a designer would draw.
- Debugging by boundaries can stop at "not our side" prematurely; a provider returning an unexpected code is still a case our code has to handle, and the ticket is not closed by locating the cause outside.
- "This is just reading a codebase with a bug attached." The order differs: reading to understand starts at the README; reading to debug starts at the entry point for one request and reads outward only as far as the failure requires.
- "Finding the boundary means finding the culprit." The boundary is where responsibility changes hands. The data being wrong on the far side tells you where to read next; it is not a verdict.
- "I should understand the module before I change it." You should understand the path you are changing and what it touches. The module as a whole is a different, larger question.
Where this applies
Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.
- GENERALEntry, data, state, side effects, boundary — the same five for a web handler, a batch job, a UI component or a CLI; what counts as entry and state changes.
- TEAM-SPECIFICOn a team where the author is available, the fastest first observation is a question to them; the five questions are still what you ask, but the answers come in a conversation, and the map is theirs to correct (Asking People).
- ILLUSTRATIVEThe departed author, the JSON cart column and the expired saved-card token are invented to show the five questions producing a place to look; no real system is described.
Where the depth lives
This domain asks the question and hands the answer off by name.
- — The manifesto's layers page at /manifesto/layers is the same five questions asked of a stack rather than of a request: where does control enter, what does each layer hand to the next, where does it leave your code.