Reading and Understanding Unfamiliar Codebases
Reading and Understanding Unfamiliar Codebases
Why It’s a Distinct Skill
Most developer education focuses on writing code. Reading code — particularly large, unfamiliar codebases written by people you’ve never met, under constraints you don’t know, solving problems you’re still discovering — is a different activity that receives almost no explicit attention.
Yet it is something every developer does constantly. Joining a new team, picking up an open source library to understand its internals, inheriting a system from a team that no longer exists, returning to your own code after six months. The ability to build an accurate mental model of an unfamiliar system quickly is one of the highest-leverage skills a developer can have, and it is largely learned through trial and error rather than through deliberate practice.
The instinct when encountering an unfamiliar codebase is often to read it the way you’d read a book — starting at the beginning and working through to the end. This is almost always the wrong approach. A large codebase is not a linear narrative. It is a system, and systems are understood through structure, behaviour, and relationships — not through sequential reading.
Start With the Why, Not the What
Before reading a line of code, orient yourself to the purpose of the system. What problem does it solve? Who uses it? What are its primary responsibilities? What is its position in the larger technical landscape — is it a standalone application, a service in a larger system, a library consumed by others?
This context shapes everything that follows. The same code means different things in a high-throughput financial system and in a batch processing tool that runs nightly. The trade-offs made, the patterns chosen, and the complexity accumulated all reflect the context in which the system was built. Understanding that context before reading the code means you read it with the right questions rather than the wrong ones.
Sources for this context, roughly in order of reliability: talking to someone who has worked on it, README and documentation, architecture decision records if they exist, commit history and PR descriptions, and finally the code itself.
The most underused resource is people. If someone on the team built or maintained the system, a thirty-minute conversation will give you a mental model that would take days to assemble from reading alone. Ask them: what is this system for, what are the hard parts, what would you do differently, what should I know before I start making changes. The answers compress enormous amounts of accumulated understanding into a form you can use immediately.
Read the Tests Before the Implementation
If the codebase has a reasonable test suite, it is one of the best entry points for understanding. Tests describe intended behaviour in a form that is meant to be read — each test is a small specification of what a unit does, expressed in terms of inputs and expected outputs, usually with meaningful names.
A test suite gives you a map of the system’s capabilities without requiring you to understand its implementation. You can see what the system is supposed to do before you look at how it does it. You can identify the key concepts — the things that have tests are the things that matter. You can understand the edge cases the original developers anticipated. You can form hypotheses about the design that the implementation will either confirm or complicate.
Reading tests is also an efficient way to discover the domain vocabulary. The names used in tests tend to be more deliberately chosen than variable names in implementation code — test authors are trying to be clear about what they’re testing. The concepts that appear repeatedly are the concepts that matter.
Find the Entry Points
Every system has entry points — the places where the outside world connects to the code. For a web service, these are the HTTP endpoints. For a library, the public API. For a batch process, the main function and its arguments. For an event-driven system, the message consumers.
Entry points are where behaviour begins. They are the seams between the system and its environment, and they define what the system can do. Reading inward from the entry points — tracing how a request or event flows through the system — gives you the most direct route to understanding behaviour.
Start with the most important entry point — the one that represents the core purpose of the system — and follow one request through from entry to exit. Don’t try to understand everything you see on the first pass. The goal is to get a high-level map of the journey: what happens first, what is called next, where are the major operations, where does control leave the system. Detail can be filled in on subsequent passes.
Map the Structure Before Reading the Detail
Resist the urge to dive into implementation detail before understanding the high-level structure. Detail without structure is disorienting — you can understand a function perfectly and still have no idea where it fits in the larger picture.
Start by understanding the package and module structure. What are the top-level divisions? Do they reflect technical layers (controllers, services, repositories) or domain concepts (orders, billing, fulfilment)? The structure tells you something about how the developers thought about the system — and whether those concepts map onto the domain or onto the technology.
Look for the major abstractions — the interfaces, the base classes, the core domain objects. These are the load-bearing concepts that the rest of the system is built around. Understanding them gives you a vocabulary for everything that follows.
Draw a rough map if it helps. Not a formal architecture diagram, but a sketch — boxes for the major components and arrows for the relationships. The act of drawing forces you to identify what you understand and what you don’t, and the resulting sketch is a reference you can refine as your understanding deepens.
Use the Version History
The current state of a codebase is a snapshot; the version history is the film. A snapshot can be hard to interpret — you see what the code is but not why it is that way. The history tells you how it got there, which often explains things the code itself cannot.
A piece of code that looks wrong might have been deliberately written that way to work around a specific problem — the commit message will tell you. A surprising design decision might have been the result of a constraint that has since been removed. A mysterious special case might have been added in response to a specific incident.
The most informative commits are the ones that fix bugs and the ones that change significant structure. Bug fixes show you where the system’s edge cases lie and what failure modes have been encountered in production. Structural changes show you how the design has evolved and what drove those changes.
git log --follow -p <file> for the history of a specific file, git log --oneline for a high-level map of recent activity, and git blame to understand who changed what and when — these are navigational tools, not just auditing tools.
Understand the Data Model
For most systems, the data model is the skeleton around which everything else is built. Understanding what data exists, how it is structured, and what the relationships between entities are gives you a foundation for understanding behaviour.
In a system with a relational database, the schema is often the clearest expression of the domain model available. It shows you the entities the system recognises, the attributes that matter, and the relationships between them. It is also often more honest than the code — code can layer abstractions and hide the underlying model, but the schema is what the data actually looks like.
In a system with a rich domain model, the domain objects themselves are the data model. Reading the entities and their relationships — what does an Order contain, what does a Customer own, what does a Subscription know about — gives you the conceptual vocabulary of the system before you need to understand its behaviour.
Pay particular attention to the key entities — the ones with the most relationships, the ones that appear most often. These are the central concepts around which the system is organised.
Build a Glossary
Unfamiliar codebases have their own vocabulary. Terms that seem generic might have specific meaning in this system. Terms that are used consistently are concepts worth understanding precisely. Terms that are used inconsistently are a signal of accumulated confusion that you’ll need to navigate.
Building a simple glossary as you read — even just a few notes on what the most important terms mean in this specific system — forces you to be explicit about what you’ve understood and what remains unclear. It also becomes a reference as you work in the system, and a contribution to the team if you write it up.
Domain terms that don’t match standard vocabulary are worth asking about. Sometimes they represent genuine domain-specific concepts with precise meaning that isn’t obvious from the word alone. Sometimes they are historical accidents — the word was chosen early and stuck even as the meaning evolved. Knowing which is which matters for understanding the system.
Make a Small Change
There is a limit to how much you can learn about a system from reading alone. At some point, the most effective way to deepen your understanding is to make a small change and observe the effects.
A bug fix is ideal for this — it is bounded, it has a specific goal, and it requires understanding a specific path through the system rather than the whole thing. The process of making the change teaches you things that reading cannot: how the tests work in practice, how hard it is to change the thing you’re changing, where the coupling is, how the deployment process works, what the development environment requires.
The change doesn’t have to be significant. The point is engagement — moving from passive reading to active participation. The act of making a change, running the tests, and observing what happens converts abstract understanding into practical knowledge.
What Hard-to-Read Code Is Telling You
When code is difficult to understand — when it resists comprehension, when you need to read it three times to understand what it does, when it seems inconsistent with the surrounding code — it is telling you something.
It might be telling you that this area has accumulated significant accidental complexity. It might be telling you that the original design didn’t anticipate the way the code evolved. It might be telling you that a design decision that made sense in an earlier context was never revisited when that context changed. It might be telling you that this is a genuinely hard problem and the complexity is essential.
Knowing which is which matters. Accidental complexity can be reduced; essential complexity must be respected. The code that is hardest to read is often the code with the most history — the most bug fixes, the most special cases, the most accumulated workarounds. Reading that code carefully, and reading its git history, usually reveals a story about what the system has been through.
Hard-to-read code is also often the code that most needs improving. The areas of a codebase that nobody understands are the areas where bugs hide and where changes are most likely to go wrong. Building understanding of the hard parts — even if you can’t immediately improve them — is a contribution to the team’s collective capacity.
Forming and Testing Hypotheses
Understanding a codebase is a scientific process. You form hypotheses — “this looks like it handles the payment flow”, “this class seems to be the aggregate root for orders”, “this service looks like it’s responsible for all external communication” — and then test them against evidence as you read further.
When a hypothesis is confirmed, your model strengthens. When it’s contradicted, you update the model. The goal is not to be right immediately but to converge on an accurate model iteratively.
This means reading with questions rather than just reading for information. What does this do? Why is it structured this way? What would happen if this were removed? Who calls this? What does this depend on? The questions direct your attention and make the reading active rather than passive.
It also means being willing to revise your model significantly. A mental model formed from the first hour of reading might be substantially wrong. The willingness to discard a model that isn’t working — rather than forcing new information to fit a model that was wrong — is what separates fast, accurate understanding from slow, confused understanding.
The Relationship to Everything Else
Reading unfamiliar codebases is where all the other practices become visible in their presence or absence. A codebase with good test coverage is easier to understand than one without. A system that follows Clean Architecture is easier to navigate than one where concerns are entangled. A rich domain model that uses the ubiquitous language makes the domain comprehensible; an anaemic model with generic names obscures it. Low cognitive load makes the reading faster; high cognitive load makes it exhausting.
The quality of a codebase is most honestly assessed by someone reading it for the first time. That reader experiences what the codebase actually communicates — not what the authors intended, but what survives in the code. Building the habit of reading unfamiliar code, and paying attention to what makes it easy or hard to understand, develops both the skill of reading and the instinct for writing code that will be comprehensible to the next person who needs to understand it.
If you enjoyed this article, feel free to support my work on Ko-fi!
Support on Ko-fi