Understanding the codebase
A repository can be easy to describe and difficult to understand. It has an API, a database, some workers, and a collection of shared packages. That description says little about where a particular decision is made or what happens when an operation fails halfway through.
An AI assistant can produce an architectural overview from a directory tree. Names such as controllers, services, and repositories suggest a familiar arrangement. The explanation may be a useful first orientation. Its usefulness becomes more substantial when it can connect those names to behaviour the engineer needs to understand.
For an unfamiliar codebase, a question such as “Where does a notification get sent, and what has already happened by then?” gives the conversation a concrete direction. It asks for a path through the application, with decisions and side effects along the way.
Follow one behaviour through the repository #
We already examined duplicate notifications using logs and targeted observations. Understanding the implementation means finding the code that produces those observations and following its dependencies.
In a simplified repository, that path might cross an API handler, an event publisher, a worker registration, a notification handler, and a provider adapter. The names and snippets below are illustrative. An investigation in a real repository would need to establish each connection from the checked-out source.
The relevant part of that repository could look like this:
src/
api/request-handler.ts
notifications/
publish.ts
job.ts
handle-notification.ts
email-adapter.ts
create-email-adapter.ts
workers/notifications.ts
config.ts
test/
handle-notification.test.ts
This tree supplies places to investigate. The connections come from the code. For example, request-handler.ts calls the publisher after updating a request. The publisher places a job on the queue named notifications. That matches the queue consumed in workers/notifications.ts. The simplified excerpts below start at the job contract; queue and storage implementations are omitted.
// notifications/job.ts
export interface NotificationJob {
id: string; // Identity of the intended notification, preserved on retry.
customerId: string;
template: string;
data: Record<string, string>;
}
The handler declares its dependencies so we can follow what each operation means:
// notifications/handle-notification.ts
import type { NotificationJob } from "./job.ts";
import type { EmailAdapter } from "./email-adapter.ts";
interface Dependencies {
email: EmailAdapter;
recipients: { find(id: string): Promise<{ address: string }> };
audit: { record(id: string, outcome: "sent"): Promise<void> };
render: (template: string, data: Record<string, string>) => string;
}
export function createNotificationHandler(deps: Dependencies) {
return async function handleNotification(job: NotificationJob) {
const recipient = await deps.recipients.find(job.customerId);
const message = deps.render(job.template, job.data);
await deps.email.send(recipient.address, message);
await deps.audit.record(job.id, "sent");
};
}
This function establishes an order: recipient lookup, rendering, sending, and audit recording. It also reveals a detail that a broad architecture summary could miss. The recipient address is resolved during processing, so understanding which address is used requires inspecting recipients.find. The job itself carries a customer identifier.
The function leaves other questions open. The queue acknowledgement is absent. So are retry handling and the definition of a successful send. Those behaviours may belong to callers or imported dependencies.
A useful next investigation is therefore specific:
Which code invokes this handler, and how does the caller treat a resolved or rejected invocation? Show the call site and identify any behaviour delegated to a library.
The response now has a claim to substantiate. It needs a caller, a registration, or another concrete connection. A file with a plausible name is only a candidate until that connection is established.
Wiring establishes the path #
Following a symbol can lead to an interface with several implementations. An email.send call might reach a production provider, an in-memory test adapter, or a development logger. Reading one implementation explains what that implementation does. Establishing which one runs requires examining how the application is assembled.
The adapter contract states what the handler expects. This example defines success as provider acceptance; delivery to the recipient is outside this interface:
// notifications/email-adapter.ts
export interface EmailAdapter {
send(address: string, message: string): Promise<void>;
}
The factory createEmailAdapter selects an implementation of this interface. The worker entry point imports that factory and the handler. Other dependencies in this excerpt are initialised during startup:
// workers/notifications.ts
import { createNotificationHandler } from "../notifications/handle-notification.ts";
import { createEmailAdapter } from "../notifications/create-email-adapter.ts";
const email = createEmailAdapter(config.emailMode);
const handler = createNotificationHandler({ email, recipients, audit, render });
queue.consume("notifications", async (delivery) => {
await handler(delivery.body);
await delivery.ack();
});
This adds evidence about acknowledgement: the application calls ack after the handler resolves. If the handler rejects, execution does not reach that line. What happens after the rejection still depends on the surrounding queue client and its configuration.
The adapter selection introduces another dependency. To explain production behaviour, the conversation needs the meaning of config.emailMode and the value supplied in that environment. A default in application code may be overridden by deployment configuration. The checked-out repository may also differ from the revision currently deployed.
These distinctions make a difference when asking an assistant to explain a system. “This function sends email” is supported by the local call. “Production uses this provider and retries failed sends three times” requires additional evidence about wiring, configuration, and runtime behaviour.
If a dependency owns that behaviour, its installed version and corresponding documentation become part of the investigation. Inferring retry semantics from a method name leaves a significant gap in the explanation.
An explanation changes with the evidence #
Suppose the engineer first supplies the job contract, handler, and adapter interface. An illustrative response might be:
Assistant: The handler sends the notification and records it as sent, preventing a later delivery from sending it again.
The first part describes the operations shown. The claim about preventing another send adds behaviour that is absent from the handler. There is no lookup of a previous outcome before email.send.
Engineer: Show the condition that skips an already-sent notification. Here is the worker call site as well. Does
audit.recordparticipate in that decision?
The worker shows that every delivered job reaches the handler. Inspecting audit.record then reveals a storage insert that returns after persistence, with no deduplication check or uniqueness constraint on the notification identity in this example.
Assistant: There is no duplicate check in the inspected path. Each invocation attempts a send, then writes the audit entry. The worker acknowledges only after both complete. A rejection from either operation prevents this callback from reaching acknowledgement; the queue client's handling of that rejection still needs inspection.
This correction is more useful than the initial explanation because it separates three behaviours: provider acceptance, audit persistence, and queue acknowledgement. It also identifies what evidence is missing. The exchange is illustrative, but the habit transfers directly: ask the assistant to locate the implementation of a claim before allowing that claim to shape the investigation.
Make explanations inspectable #
An explanation becomes easier to assess when important claims point to the source that supports them. File paths and symbol names let the engineer move from prose to implementation. Line references help when they refer to a known revision; edits can move them or change the code they describe.
A useful account of the example might say that the worker callback invokes the handler and acknowledges after it returns, that the handler awaits the email adapter before recording the audit entry, and that the adapter implementation depends on configuration. Each claim corresponds to a particular piece of code.
It should also preserve the unresolved boundary: the application code shown does not establish how the queue client handles a rejected callback or an acknowledgement failure.
This gives the engineer something practical to inspect. They can open the call sites, check whether an exception is caught elsewhere, and look for configuration that changes the path. A smooth explanation without these anchors is harder to challenge because its assumptions are hidden inside the prose.
The assistant's access affects what it can establish. In a conversation using pasted excerpts, the engineer supplies the next file. With repository search enabled, the assistant can find callers and imports directly. Read access can expand the investigation while execution and editing remain with the engineer. In either case, the explanation should reflect which material was actually inspected.
Tests explain expectations #
Tests add another view of the codebase. They show scenarios someone considered important and the outcomes those scenarios assert.
A notification test might replace the provider with a fake and assert that sending happens before the audit entry is recorded. The core of such a test could be:
// test/handle-notification.test.ts — framework setup omitted.
const calls: string[] = [];
const handler = createNotificationHandler({
recipients: { find: async () => ({ address: "reader@example.test" }) },
render: () => "Your request has changed",
email: { send: async () => { calls.push("send"); } },
audit: { record: async () => { calls.push("audit"); } },
});
await handler({
id: "N42", customerId: "C7", template: "updated", data: {},
});
assertEquals(calls, ["send", "audit"]);
The assertion supports an expectation about the handler's sequence. It says little about queue redelivery: the test invokes the handler directly and never starts the consumer. It also performs only one invocation, so it does not establish what happens when the same job arrives again.
The fake matters too. An adapter that always resolves cannot exercise provider rejection or an uncertain outcome after a network interruption. A test named “handles delivery failures” needs inspection of its setup and assertions before it can support an explanation of failure handling.
AI can help connect these views: the code path, the test that exercises it, and the boundary that test leaves outside its scope. This can expose a useful next investigation without requiring a code change. For example, the team may discover that handler behaviour is covered while the interaction between handler completion and acknowledgement has no corresponding integration test.
That is a more precise finding than saying the notification system is tested or untested.
Build a map around the question #
An entire repository contains more information than most investigations need. Generated clients, old migrations, alternative adapters, and unrelated features can all be valid source material while contributing little to the current question.
Following one behaviour gives the conversation a reason to expand. The handler leads to the consumer because acknowledgement is unresolved. Adapter selection leads to configuration because the active implementation is unknown. A test leads to its fake because the meaning of a passing result depends on what was simulated.
The resulting map is selective, but its boundaries are visible. It can distinguish the request path from the worker path, identify where data is loaded, and show which operations happen before completion is reported. It can also name the parts still requiring documentation or runtime evidence.
For the team, the durable result might be a short repository note linking the entry point, handler, adapter selection, relevant tests, and unresolved questions. That note gives the next engineer a starting point and can be checked when the implementation changes.
The conversation has then contributed something beyond a readable summary. It has helped build an explanation of behaviour that another engineer can navigate, question, and use when considering a change.