The Pragmatic Programmer: What I Learned
2025-04-24
I recently read The Pragmatic Programmer by Andrew Hunt and David Thomas. I have distilled the major themes that stood out to me. Each section below captures an idea I want to carry forward in my work, along with a few personal reflection prompts to revisit over time.
📚 Contents
🧠 Think Deliberately
The Pragmatic Programmer emphasizes intentionality in coding. On the other hand, programming "by coincidence” relies on luck or code that seems to work without fully understanding why. When "coincidental" code breaks, debugging becomes guesswork, because you never really understood why it worked in the first place.
Instead, program deliberately. Understand what you are building and how you are building it. This means explicitly documenting assumptions, actively testing the code, and challenging your understanding of the program's behavior. Rather than hope that things continue to work, be in control of the cause and effect in your program.
Spend the most time on the parts that matter most: the fundamental, the difficult, or the most frequently reused. Do not let legacy code shape new features, because this leads to unmaintainable "duct-taped" logic. Don't let past decisions constrain future designs.
🪞 Reflections
- Could I explain this code?
- Am I documenting assumptions as I go?
- Am I pausing to understand and test fundamental behavior?
🔁 Refactor Early, Refactor Often
If code is subpar now, it will only become harder to fix once more code depends on it. Refactoring early and regularly lessens the cost of fixing it later.
But refactoring should be deliberate. Don’t refactor and add new functionality at the same time. Don’t treat refactoring as an excuse to rewrite things on a whim. Make small, isolated changes and test after each step. Your goal is to replace duplicate code, nonorthogonal design, outdated knowledge, or poor performance.
A well-designed system enables isolated refactoring. If each unit of code honors a clear contract, it becomes easy to refactor in isolation since you can trust each unit's boundaries and expectations.
🪞 Reflections
- Am I trying to refactor and add new behavior at the same time?
- Am I refactoring with a clear goal, or just chasing a vague idea of improvement?
- Do I trust my test suite enough to make changes confidently?
🤸 Design for Flexibility
Design should bend, not break. Decisions should be reversible. Systems should tolerate change. Decouple code into modules with minimal interaction. If one module breaks, the rest of the application should be able to carry on. If a system has tightly coupled modules, a change in one module can cascade throughout your system, making developers too afraid to change anything at all.
According to the Law of Demeter, an object's method should call only methods that belong to the same object, to its parameters, to its owned components, or to objects its instantiates. Essentially, don't reach through objects to get at deeper objects. Keep modules encapsulated and responsibilities clear.Designs should also be adaptable. Discard hardcoded logic. The choice of algorithm, database, or middleware should be configurable. Program for the general case and inject the specifics from elsewhere.
Temporal decoupling promotes services over components. Rather than programming a monolithic system that handles everything synchronously, use independent, concurrent objects with well-defined interfaces. An example of temporal decoupling is the hungry consumer model: instead of a central scheduler, let multiple consumer tasks pull from a shared queue. Services communicate events using a publish/subscribe protocol, without knowing each other's implementation details. Temporal decoupling increases resilience and throughput: if one part slows down, the others keep going. However, services introduce many of their own complexities.
Ultimately, the best systems are antifragile.
🪞 Reflections
- Is a single change cascading across too many modules?
- Am I depending on internal state I shouldn’t be reaching into?
- Is this hardcoded value or structure going to hurt me later?
- Can I decouple this code?
📐 DRY and Orthogonality
Don’t Repeat Yourself. Every piece of knowledge should live in a single, unambiguous, and authoritative place. Duplicating knowledge stores increases the cost of change.
There are many ways duplication creeps in: copying functions because you're in a hurry; hardcoding literals in multiple places; spreading a single business rule across the frontend and backend.
Comments are another source of knowledge duplication. Don’t narrate the code. Instead, reserve comments for higher-level context and keep low-level details in the code itself.
Orthogonality complements DRY. Two pieces of code are orthogonal if they don’t affect each other — changing one doesn’t cascade into the other. An orthogonal system is easier to understand and modify. Orthogonal modules can even be layered: each layer using only the abstractions of the layer below it, thus providing flexibility.
Orthogonality isn’t just for code. Teams and responsibilities should be orthogonal too, each owning distinct, loosely coupled concerns.
Orthogonality gives you local reasoning. DRY gives you a single source of truth. Together, they make code easier to change, harder to break, and less painful to revisit.
🪞 Reflections
- Am I duplicating knowledge across modules?
- Does a change in one area unexpectedly impact unrelated parts of the system?
- Do I trust that each unit of code has a clearly defined, isolated responsibility?
- Am I repeating details in comments?
🔍 Know and Test Assumptions
Every project, every feature, every function begins with assumptions about inputs, invariants, data, error cases, and behavior. These assumptions are often implicit, which makes them dangerous. You need to explicitly challenge these assumptions. A "correct" system doesn't simply pass a set of tests; it behaves correctly under the assumptions you expect and fails clearly when those assumptions are broken.
Design by Contract is a useful lens of defensive programming. Code should be "lazy", doing no more and no less than what it claims to do in its contract. Every function should have clear preconditions, postconditions, and invariants. If the caller meets the preconditions, the callee must guarantee the postconditions. When that guarantee fails, fail fast and loudly. A dead program does less damage than a crippled one that creates issues further downstream.
Fail at the boundaries, not in the core. Validate input at interfaces. Wrap third-party calls with checks. Catch unexpected states at the edge of your system, so that the core remains stable and testable.
🪞 Reflections
- Have I made untested assumptions?
- Do I fail early and loudly when a contract is violated?
- Am I checking that resources are released, caches are cleared, and cleanup actually happens?
🔬 Build for Testability
Testing is inextricable to building integrated circuits. Similarly, build software that you can rigorously test. Each module should be testable in isolation. Each interface should make it clear what inputs are valid, what outputs to expect, and what side effects occur. You should be able to verify the correctness of a module without spinning up the full system.
Unit tests are the fundamental base of testing. They ensure that a module honors its contract and behaves correctly in isolation, covering common and edge cases. Integration tests verify that modules work together as expected. Finally, you need to stress test the system under real-world constraints: memory, CPU, disk, timeouts, screen size, network latency, etc. The earlier you simulate these conditions, the more confidence you will have in the system’s resilience. Additionally, gather feedback from real users.
When starting a project, testability is a daunting task. This is especially true when you envision that the test code might be longer than the production code. Tracer bullets are a helpful metaphor to start writing tests.
Tracer code is a minimal path through the system that allows you to test a simple end-to-end connection through all system components. It contains production-ready error handling, documentation, and tests without necessarily all of the functionality. It quickly verifies a change without a full deployment. Tracer code lets you check how close you are to a target, making it easier to know what you need to add.
Tests are not overhead; they are leverage. Tests enable safe changes and reduce the mental burden of guessing what might break. Early tests help you avoid the shame that comes from others finding bugs in your code.
A system that’s hard to test is hard to change. A system that’s easy to test is easy to change.
🪞 Reflections
- Is this component easy to test in isolation?
- Do my tests cover both success and failure paths?
- Am I testing under real-world constraints?
- Can I verify this change without running the full system?
⚙️ Automate What Matters
Manual work is a liability. It is error-prone, inconsistent, and easy to forget. Automate anything you rely on for correctness or consistency: builds, tests, deployments, formatting, documentation generation, provisioning. If you find yourself repeating a task, write it down once and let the system repeat it for you.
Automation helps teams scale. When a new engineer joins, you shouldn’t need to walk them through dozens of manual steps just to run the test suite. When it’s time to deploy, you shouldn’t need to read from a checklist. When it’s time to rebuild a system, it should be reproducible from source and configuration alone.
Run automatic tests. Enforce formatting. CI should be fast, visible, and strict. Treat tooling as infrastructure. The more seamless and consistent you make tooling, the less friction the team feels and the more trust everyone has in the system.
🪞 Reflections
- Am I repeating manual steps that could be scripted?
- Is the build, test, or deploy process reproducible from scratch?
- Would a new developer know how to run and trust the test suite on their first day?
- Do I trust the system to enforce its own correctness?
🕵️ Requirements Are Discovered
Users often describe what they do, not why they do it. It’s your job to dig underneath the surface: to identify goals, challenge assumptions, clear misconceptions, and uncover the real constraints. Requirements are rarely clear from the start. Don't just transcribe what the user says, but investigate.
The best way to investigate requirements is to get as close to the user’s experience as possible. Become a user yourself. Shadow someone at their job, if possible. Understand the workflow, the edge cases, and the pain points so that your software can fix the real problem.
When documenting requirements, stay abstract. Describe the goals, scope, preconditions, success or failure conditions, actors, schedule, dependencies, etc. But don't jump into the UI or implementation details. Specifications are useful only to the extent that they clarify behavior, not when they unnecessarily constrain it. Specifications yield diminishing returns, since over-specifying removes room for adaptation. Feedback should not be unidirectional; it should also flow "backward" from implementation and testing into the specifications.
Requirements evolve. If you’re not tracking them, feature creep will sneak in. Tie every change back to its cost (in time, in complexity, in schedule), so that you can accurately assess its cost (and whether some other component would suffer).
Clear language reduces ambiguity and aligns needs across teams. Maintain a project glossary: an authoritative, shared lexicon of terms used in your system, with precise definitions. Post it somewhere visible so that all people, including developers and users, have a shared understanding.
🪞 Reflections
- Do I understand the user's goals, or just the way they currently do things?
- Is this functionality solving a problem (if so, what?), or blindly implementing what's written?
- Are my specifications abstract, or cluttered with implementation details?
- Do I know the cost of the functionality I'm working on?
- Do I have a shared glossary of terms?
🧭 Understand the Problem Space
“Think outside the box” is bad advice. You have to work within real constraints. But often, the constraints aren't obvious. Since you don’t know where the boundaries of the box lie, you don't know how much freedom you truly have. You may have more freedom than you think! The real difficulty is not in exiting the box; it's in finding the real constraints and then challenging those entrenched habits.
Sometimes solving the problem means redefining it. Ask, does it really have to be done this way? Is this constraint real, or just what someone told you? Cut the Gordian knot!
Many design discussions jump straight to technicalities without first establishing what is and isn’t negotiable. That’s how you end up with overengineering or premature optimization. Instead, define the real limits, and then work backward. Like a woodworker, first identify and cut the longest (most restrictive) pieces, and then fit everything else in what remains.
Don’t confuse constraints with requirements. A constraint defines the shape of the solution space. A requirement defines a goal.
🪞 Reflections
- What assumptions are driving my design?
- Have I identified the real constraints?
- Am I jumping into technicalities before understanding what matters?
🤝 Teams Share Culture
Software stagnates and rots due to team culture. One poorly designed piece of code or one poorly reasoned management decision is all it takes to accumulate entropy.
Individually, you should never accept broken windows. Crack down on small mistakes in order to keep your neighborhood clean, so that others don't use one broken window as an excuse to make even larger mistakes. Fix each issue as soon as it is discovered, or at the very least “board it up” by commenting it out or by adding a not implemented flag.
But even the best developers cannot do much in a disorganized team. You cannot uphold quality in a vacuum. Without shared values, broken windows will accumulate. One bad pattern goes unchallenged, then another, until nobody bothers fixing anything at all.
Teams drift slowly, like a boiling frog who does not notice its environment gradually changing until it is too late. It becomes normal to skip documentation, merge unreviewed code, or deploy manually.
The best teams are not just functional, but have a single, distinct voice. They are proactive. They have structure. They prepare. They respond to each other. They have pride.
🪞 Reflections
- Does my team share a single voice?
- Does my team feel "on the ball"?
- Is my team ignoring bad patterns?
- Do I feel like I am part of a team?
🛠️ Own Your Craft
Do a job you are proud of. Good software doesn’t just meet requirements, but delights.
Specifications only go so far. Real success is measured against expectations. You want to surprise users by going one step beyond what they asked for, and giving them something better than they imagined (within reason). This doesn't mean overengineering. It means polish.
Sign your work. Leave behind code and documentation you’d be willing to put your name on. Taking ownership pushes you to take the extra step in your work. By the same token, treat others’ work with respect. If someone did good work, build on it without defacing it. If someone left a mess, clean it up.
Craftsmanship also means maintaining your edge. Your knowledge is like a financial portfolio that depreciates if you don’t invest. Learn new languages. Try different environments. Read. Build. Teach. Take on small risks. Your value does not come from knowing one stack deeply forever, but from keeping your thinking sharp, current, and portable.
Pay attention to trends without falling under the sway of hype or zealotry.
Finally, communicate with intention. Know what you want to say. Tailor your words to your audience. Don’t be satisfied with being heard, but aim to persuade and be understood. Good communication is part of the craft.
🪞Reflections
- Would I sign my name under this code?
- Am I delivering something that merely meets expectations, or something that exceeds them?
- What have I done this month to sharpen my abilities?
- Am I communicating effectively to persuade my audience?