TS-7: Code Design
This technical standard covers low-level concerns related to the structure and formatting of code. It covers topics such as naming conventions, commenting best practices, and object-oriented design principles.
These guidelines are language-agnostic. They are intended to be applicable to any general-purpose, high-level programming language – Python, Java, JavaScript, etc. Some of the content will also be relevant for lower-level languages, such as shell scripting languages like Bash.
Clean code can be read and maintained by people other than the original author. It has unit and acceptance tests. It has meaningful names. It provides one way rather than many ways of doing one thing. It has minimal dependencies, which are explicitly defined, and provides a clean and minimal API. […] The code must be loosely coupled and highly cohesive – in other words, well designed.
– Robert C. Martin
Low-level coding design is a nuanced and subjective thing. Some code style guides, like Robert C. Martin’s "Clean Code", impose hard rules like maximum line lengths and function line counts. This technical standard takes a more pragmatic approach. Rather than imposing rules on your code, it merely sets out some general principles to help guide you in finding a reasonable balance between various trade-offs in code design.
While code design decisions are important, you should not dwell on them. Instead, focus on logical separation, data structures, communication patterns, and other architecturally-significant decisions. These concerns are the subject of TS-5 and TS-2.
Beware the bike-shed effect
Code design discussions can easily get bogged down in trivial details.
Bike shedding, also known as the bike shed effect or the law of triviality, is a phenomenon where people in an organization fight over trivial issues and ignore what’s complicated and truly important. The idea comes from a story in Cyril Northcote Parkinson’s book Parkinson’s Law: Or the Pursuit of Progress (1986). In the story, a fictional committee discusses the construction of a nuclear power plant, but spends most of its time discussing details like which materials to use for the construction of a bike shed.
Parkinson originally observed this phenomenon in an essay published in The Economist in 1955. Drawing from his experience in the British Civil Service, Parkinson described how bureaucracies tend to expand regardless of the actual workload. The most famous line in the essay is: "Work expands so as to fill the time available for its completion." This became known as Parkinson’s Law.
In the field of software development, the bike shed effect tends to show itself in time spent arguing over things like code formatting conventions, what the name of an inconsequential private variable should be, and whether a comment or an abstraction makes a bit of code easier to understand.
The truth is that such low-level concerns have a relatively small impact on the construction quality of a software system compared to higher-level concerns such as choices of communication patterns, data structures, module boundaries, and so on.
Furthermore, much of code design is neither right nor wrong, but merely a matter of personal preference. Code design is a much more subjective thing than architecture and system design. It is influenced more by individual aesthetic tastes than by objective analysis.
Of course, it is important that code design be consistent. That is important for the habitability of a codebase – how happy and productive are the developers who work on it. But we should not spend too much time arguing over code design. We should decide our coding conventions, codify them in linters and style guides, and move on to the important stuff – higher-level design concerns that will yield greater returns on the cost of construction.
The boy scout rule
Uncle Bob’s boy scout rule – leave the campground cleaner than you found it – is a practical complement to the advice above. Rather than scheduling dedicated refactoring sprints or waiting for perfect conditions to improve code, the boy scout rule encourages developers to make small, incremental improvements whenever they work in an area of the codebase. Rename a confusing variable. Extract a small helper. Delete a dead code path. Improve a misleading comment.
Over time, this consistent behavior leads to a codebase that improves organically with each commit, rather than accumulating technical debt to be paid down someday in a costly rewrite.
The key constraint is proportionality. Boy scout improvements should be small and targeted – closely related to the task at hand. Resist the temptation to refactor entire modules when all that was asked was a small bug fix, or to rewrite perfectly functional code simply because it does not conform to your current aesthetic preference.
Make the change easy, then make the easy change
Kent Beck’s maxim for approaching a difficult change is to split it into two distinct steps: first make the change easy – which may itself be hard – then make the easy change.
In practice, this means that when the code you need to modify is not yet shaped to accommodate the change you want to make, don’t force the change into the existing shape. Refactor first, so that the change becomes a small, natural addition once the refactoring is done. Extract a function, introduce an interface, rename something for clarity, or restructure a conditional – whatever reshaping is needed so that the eventual behavioral change is obvious and low-risk. Only once the code is ready should you make the change itself.
This is the boy scout rule applied deliberately, at the point of greatest leverage: immediately before the code is touched for a specific purpose, rather than as an opportunistic tidy-up along the way. The two steps SHOULD be kept visibly distinct – the reshaping work should not be tangled up with the behavioral change in the same unit of work, so that each can be understood, reviewed, and reverted independently of the other. TS-9 covers the commit-level mechanics of keeping these two kinds of change separate.
Treating refactoring as a routine first step, rather than an occasional cleanup exercise, changes the overall shape of a codebase’s history. A healthy proportion of a project’s changes should be pure refactorings – work that reshapes existing code without altering its observable behavior. A history with very few refactoring changes is a signal that changes are habitually being forced into code that was not ready to receive them, which tends to produce increasingly convoluted, patched-together logic over time.
Abstraction
Perhaps the most important concept in code design is abstraction.
Abstraction is a general term that refers to any design pattern that hides complexity. Complex behaviors and/or data are hidden behind some kind of facade, which exposes a simplified interface for interacting with that hidden logic and data.
Abstraction in programming is the process of identifying common patterns that have systematic variations; an abstraction represents the common pattern and provides a means for specifying which variation to use.
An abstraction facilitates separation of concerns: The implementor of an abstraction can ignore the exact uses or instances of the abstraction, and the user of the abstraction can forget the details of the implementation of the abstraction, so long as the implementation fulfills its intention or specification.
– Balzer et al. 1989
Abstraction is one of the primary ways by which we make complex systems seem simpler than they really are. It is implemented through various constructs for encapsulation, which are typically provided at the level of the programming language – things like functions, classes, objects, modules, packages, subroutines, plugins, and macros.
One of the effects of abstraction is the compression of other parts of the code. Compression refers to complex behavior or logic being represented by less code. Abstraction makes that possible by hiding complexity behind high-level programming constructs like function calls. Where an abstraction is used to extract complexity from code, all that is left in the code – in the place where all that complexity previously existed – is just a reference to the abstraction.
Abstraction, and the compactness and cleanliness it brings to program code, is widely held up to be a good thing. Abstraction leads to efficiently-expressed logic, reducing cognitive load. Abstraction also facilitates code reuse.
Do more and more with less and less until eventually you can do everything with nothing.
– R. Buckminster Fuller
Nine Chains to the Moon (1938)
But, like everything in software design, abstraction involves trade-offs. The overuse and misuse of abstraction can create problems, too.
Be moderate in your application of abstraction. Avoid extremes and seek balance between the trade-offs, which we’ll discuss next.
Modules
To talk about the quality of an abstraction, we need a name for the thing being abstracted. This standard uses module, in the general sense used by John Ousterhout in A Philosophy of Software Design: any unit of code that has an interface and an implementation. A function is a module. So is a class, a package, a library, and a network service. The term is used more narrowly elsewhere in this standard – in Decomposition, for instance, where it means a file-or-directory-sized unit – but here it covers everything from a three-line function upwards, because the same reasoning applies at every scale.
A module’s interface is everything a caller must know in order to use it correctly. That is more than its signature. It includes the documented behavior, the units and ranges of its parameters, its side effects, the errors it can produce, any ordering or threading constraints on calling it, and any invariant the caller is expected to maintain. Some of this is expressed in the language’s syntax and is checked by the compiler; the rest is expressed in documentation and comments, and is not. Both halves are part of the interface, and both are paid for by every caller.
A module’s implementation is everything else – the code that fulfills the interface, which callers should be able to ignore entirely.
Module depth
The interface is a cost and the implementation hidden behind it is the benefit. The interface must be learned by everyone who uses the module, and relearned whenever it changes; the implementation, if the interface is any good, need never be learned at all. The quality of an abstraction is therefore a matter of the ratio between the two, and that ratio has a name: depth.
A deep module presents a simple interface over a substantial implementation. Unix file I/O is the canonical example: open, read, write, close and a couple of others, hiding block allocation, buffering, permissions, device drivers, and multiple on-disk filesystem formats. A database client where you call query() and it handles connection pooling, statement parsing, query planning, and networking is the same shape. In both cases a caller learns a handful of operations and gets an enormous amount of behavior in exchange.
A shallow module is one whose interface is nearly as complex as the implementation it hides. The caller has to learn almost as much as they would have had to learn by reading the code, so the abstraction has bought them little or nothing – and once the indirection is counted, often less than nothing. Single-caller wrapper functions that do nothing but forward their arguments, classes whose entire public surface is getters and setters over their own fields, and "manager" or "helper" types that exist to hold one method are the usual examples. A module is also shallow in practice when its interface leaks – see Leaky abstractions – because a caller who must understand the implementation anyway has gained no insulation from it.
Depth is a ratio, not a size. This is the point most often lost. A small module can be perfectly deep: a well-named function of ten lines that hides a fiddly calculation the caller genuinely does not want to think about is a good abstraction. A large module can be thoroughly shallow: a thousand-line class with forty public methods, each of which maps one-to-one onto an internal operation, hides nothing. It follows that neither "make it smaller" nor "make it bigger" is design advice. The question is always what the caller has been relieved of knowing.
It matters more that a module’s interface is simple than that its implementation is. A complicated implementation is paid for once, by the person maintaining it. A complicated interface is paid for repeatedly, by everyone who calls it, and it propagates: complexity exposed through an interface tends to be passed along into the interfaces of the callers in turn. Where complexity cannot be eliminated, the design goal is to pull it downwards, into implementations, and away from the boundaries where it multiplies.
This is why arbitrary structural limits are a mistake. Capping the number of statements in a function body, or the number of methods in a class, optimizes for module size – which is not the thing that determines whether the design is any good – and it pushes directly against depth, because the usual way to satisfy such a cap is to split one deep module into several shallow ones. See Function length for the practical consequences.
Remember, the primary purpose of an abstraction is to extract complexity from elsewhere in the application code. Do what you need to do to hide that complexity from users of the abstraction.
Depth is not an excuse for vagueness
Depth is about how much a module hides, not about how vaguely it describes what it does. These are easy to confuse, because both produce a short interface.
A function that hides several hundred lines of business logic and a number of interactions with external systems, and that is called processOrder(), has a short signature but not a simple interface. The caller still needs to know which of those external systems are touched, in what order, what happens if the third one fails, and what state the system is left in afterwards – and none of that has gone away by not being written down. It has simply moved from the interface into folklore, to be rediscovered by each new maintainer reading the implementation.
The test is whether you can describe what a module does briefly and completely. If a full description is short, the module is deep. If a full description is long, the module is not deep, however short its signature; the interface is just under-documented, and the implementation is likely doing several things that should be separated. A useful check is the one described in Comments: try writing the interface comment before the implementation, and treat difficulty in writing it as a finding about the design.
Leaky abstractions
Depth is what we are aiming for, but the more a module hides, the more opportunity there is for some of what it hides to escape. An abstraction leaks when a caller cannot use it correctly without knowing something about its implementation – a performance characteristic that only holds for certain inputs, an error that only makes sense in terms of the underlying mechanism, an ordering requirement imposed by internal state.
A leak is not merely untidy. It converts a nominally deep module into a shallow one. The implementation detail that escapes becomes part of the interface in practice, whether or not it is documented as such, because every caller has to know it; the module’s real interface is therefore larger than its stated one, and the benefit the depth was supposed to buy has been given back.
The problem with leaky abstractions is that, if their internal implementations change, they typically require corresponding changes to their public interfaces. If you need to change the public interface to an abstraction, you will need to change the calling code wherever that abstraction is used, too. Thus, changes in low-level implementation details can snowball into wider refactorings throughout a codebase.
The purpose of an abstraction is for the user of the abstraction to be ignorant of its implementation. Thus, good abstractions have generic interfaces that do not leak their implementation details. The design goal is to be able to change the internal workings of an abstraction while keeping its interface stable.
For the same reason, we should avoid premature abstraction. We should extract common logic and data into abstractions only when we have a good level of confidence that the interface to those new abstractions can be kept stable. The best abstractions tend to emerge piecemeal, evolving naturally as the system grows. Design grounded in actual usage, rather than speculation, tends to produce abstractions that are more stable and better suited to the problem at hand.
Once an abstraction is made, we should expect to evolve its interface only in a backwards-compatible way. Where an interface cannot be changed in a backwards-compatible way, a new version of the abstraction should be created, leaving the old version in place to allow for incremental migration to the new version.
The risks of premature abstraction are greatest in distributed systems. Extracting microservices too early, for example, can lead to services that are difficult to evolve independently of one another, due to volatility in their interfaces and tight coupling between them. The problem is much less acute in single-node applications, where all the code is in a single repository and deployed simultaneously. Here, you can more safely evolve the interfaces to your abstractions.
Vertical consistency
Within each of the tiered layers of an application’s architecture, there should be a consistent level of abstraction.
Within a layer, if one module makes calls to high-level business services, there should not be another module that implements low-level abstractions for things like string manipulation – even if that second module is unconnected to the first.
Mixed abstraction levels are even worse when they exist in the same module. Such code reads like a "stream of consciousness" of the developer’s thought process as they tried to make something work, rather than a polished design.
Mixed abstraction levels are a code smell that indicates that some things should be extracted to new abstractions.
Decomposition
Decomposition is the process of breaking complex code into smaller and smaller abstractions – typically functions, classes, or modules. Generally, decomposition is a good thing, but as with everything in software design, there are trade-offs. Decomposition actually increases overall system complexity, because it creates new dependencies between components. We get localized simplicity (through abstraction) at the cost of more global complexity (through dependency graphs).
There is a danger in decomposing code too aggressively. If you break logic into too many small functions or components, you can end up with many tiny abstractions that are tightly coupled to one another. So we should be wary of applying principles like "single responsibility" too rigidly. Aggressive decomposition for the sake of purity of design can make codebases harder to change, not easier.
Each abstraction is a new layer of indirection in our understanding of the code. If abstraction has the effect of pulling apart related units of logic and data, the code loses locality of reference. This is the principle that things that are related to one another should be kept close together, while unrelated things should be kept far apart. This may be a literal spacing on the filesystem.
This principle of locality of reference has cognitive benefits. When related logic is colocated in a single file or function, it requires less context-switching to understand. The reader can see the whole picture without jumping between files. (Locality of reference is also important in performance engineering – it can help machines to interpret code, too.)
If you cannot understand the purpose of one function without reading the internal logic of several others, you have over-decomposed. The decomposition has not actually reduced complexity, it has only distributed it across more boundaries. This condition has a name – entanglement – and it is the clearest symptom that a set of abstractions is not paying for itself. An entangled group of functions presents the reader with several interfaces to learn instead of one, without ever allowing them to stop at an interface and trust it.
You don’t need much indirection to make things really difficult for humans. The average person can hold only 3-5 pieces of information in working memory at a time. Each context switch, introduced by a new abstraction, however trivial that abstraction, takes up one of those slots. The more memory tokens we use up in trying to understand a piece of code, the more likely we are to let bugs slip through.
Abstraction should be a net remover of complexity. Don’t introduce abstractions purely for the sake of separating concerns if that separation doesn’t yield a noticeable reduction in overall system complexity.
Not everything benefits from decomposition into abstractions. If it is desirable for the user of some code to know about its implementation details, then it’s probably best if those implementation details are not abstracted away. Don’t abstract things that would be better left explicit and visible. Think about the users of your code, and what knowledge they need to have in order to integrate with it, to analyze and debug it, and to maintain it.
Function length
The most concrete form this trade-off takes is the question of how long a function should be. One school of thought, associated with Robert C. Martin’s Clean Code, holds that functions should be very short – a handful of lines – and that a function doing more than "one thing" should be extracted until it does not. The opposing view, argued by John Ousterhout in A Philosophy of Software Design, is that decomposing this aggressively produces a proliferation of shallow modules – functions too small to carry the cost of their own interfaces, in the sense described in Module depth – and that the resulting entanglement costs the reader more than the original length ever did.
Both authors set out their positions at length in a public exchange, A Philosophy of Software Design vs. Clean Code, which is worth reading in full. It is instructive less for who wins than for what it demonstrates: two experienced practitioners, agreeing on the goals – modularity, testability, and reducing what a reader has to hold in their head – working through the same example code and reaching opposite conclusions about how far to decompose it. That is a reasonable signal that no fixed threshold is defensible as a general rule.
This standard therefore takes no position on function length as a number, for the same reason it rejects arbitrary structural limits generally. Length is not the thing being measured; depth is. The test is whether extracting a piece of logic into its own function removes more complexity from the caller than the new interface adds to the reader’s burden. A function called from exactly one place, whose name has to restate the caller’s context to mean anything, and that cannot be understood without also reading that caller, has failed the test regardless of how few lines it contains. Conversely, a long function whose parts are sequential steps in a single operation, none of which has any independent meaning, may be better left long.
A function that is genuinely too long usually has a second problem underneath the length – it is doing work at several different levels of abstraction, or it is holding state that several of its parts read and write. Those are the defects worth acting on. Fix them and the length usually resolves itself; chase the length alone and it merely relocates.
Where a long function is the right answer, and the length is likely to invite a well-meaning refactoring later, say so in a comment. See Comments.
Cohesion
Decomposition raises the question of what belongs inside a module and what does not. Cohesion is the name for the answer. Larry Constantine and Ed Yourdon, who introduced the term in Structured Design (1979), defined it as the degree to which the elements within a single module belong together — how strongly related, and how singly focused, the responsibilities inside one boundary are. A highly cohesive module does one thing. A poorly cohesive one is a collection of loosely associated parts that happen to share a file.
Cohesion is one half of a pair. Coupling describes the strength of the connections between modules, cohesion the strength of the relationships within one. The general aim is low coupling and high cohesion, and the two tend to move together. Logic that belongs together and is kept together does not need to reach across a boundary to do its work, while logic scattered across modules that have no reason to hold it generates connections that would not otherwise exist. TS-2 covers coupling, and its several distinct forms, in detail.
Constantine and Yourdon ranked cohesion on a scale, running from coincidental cohesion at the weakest end — elements grouped for no reason at all, the catch-all utils module being the familiar example — through logical, temporal, procedural, communicational, and sequential cohesion, to functional cohesion at the strongest, where every element in the module contributes to a single, well-defined task. The scale is more useful as a diagnostic vocabulary than as a grading system. Its value is in naming the weak forms, so that a module held together only by "these things all run at startup" (temporal) or "these things all touch the customer record" (communicational) can be recognized as such, and a deliberate judgment made about whether that is a good enough reason.
Cohesion is not an argument for decomposing without limit. A module that does one thing is not automatically better than one that does two closely related things, and splitting modules until every unit is functionally cohesive produces exactly the over-decomposition described above, in which many tiny abstractions end up tightly coupled to one another. Treat cohesion as a reason to move logic that clearly doesn’t belong, not as a target to optimize for its own sake.
Note also that cohesion is a local property. Every module in a system can be highly cohesive while the system as a whole is still built on several inconsistent ideas. That system-wide property is conceptual integrity, and it is a separate concern, covered in TS-2.
Don’t repeat yourself
One of the objectives of abstraction is to promote code reuse. By decomposing "common patterns" into constructs like functions and modules, we can define those patterns once and reuse them in multiple contexts.
The principle of "don’t repeat yourself" (DRY) states that we should use abstraction to extract and encapsulate discrete units of knowledge. This means having an abstraction for each distinct business rule or domain concept, which can then be reused in different contexts. The objective is to need to make changes to important business logic, domain entities, and data representations in only one place.
Somewhere along the way, "don’t repeat yourself" came to be misunderstood as meaning "don’t copy-and-paste any logic or data". It is wrongly viewed as a directive to eliminate all code duplication. But this interpretation leads to premature abstraction and tightly coupled code.
When two components contain what appears to be duplicate code, but when those components are semantically unrelated to one another, the code duplication is coincidental. If we extracted this replica code to a shared abstraction, we only optimize locally (fewer lines of code per module) but at the expense of increased system complexity (poorer modularity). We create a coupling between two modules, increasing the difficulty of evolving the behavior of each independently of the other.
The true cost of complexity comes not from lines of code, but from dependencies and indirection. A small codebase with many abstractions can be more complex than a big codebase with less indirection.
Modern tools — IDEs with static analysis, automated refactoring, and AI code assistants — have made the cost of code duplication much cheaper. Refactoring is easier than ever. This means we can afford to repeat code now and extract abstractions later, once we have high confidence the abstractions will be worthwhile. In other words, we should write everything twice (WET) before abstracting, to gain confidence that the planned abstraction will be stable and valuable.
The Rule of Three
Waiting for high confidence before abstracting is one thing; knowing when that confidence has actually arrived is another. A useful trigger is the Rule of Three: writing the same logic once is fine, writing it a second time is acceptable, but writing it a third time is the signal to consolidate.
The reasoning is about data points, not aesthetics. A single instance of some logic tells you nothing about how it might vary. A second instance gives you one comparison, which is rarely enough to distinguish an incidental similarity from a genuine shared pattern. By the third instance, you have enough examples to see which parts are truly common and which parts vary – and a good abstraction can only be designed once you know that.
Consolidating too early, from a single instance or a hasty second one, risks building an abstraction around an accidental resemblance. As the copies continue to diverge with each new use, the interface you guessed at either grows special cases to accommodate variations it didn’t anticipate, or forces callers to bend their needs to fit an abstraction that was never quite right for them. Waiting for the third occurrence costs nothing but a little duplication, and buys the information needed to abstract well.
Orphan modules
Decomposition sometimes produces a unit of logic – a function, a small class, a handful of related utilities – that doesn’t obviously belong to any existing module. The natural temptation is to find the closest existing module and add the new logic there, even when the fit is poor, rather than create a new module for something that, on its own, looks too small to justify one.
Resist that temptation. An orphan module – a small, standalone module that exists because nothing else was the right home for it – is a perfectly acceptable outcome. It is a better outcome than forcing unrelated logic into a module where it doesn’t belong, which erodes that module’s cohesion and makes both the original logic and the newly added logic harder to find and reason about later.
Creating a new module for orphan logic keeps the codebase’s structure honest: each module contains what actually belongs together, and a module’s size or its coupling to a specific location in the codebase are not treated as a proxy for whether a new module is warranted.
Optimization as a source of over-engineering
Over-decomposition is not the only route to over-engineering. Performance optimization is another common one, and it deserves specific caution because it tends to arrive with its own justification already attached – "this needs to be fast" is rarely questioned the way "this needs another layer of abstraction" is.
Optimization introduces complexity. Optimized code is frequently harder to read than its straightforward equivalent, because it trades a direct expression of intent for a more convoluted implementation chosen for its runtime characteristics. It also tends to introduce tighter coupling – between components, between layers, and sometimes across business processes – as code is reshaped around data flows and access patterns that favor performance over clean separation of concerns.
Optimize only once you have evidence – profiling data, load-testing results, or production metrics – that a specific piece of code is actually a bottleneck. TS-2 covers the broader trade-off between performance and other design qualities in more detail. Once code works and meets its performance requirements, stop engineering it further. Continue simplifying where you can, but resist the urge to keep optimizing past the point where it’s justified by evidence – the complexity and coupling it introduces are a lasting cost, paid whether or not the optimization was ever necessary.
A small vocabulary of patterns
Decomposition decisions accumulate into a codebase-wide vocabulary of design patterns – the recurring shapes a reader learns to recognize, such as "this kind of problem gets solved with a factory here" or "cross-cutting concerns are handled with middleware in this codebase." A reader who has learned how one part of the system expresses an idea should not need to relearn new structures to understand another part that solves a similar problem.
This is a cross-cutting discipline, distinct from choosing the right pattern for a given problem. It is entirely possible for every individual decomposition decision in a codebase to be locally well-reasoned, and for the codebase as a whole to still be hard to work in, because it uses a different idiom to solve the same recurring kind of problem in every module – one repository layer built around the active record pattern, another around a repository interface, a third talking to the database directly. Each choice might be individually defensible. Together, they multiply the number of distinct mental models a reader has to hold to work anywhere in the system.
Favor the pattern the codebase already uses for a given kind of problem over introducing a new one, even where the new pattern would be a marginally better fit in isolation. Reserve a genuinely new pattern for a kind of problem the codebase has not previously had to solve, and once introduced, treat it as the new default for that kind of problem elsewhere in the codebase, rather than letting it become a third or fourth alternative alongside the existing ones.
The rule of representation
Treat data structures, rather than code structures, as the foundation of a software design.
Application logic tends to follow the data model. Treating the data model as an afterthought results in more work later, whereas a well-considered data model makes migrations and extensions substantially easier.
Bad programmers worry about the code. Good programmers worry about data structures and their relationships.
– Linus Torvalds
Torvalds made this remark in the context of Git’s design, observing that Git succeeded in large part because it was built around simple, stable, well documented data structures rather than around its algorithms.
The same idea appears in Eric Raymond’s The Art of Unix Programming as the rule of representation, which says to fold knowledge into data, so that program logic can be stupid and robust.
Data is more tractable than program logic. It follows that where you see a choice between complexity in data structures and complexity in code, choose the former. More: in evolving a design, you should actively seek ways to shift complexity from code to data.
– Eric Raymond
The Art of Unix Programming
Good data structures make code easier to read, easier to maintain, and easier to reason about – which is to say they make a codebase more habitable. They also support correctness and reliability, because a data model that makes invalid states difficult to represent removes whole categories of defect by construction.
In distributed systems, shared data structures deserve particular care. Where several services exchange data, a well-designed common representation reduces the need for each team to write its own converters and mappers, and with it the risk of inconsistencies creeping in between services.
Expressiveness
Expressiveness refers to how clearly code communicates its intent and behavior. Expressive code reads like a narrative. The reader understands what the code does and why without having to mentally parse syntax or reverse-engineer logic.
Expressiveness is fundamentally about reducing cognitive load. When code expresses its intent clearly, readers can focus on understanding the business logic and domain concepts rather than deciphering implementation details or inferring meaning from cryptic constructs.
Expressive code is achieved through good abstraction and clear naming of those abstractions, and through thoughtful choices of language syntax and control structures.
Naming things
Where we abstract complexity, we need to give names to those abstractions. Naming things is one of the hardest things to do in computer programming. (Only cache invalidation is harder!) But the general rule is to err on the side of clarity over brevity.
A longer name that precisely communicates intent is far better than a short abbreviation that leaves readers guessing. The time spent typing an extra word or two is trivial compared to the time wasted by someone trying to context switch to the implementation code to understand what an abstraction does.
Name abstractions for the user, not the implementor. Generic names like handle, process, data, or utils communicate nothing and force readers to examine the implementation to understand purpose.
Do not truncate or abbreviate the names of things where doing so would decrease the expressiveness of the code. A function named calculateTaxAmount() is more verbose than calc() or process() – but it is much, much more expressive.
An abstraction’s name should describe what it does from the perspective of someone calling it, not how it works internally. For example, fetchUserData describes the purpose of the abstraction without revealing unnecessary implementation details.
The names of all things – functions, variables, etc. – should be expressive in all contexts. You should not rely on adjacent comments to document the meaning of things where they are declared, because those names will be used in other places where those descriptions are not present. Do not assume that inline API documentation will be parsed by some tool and rendered alongside the calling code.
Expressive naming makes for less brittle code, because the identifiers are less likely to need changing when implementation details change. Code becomes more extensible too, because you are less likely to get conflicts with identifiers you need to add in the future.
In naming things, be specific about side effects and outcomes. If a function performs I/O, triggers side effects, or has specific preconditions, that information belongs in the name. For example, fetchAndCacheUser is more honest than fetchUserData if caching is involved. Some programming contexts benefit from distinguishing between synchronous and asynchronous operations; fetchUserDataAsync would be acceptable in this situation.
Avoid jargon and acronyms unless they are universally understood in your domain. But favor precise, specialized terminology over generic words that could be misunderstood, and respect established precedent in the domain and in the surrounding ecosystem, even where that precedent is less approachable to newcomers. A term already in wide use among the people who work in this domain and this codebase every day should not be replaced with a friendlier synonym for the benefit of readers passing through once; the daily maintainers pay the cost of relearning a name far more often than a newcomer pays the cost of looking one up.
The same discipline applies to word count in the other direction. Include every word needed to remove ambiguity, and omit every word that carries no information. Favoring clarity over brevity, as recommended above, is not license to pad names with words that add nothing – userAccountData says nothing that userAccount doesn’t already say, if the value in question is not raw data but a fully-formed account object.
Two kinds of word rarely carry information in a name: the value’s type and its scope. A name such as idToUserMap or valueString repeats what the declaration already says, and becomes wrong as soon as the type changes, when the map is replaced by a cache or the string is parsed into a number. Name the value for its purpose instead, as in usersById or value. Prefixes and suffixes that encode scope, such as _value, mValue, or gConfig, and Hungarian-style type prefixes, such as strName, have the same problem and SHOULD NOT be used. The exception is a marker that an established language or ecosystem convention requires for a distinction the language cannot otherwise express, such as a leading underscore for a private member in a language without access modifiers. Follow that convention, but add no markers beyond it.
Units are the opposite case. A unit is information a name SHOULD carry wherever the type cannot carry it. A variable called timeout holding 30 could be in seconds or milliseconds, and a caller who guesses wrong writes a bug that no type checker will catch. Where a quantity has a unit, put the unit in the name, as in timeoutMs, pollIntervalSeconds, or fileSizeGb. Better still, where the language or a library provides a type that carries the unit, such as a duration type in place of a bare integer, use it. The unit then travels with the value, and every conversion between units is explicit.
Names form a catalog of things that are relevant to a computer program. Every abstraction adds an entry to the vocabulary of the codebase, and so the names are like words in a custom language that is unique to each program. Naming conventions should be consistent throughout a program, for this reason.
This consistency extends beyond the identifiers in code. Once a term is chosen for a domain concept, use that same term everywhere the concept appears – in code, comments, tests, and documentation alike – rather than letting synonyms accumulate. A concept called order in the code but "purchase" in the tests and "transaction" in the documentation forces every reader to first work out that the three words denote one thing, before they can begin to understand what that thing does.
Magic numbers – unexplained numeric literals or string constants embedded directly in logic – are a naming problem. A condition like if (statusCode === 403) is less expressive than if (statusCode === FORBIDDEN). Replace such literal values with named constants that communicate their meaning in the domain. This rule applies equally to string literals, threshold values, and any other constant that carries a semantic meaning beyond its raw value.
Syntax and control structures
Beyond naming, expressiveness is achieved through thoughtful use of language syntax and control structures.
We should choose idioms and constructs that make the code’s intent obvious. For example, a loop written with a high-level construct like collection.map() or for item in items: is more expressive than manually managing indices with for (let i = 0; i < items.length; i++). Similarly, using guard clauses or early returns in a function makes the happy path more obvious than deeply nested conditionals.
Prefer positive conditionals over negative ones. A condition written as if (isActive) is more immediately legible than if (!isInactive), particularly when combined with other logical operators or nested inside further conditions. Double negatives – such as if (!isNotAuthorized) – should always be refactored into their affirmative equivalent. If no natural positive form of a predicate exists, that is often a sign that the underlying concept is not well named.
Return a boolean expression directly, rather than branching on it only to return a literal true or false. The branch adds lines and a second place to read, and says nothing the expression does not already say. The same applies to a branch whose only job is to choose between two return values, which a conditional expression states in one line.
// Redundant.
if (session.expiresAt <= now) {
return true
} else {
return false
}
// Direct.
return session.expiresAt <= nowWherever possible, use language features that express the domain problem directly rather than forcing readers to translate between low-level mechanics and high-level intent.
Chaining is a common way this trade-off goes wrong. A single dense expression that pipes a value through several transformations – filtering, mapping, mutating – is compact, but it forces the reader to mentally evaluate the whole chain before they can name any of the intermediate values. Breaking the chain into named steps costs a few extra lines but turns each step into a small, self-documenting fact about the transformation. Prefer the explicit form unless the chain is short enough to read as a single idea.
// Clever, terse. return (await fetchData()).filter(f).map(m).unshift(newElm) // Verbose, explicit. const data = await fetchData() const filtered = data.filter(f) const transformed = filtered.map(m) return [newElm, ...transformed]
Naming an intermediate step is worthwhile when the name says something the expression does not. A variable that is assigned and then immediately returned, under a name such as result or a name that repeats the function’s own, says nothing. It SHOULD be removed, and the expression returned directly, as the final step of the chaining example does.
For a similar reason, do not initialize a variable to a placeholder value, such as null, 0, or an empty string, that every path through the code overwrites before the variable is read. The placeholder is never used, but a reader has to check every path to be sure of that. In a language that checks definite assignment, such as Java, C#, or TypeScript, declaring the variable without an initializer lets the compiler prove that every path assigns it. Where a branch chooses the value, prefer an expression that yields it directly, such as a conditional expression or a small function, so the variable can be declared constant.
// A placeholder that is never used.
let label: string = ''
if (count === 1) {
label = 'item'
} else {
label = 'items'
}
// Direct.
const label = count === 1 ? 'item' : 'items'Avoiding magic
Expressiveness is undermined just as much by what a reader cannot see as by what is badly named. "Magic" is behavior that happens implicitly – through naming convention, reflection, decorators, dependency injection, or other framework machinery – rather than through an explicit call the reader can follow at the point of use.
A test runner that discovers test functions by scanning for names prefixed with test_, an ORM that populates a model’s fields by reflecting over a database schema, or a dependency injection container that wires up a class’s constructor arguments by inspecting their type annotations, are all magic in this sense. Each saves the author a small amount of explicit wiring code. Each also means that a reader who has not memorized the framework’s rules cannot tell, from the code alone, what is going to happen – they must already know the convention, or go looking for documentation that explains it.
This is a direct trade-off against the goal of this section: code that reads as narrative. Explicit code may be more verbose, but its behavior is discoverable by reading it. Magic code is more compact, but its behavior is only discoverable by already knowing – or by going elsewhere to learn – the convention or reflection rules the framework applies. The more of a codebase’s behavior depends on such conventions, the more a new reader has to learn before they can trust their reading of any single file.
Some magic is a reasonable trade, particularly where a framework’s conventions are widely known throughout its ecosystem and are consistently documented and applied. But where a framework offers both an explicit and an implicit way to achieve the same result, prefer the explicit one, and be sparing with magic that is bespoke to one codebase rather than a well-known convention of a widely-used framework. Bespoke magic imposes the full cost of learning a hidden convention without the benefit of that convention being documented and understood anywhere outside the one codebase that invented it.
Programming paradigms
Another dimension of expressiveness is the choice of programming paradigm. Different paradigms lend themselves to different kinds of problems, and using the most appropriate paradigm for the task at hand can make code substantially more expressive.
For example, object-oriented programming is well suited to domain modeling, where entities and their relationships are central to the design. Functional programming, on the other hand, excels at data transformation and processing pipelines, where the focus is on composing pure functions and avoiding side effects.
It is perfectly acceptable – and often desirable – to mix and match paradigms within the same codebase. A single application might use object-oriented design for its domain model, functional constructs for data processing, and procedural code for scripts and automation. The goal is not paradigm purity but expressiveness: use whichever paradigm makes the code’s intent clearest for the problem at hand.
Dependency management
Libraries are the ultimate abstractions. They are extracted such that they can be reused between software systems, let alone in the same system.
Managing external dependencies is a key skill in modern software development. The success of open source licensing has substantially reduced the cost of developing software by abstracting common problems to globally-shared libraries. It’s an incredible ecosystem.
Libraries and frameworks solve common problems much faster than building from scratch. But dependencies are not free. They carry tangible costs that need to be weighed against their benefits.
Using external dependencies involves several trade-offs:
- Maintenance and security risk: You are responsible for understanding and vetting all code shipped to production, including code in your dependencies. When you update a dependency, you inherit not just bug fixes but also the risk of newly introduced vulnerabilities or performance regressions. Worse, supply chain attacks specifically target popular packages. Attackers develop useful libraries, build up popularity and trust, then inject malicious code via a patch. You need a strategy for monitoring and updating dependencies safely.
- Size and compilation overhead: Libraries and frameworks increase your codebase size, which affects startup time, compilation time, and deployment size. In some contexts – mobile apps, embedded systems, or performance-critical services – this overhead can be significant.
- Opacity and loss of control: External libraries hide design trade-offs, failure modes, and potential security attack vectors. When you implement a feature yourself, you understand exactly how it works, what could go wrong, and how to debug it. A black-box dependency obscures this knowledge.
- Learning opportunity: Building core functionality yourself, even when libraries exist for it, deepens your understanding of your system and strengthens your craft. It’s often more rewarding than assembling pre-built components.
So, be selective in what dependencies you introduce. Evaluate each dependency carefully. Ask, what problem does this solve, could we solve it ourselves, what’s the maintenance burden, how stable is the project, and how large is the dependency tree it brings with it? Make this analysis explicit and document your decisions.
Once you adopt a dependency, isolate it. Create good abstractions (using the facade pattern) for all external dependencies, including infrastructure-level ones like database access, file system I/O, remote services, and external APIs. This shields your application code from changes in the dependency and makes it easier to swap implementations or remove dependencies later.
Manage your dependencies explicitly. Pin specific versions in your vendor configuration files. Never rely on floating version constraints that could pull in unexpected changes during installation. Better still, consider not using a package manager, and instead add third-party libraries directly to your codebase (usually in a vendors or similar directory). This is more work, but it has numerous advantages:
- It’s easier to audit your application’s dependencies. You get clearer visibility of the dependency tree.
- You will be forced to maintain shallow dependency trees, which in turn reduces the risk of supply chain attacks.
- You are forced to introduce your own tests for each dependency – which is good practice but often overlooked.
- You’ll be able to reproduce builds for any prior version of your software. There’s no risk that earlier versions of dependencies will no longer be available from public code registries. This is a requirement if you want to implement a deployment strategy with automated rollback.
- Your code repository has everything you need to build and run your application. New developers can onboard more quickly. CI/CD pipelines run more quickly, too.
Dependency injection
Dependency injection is a design pattern in which a component’s dependencies are supplied to it from outside, rather than constructed inside it. Instead of a class instantiating its own collaborators with new, it receives them as constructor arguments or method parameters.
This pattern makes dependencies explicit – there is no hidden coupling buried in the implementation. It also makes components easier to test, because dependencies can be replaced with test doubles without modifying the component under test. And it makes it straightforward to swap implementations, which aligns directly with the principle of wrapping external dependencies in good abstractions.
At a higher level, dependency injection is an application of the Dependency Inversion Principle (DIP) – one of the five SOLID principles, see SOLID for the other four. DIP states that high-level modules should not depend on low-level modules directly; both should depend on abstractions, and the abstraction should be owned by the high-level, policy-setting module, not by the low-level implementation. A payment service defines the PaymentGateway interface it needs; a specific gateway implementation depends on that interface, not the other way around. This ownership direction is what allows the high-level module to remain unaware of which concrete implementation it is using.
At the service level, the same principle argues for replacing direct service-to-service calls with an abstraction such as a message bus or an event stream, rather than one service depending directly on another service’s API. The adapter pattern is the DIP applied at an integration boundary: rather than the application depending directly on a specific external API or library, define an interface the application owns, and implement an adapter that translates between that interface and the external dependency’s actual API. This is the same shielding function as the facade pattern described above, applied specifically to keep the dependency’s own interface out of the application’s high-level code.
Reading dependency source
Isolating dependencies behind abstractions guards against the risk of depending on them too tightly. It should not be mistaken for a reason to treat dependencies as opaque black boxes. Where documentation is thin or a dependency behaves unexpectedly, keep a local checkout of its source and read it. Understanding how a library or framework actually works – not just its documented interface, but its internal design and its failure modes – is often the fastest way to debug a strange interaction, and it builds confidence that carries over to future problems with the same dependency.
This applies as much to languages and runtimes as it does to libraries. Most languages and standard libraries are themselves open source. Reading how a language’s core data structures or a runtime’s scheduler are implemented is a normal and valuable engineering habit, not a distraction from application work.
Not-Invented-Here syndrome
The trade-offs above argue for being selective about dependencies, and for understanding the ones you take on. They are not an argument for defaulting to building things yourself. Not-Invented-Here (NIH) syndrome is the tendency to reinvent functionality that a well-maintained, widely-used dependency already provides, out of an unexamined preference for first-party code.
NIH syndrome carries real costs. A hand-rolled replacement for a mature library rarely covers the same breadth of edge cases on day one, and the team now owns its ongoing maintenance – bug fixes, security patches, and feature gaps that the original dependency’s maintainers would otherwise have absorbed. Where a dependency solves a well-understood problem, is actively maintained, and has a stable track record, reusing it is usually the better choice, even though building it yourself would deepen the team’s understanding of the problem.
Weigh this against the dependency risks discussed above, on a case-by-case basis. The learning opportunity of building something yourself is a genuine benefit, and for problems that are central to a system’s competitive advantage, or where no dependency fits well, building it yourself remains the right call. The caution here is against reaching for that option by default, without first asking whether a good, maintained solution already exists.
Configuration and hardcoded values
Consistent with the dependency injection principle, keep configurable values – environment-specific settings, thresholds, timeouts, feature flags, format strings – as high in the call stack as possible. Do not hardcode such values into low-level implementation logic, where they are difficult to find, change, and test. Instead, inject them where needed.
Configurable values buried deep inside implementation code are not obvious to callers. It is unclear that they exist, where to change them, or whether changing them in one place would affect other parts of the system. Lifting configurable values to the top level – into configuration objects, constructor parameters, or environment variables read at startup – makes the configuration surface of the system visible and easy to manage.
Comments
As discussed in earlier sections, abstraction is the primary mechanism for making code expressive and self-explanatory. But abstraction is not always the right tool. When the choice is between premature abstraction and some well-placed comments, add the comments. Comments are cheaper and more reversible than abstractions, and they don’t introduce new dependencies or indirection.
The so-called "self-documenting code" approach, popularized by Uncle Bob’s "Clean Code" book, encourages developers to express intent through well-named abstractions rather than comments.
When you feel the need to write a comment, first try to refactor the code so that any comment becomes superfluous.
– Martin Fowler
This has merit as a first instinct — a comment that only restates what the code already says is better replaced by clearer code. But taken as a rule, it overreaches. Comments cannot be avoided altogether. There are many things that cannot be easily expressed in code alone, no matter how good the abstractions are. Complex algorithms, important context about business rules, rationales for non-obvious design decisions, and assumptions made about how black-box dependencies work – all these things cannot be fully captured by code alone, and yet this is important knowledge that other developers will need to understand and maintain the code in the future.
The usual substitute offered for a comment is a longer, more descriptive name. This standard recommends expressive naming (see Expressiveness), but naming and commenting are not interchangeable, and treating them as though they were leads to identifiers that are neither good names nor good documentation. A name is read at every call site, so it has to stay short enough to read inline; a comment is read once, at the declaration, so it can afford to be a paragraph. Compressing a precondition, a unit, an error case, and a rationale into an identifier produces something long enough to disrupt every line it appears in, and still too terse to say what was needed. Name the abstraction for what it does. Put the rest in a comment above it.
Comments are most useful when they explain things that are not obvious from the code itself. Programs written in low-level languages, like shells and other scripting languages, tend to require more comments, because low-level languages provide fewer constructs for abstraction, and the syntax tends to be quite cryptic and non-intuitive, too. In general, the lower the level of the programming language, the fewer opportunities there are for decomposition into good abstractions, and so the more comments will be relied upon to explain the code. Depending on the audience (the level of experience of the expected maintainers of the code), comments in low-level languages may need to be quite detailed, explaining even basic constructs and control flows.
Inline code comments are particularly valuable for documenting the rationale for unusual or unexpected code or configuration. For example, code that appears to violate good design, but has good reasons to do so (such as legacy constraints, performance requirements, or business necessity), should have those reasons clearly articulated alongside it. Similarly, the rationale for code smells, such as a bloated function or an overloaded class, should be clearly annotated alongside the code. Doing this reduces the risk of future maintainers wasting time trying to refactor the code.
So, we should ignore what Uncle Bob says and instead adopt the view that "comments are (mostly) good"! Even if code looks a bit messier with the addition of comments, this is usually preferable to losing valuable knowledge.
This is not to say that comments should be liberally sprinkled throughout code. Comments that are superfluous, redundant, or that do not add any tangible value, should be removed.
Remember: the purpose of comments is to reduce cognitive overhead. Whatever the language or level of abstraction, add comments where they make things easier to understand, or where you want to communicate important information that cannot be ascertained from the code alone – even with good abstractions.
If in doubt: leave a comment!
What a comment should say
"Explain what isn’t obvious from the code" is the right instinct but a vague test, and it is the reason so many comments end up merely restating their subject line in English. A sharper formulation is this: a useful comment sits at a different level of detail than the code beneath it. It is either more abstract than the code, or more precise than the code. A comment pitched at the same level as the code it sits above has nothing to add, and that is exactly the comment the "self-documenting code" argument is right to object to.
Comments that go higher than the code state intent, strategy, and rationale – what this block is for, why this approach was chosen over the obvious alternative, what invariant is being maintained. They let a reader skip the implementation entirely when they don’t need it, which is the whole point of an abstraction and something the code by itself can never offer.
Comments that go lower than the code state the details the syntax has no way to carry – the units a number is expressed in, whether a range is inclusive, what a caller must guarantee before calling, what state the code leaves behind on failure, which error conditions are possible, whether a returned collection may be empty or null. This is the information a caller needs and would otherwise have to acquire by reading the implementation, which defeats the abstraction.
// Restates the code. Adds nothing.
// Increment the retry counter.
retries += 1
// Higher than the code: explains why.
// Retry on 5xx only. 4xx responses are caller errors and will fail identically
// on retry, so retrying them just delays the error the caller needs to see.
if (isServerError(response)) { ... }
// Lower than the code: states what the signature cannot.
// Returns null (not an empty array) if the account has never been billed.
// `since` is inclusive; timestamps are UTC milliseconds.
function getInvoices(accountId, since) { ... }Comments as a design tool
Comments are usually treated as something added after the code works. Written in the other order, they do useful work as a design check.
Write the comment describing an abstraction’s interface – what it does, what a caller must provide, what it guarantees in return – before writing its implementation. If that comment turns out to be long, or full of conditions and exceptions, or impossible to write without describing how the thing works internally, that difficulty is telling you something about the design, and it is telling you at the cheapest possible moment: before the implementation exists, before there are callers, and before anything has to be refactored. An abstraction whose interface is hard to describe in a few sentences usually has an interface that is hard to use.
This also guards against the failure mode in which comments are written last, by someone who has just spent an hour in the implementation and can no longer see which of its details are surprising. A comment written first describes the abstraction; a comment written last tends to describe the code.
Comment rot
The strongest argument against comments is that they go stale. Code is changed, the comment beside it is not, and a confidently-worded lie now sits in the codebase – worse than no comment at all, because a reader has no way to tell it from the truth until they are misled by it.
The risk is real and should not be dismissed. But it is an argument for maintaining comments, not for omitting them. Information that is genuinely necessary does not stop being necessary because recording it creates an upkeep obligation; omitting it simply transfers the cost from the author to every future reader, permanently. The same argument, applied consistently, would rule out tests, type annotations, and documentation of every kind.
Several of the recommendations elsewhere in this standard reduce the exposure directly:
- Keep a comment adjacent to what it describes. A comment in the same hunk as the code it explains will be in front of the reviewer’s eyes in the diff that invalidates it. A comment in a separate document will not.
- Comment the things least likely to churn. Intent, rationale, invariants, and contracts are more stable than the implementation details beneath them. A comment that narrates the steps of an algorithm rots with every edit; a comment explaining why the algorithm was chosen usually survives the rewrite.
- Don’t duplicate a fact in two comments. Describe each thing once, in the place a reader will look for it, and cross-reference rather than restate it. Every duplicate is another copy that can fall out of step.
- Treat a stale comment as a defect. Updating the comments a change invalidates is part of making the change, not a follow-up task, and reviewers should hold changes to that standard as they would any other.
Other forms of documentation
Inline code comments should not be confused with out-of-band documentation, such as design documents, README files, wikis, and so on. Out-of-band documentation is appropriate for developer-oriented information that is not specific to any particular piece of code, such as overall system architecture, design rationales, and so on.
Use inline comments for documentation that benefits from being close to the code it describes, such as explanations of complex logic, business rules, assumptions, and so on. Also use inline comments for documentation that is likely to change as the code changes. Keeping the code close to its documentation will help to ensure that the documentation stays up to date.
Conversely, inline comments SHOULD NOT be used for material that belongs out of band, such as instructions for how to build, test, deploy, or configure the code. That material is not about any one piece of code, and a reader looking for it will look in the README, not in the source.
Inline code comments should also not be confused with inline API documentation, such as Javadoc comments or Python docstrings. Where a language has a parseable API documentation format, the interface of a function, class, or module – what it does, what a caller must provide, what it guarantees in return, and the "lower than the code" details described above – SHOULD be written in that format, not in general inline comments. API documentation is what IDEs and other tools surface at the point of use; a contract written in a plain comment is invisible there. General inline comments are for the implementation: how the code does what it does, and why.
TODO comments
It is okay to leave TODO comments in code. Most software is a perpetual work-in-progress, and inline TODO annotations are particularly handy to communicate notes between developers while new or changed functionality is being implemented in an iterative and incremental fashion.
Under iterative and incremental development models, areas of a codebase may be incomplete at any point in time. If continuous integration is practiced, incomplete code may exist in the project’s main branch of development. For example, buttons may exist in the UI, behind feature flags, that do not yet do anything when clicked.
It is RECOMMENDED that areas of incomplete code and configuration be tagged with a consistent inline commenting convention. This allows developers to search the entire codebase for incomplete code. Using a TODO commenting convention reduces the risk that incomplete user journeys will get shipped to production.
Another valid reason to use TODO comments is to flag known technical debt. This can help to keep the project’s issue tracker more focused on business requirements, performance enhancements, and more widespread refactoring plans.
The following is a recommended convention for writing TODO comments. The lines should be prefixed with the appropriate comment syntax for the programming language.
TODO: <comment> [<url>]
<comment> is REQUIRED and it should be a short description of the outstanding task. The <url> component is OPTIONAL and is a link to a related ticket in the project’s issue tracker, if applicable.
Example:
// TODO: Find a better solution to ignoring Apollo's __typename key. // https://hackscorp.atlassian.net/browse/HCK-1234
Most modern IDEs can be easily configured to parse a project space for this comment format, and to automatically generate a list of all "TODO" comments. For Visual Studio Code, the Todo Tree extension is recommended. It adds a panel to the activity bar where the current workspace can be traversed in a tree view.
Not everyone likes to see "TODO" comments in source code. But used judiciously in appropriate contexts, they can provide a useful extra quality gate — if all the "TODO" comments are expected to be removed before the code hits production — and they provide a standard convention for cross referencing open issues from code.
Error handling
Exceptions are a control flow mechanism. They represent truly exceptional circumstances — conditions that indicate a bug in your code, or anything that requires investigation by developers. Exceptions should not be used for ordinary error conditions that are expected to occur during normal program execution.
This distinction is important because exceptions are expensive. They interrupt the normal flow of control, unwind the call stack, and often trigger logging and monitoring.
Throwing exceptions for routine failures like network timeouts, missing resources, or invalid user input adds unnecessary overhead and obscures the difference between "something went wrong in an expected way" and "our code has a bug."
Expected failures are not exceptional
Applications operate in unpredictable environments. Third-party services fail. Network requests time out. Files go missing. User input is invalid. These are not bugs in your code — they are normal operating conditions that your application should expect and handle gracefully.
When calling external services or APIs, expect failures. Do not throw exceptions to signal that a network request failed or that a dependency returned an error. Instead, model these outcomes explicitly in your return types or data structures. This makes the caller aware that failure is possible, and forces them to handle it as part of normal control flow rather than as an afterthought in an exception handler.
If an abstraction you’re using throws exceptions in non-exceptional cases, catch them on immediate return from the calling code (don’t let the exception propagate up the call stack) and handle them gracefully.
Good error handling acknowledges that failures are ordinary events that require recovery, retry logic, fallback behavior, or clear communication to the user about what went wrong. None of these recovery strategies benefit from throwing exceptions.
The robustness principle
Postel’s Law, originally formulated for network protocol design, is a useful heuristic for error handling in application code. The idea is that your code should be tolerant of a wide range of inputs – handling edge cases, unexpected formats, and missing data gracefully – while being strict and predictable in what it produces as output.
Applied to error handling, this means: don’t let minor input irregularities escalate into exceptions. Instead, normalize and accommodate where possible, and produce clear, consistent error responses when you cannot.
There is a tension here with defensive programming, which advocates for strict validation and early failure. Both approaches have merit, and the right balance depends on context. At system boundaries – API endpoints, user input handlers, file parsers – be liberal in what you accept, applying reasonable normalization and coercion. Within internal code – business logic, domain models, data processing – be conservative and defensive, enforcing invariants strictly so that bugs surface early and close to their source.
Exceptions in the UI layer
Exceptions should never be thrown from the user-facing layers of your application. The UI or presentation layer is the boundary between your application and the outside world. It is the place where all error conditions — whether they originated from bugs in your code or from expected failures in external dependencies — should be caught, normalized, and converted into user-friendly error messages.
Throwing exceptions allows errors to propagate unchecked through the UI layer, where they may expose sensitive information about your application’s internals, infrastructure, or data structures to end users. This is both a security concern and a poor user experience.
Fail gracefully
When operations fail, applications should respond with grace. This means:
- Catching errors at appropriate boundaries in your code.
- Transforming technical error details into meaningful, actionable messages for users.
- Hiding internal implementation details and infrastructure specifics.
- Offering recovery options where possible – retry, alternative actions, etc.
The goal is that users experience failures as understandable, recoverable situations, not as cryptic error dumps or application crashes.
Aggregate exception handling
Beyond minimizing the number of exception types you throw, minimize the number of places that handle them. Rather than writing a distinct handler for each exception a piece of code might raise, handle many exceptions with a single piece of code, positioned at a boundary where the response to failure is the same regardless of which specific exception occurred.
This is distinct from minimizing exception types. Minimizing types reduces the surface a caller must be aware of; aggregating handling reduces the number of places in the codebase that contain recovery logic. A module can throw a small number of well-chosen exception types and still scatter handling for them across dozens of catch blocks, each with slightly different recovery logic that has to be kept consistent by hand.
Look for opportunities to consolidate. If several call sites all respond to failure the same way – log, return a default, surface a generic error to the user – move that handling to one place, such as a shared boundary or middleware layer, rather than repeating it at every call site. This mirrors "define errors out of existence" and the UI-layer boundary already described above: the fewer distinct places that contain error-handling logic, the easier that logic is to keep correct and consistent as the system evolves.
Minimize exception types
Error handling is a significant source of complexity in software systems. Every distinct exception type you throw is part of your module’s public interface. The more exception types you expose, the more complex the calling code becomes, because callers may need to handle each type differently.
Throwing lots of different exceptions is not a sign of better design. It is a sign of leaking complexity. Prefer a small number of well-defined exception types that communicate clearly what went wrong, rather than a proliferation of fine-grained types that mirror every internal failure mode.
Better still, design your system to minimize special cases and edge cases in the first place. Reducing conditional logic and normalizing data early in the pipeline means fewer error paths to handle downstream.
Code structure
Beyond the logical design of code, the physical layout of a source file affects how quickly it can be read and navigated. Good code structure reduces the time it takes to orient yourself in an unfamiliar file and lowers the cognitive overhead of following logic through it.
Note
Much of the guidance in this section is drawn from Uncle Bob’s bible on clean code.
Vertical structure
Think of a source file like a newspaper article: the most important, high-level concepts come first, and things get progressively more detailed as you read further down. Public functions and entry points should appear near the top of a file; private helper functions should follow below. When one function calls another, define the caller above the callee. A reader following the file top-to-bottom encounters abstractions before their implementations, and can stop reading once they have a sufficient understanding of the high-level behavior.
Related code should appear close together. If two functions are closely related – one calling the other, or both operating on the same data – they should be near each other in the file. Related variables and fields should be grouped rather than scattered. Conversely, use blank lines to visually separate unrelated concepts. The eye naturally interprets whitespace as a boundary between distinct ideas.
Declare variables close to where they are first used. Avoid the practice of declaring all variables at the top of a function; declare each one immediately before the context that gives it meaning. This reduces the mental overhead of tracking variable lifetimes and minimizes the distance between a variable’s declaration and its use.
Horizontal structure
Keep lines short. The conventional guideline is around 80–120 characters per line. Long lines force horizontal scrolling, are harder to read in diff views and code review tools, and are harder to scan at a glance. When a line grows beyond this range, break it sensibly across multiple lines with consistent indentation.
Do not use horizontal alignment to make code look visually symmetric across adjacent lines – for example, aligning assignment operators or values into neat columns. Such alignment may look tidy at first but it makes routine edits (adding entries, renaming identifiers of different lengths) unnecessarily disruptive and difficult to keep consistent over time. You also end up with unnecessarily large diffs every time you change anything.
Use whitespace within lines to clarify groupings. Spaces around operators, consistent spacing inside argument lists, and appropriate use of parentheses all make the structure within a line more legible.
Consistency and automation
Structure conventions should be consistent throughout a codebase. Inconsistency in layout is a low-level cognitive tax on readers, who must constantly adapt to different styles when moving between files or modules.
Rather than leaving formatting to individual preference, configure a code formatter or linter to enforce layout rules automatically. Apply formatting on save in the editor, or enforce it as a pre-commit hook or CI check.
This removes formatting from code review discussions entirely and ensures that the codebase remains consistent as it grows.
Rules of thumb are not rules
Structure guidance of the kind set out above takes the form of heuristics that are sound in general and wrong in particular cases, and heuristics of this kind routinely conflict with one another.
Consider the widely repeated advice that a function should be no more than a handful of lines long. Applied without judgment, it produces code that is harder to read, not easier: logic fragmented across many small functions, each of which must be located and held in mind to understand the whole. Good naming mitigates this but does not eliminate it. The advice to write small functions is in genuine tension with the advice, above, to keep related code together, and neither wins outright. The right balance depends on the specific code in question.
This tension is not a flaw in the guidance – it is the normal condition of design work. What it demands is empirical judgment: assess the result by reading it, and by asking whether the next person will find it comprehensible. See TS-2 for this same principle applied to habitability more broadly.
Object-oriented design
The following guidance extends the general advice on abstraction to the specific patterns and pitfalls of object-oriented programming.
SOLID
SOLID is a mnemonic for five object-oriented design principles: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. The principles were formulated for OO languages, but each has a natural restatement for multi-paradigm, dynamic, and functional code, where the "module" in question may be a function, a exported object, or a file rather than a class.
Much of SOLID is already covered elsewhere in this standard, under different names. Single Responsibility is the focus of Keep methods and classes focused. Open-Closed is the outcome of Polymorphism over conditionals. Dependency Inversion is the principle behind Dependency injection. This section names those connections explicitly, and adds the two principles – Liskov Substitution and Interface Segregation – that TS-7 does not otherwise state.
Knowing the SOLID names is useful independent of the guidance itself: they give a shared vocabulary for code review discussions, and searching for "violates SRP" or "violates OCP" surfaces a much larger body of external material than any bespoke terminology this standard could invent.
Liskov Substitution Principle
A subtype must be substitutable for its base type without altering the correctness of the program. Any code written against the base type’s interface should continue to behave correctly when a subtype is passed in its place.
Concretely, this means a subtype must not strengthen the preconditions its base type promises to accept, and must not weaken the postconditions its base type promises to deliver. A method override that throws on inputs its base class accepted, or that returns a narrower range of results than its base class documented, violates the principle – callers written against the base type’s contract will break when handed the subtype, even though the type system raises no error.
In duck-typed and functional code, where there is no formal subtype relationship, the same discipline applies to any code that conforms to a shared interface or is passed as a substitutable implementation: keep the promises the interface declares. A retry-wrapped implementation of a function MUST accept everything the unwrapped version accepts and MUST deliver everything it promises to deliver – it MAY do more, such as retrying internally, but MUST NOT do less.
A common symptom of an LSP violation is calling code that has to check the concrete type of an object before it can safely call a method on it – for example, testing whether an object is a specific subtype before calling a method that is only safe on some subtypes. Where such a check exists, one of the implementations is not honoring the shared contract, and the fix is to either strengthen that implementation to honor it, or to recognize that the two types do not actually belong in the same hierarchy.
Interface Segregation Principle
Clients should not be forced to depend on interface members they do not use. A large, general-purpose interface that bundles together methods serving several unrelated clients forces each client to depend on – and be affected by changes to – the whole interface, including the parts it never calls.
Prefer several small, role-specific interfaces over one broad interface. Where different callers use a component for genuinely different purposes, define a separate interface for each purpose, even if a single concrete class implements all of them. A caller that only ever reads from a repository should depend on a read-only interface, not the full read-write interface, even if the underlying implementation supports both.
The benefit is reduced coupling. A client that depends only on the interface it actually uses is unaffected when the broader component changes in ways that do not touch that interface, and the interface it depends on is easier to understand and to substitute with a test double, because it only exposes what that client needs to see.
Composition over inheritance
Inheritance is a design pattern supported by object-oriented programming languages that enables abstraction. However, deep inheritance hierarchies work against the goal set out in Module depth, of modules with simple interfaces over substantial implementations. A subclass several levels down a hierarchy has an interface that is the accumulation of every ancestor’s public and protected surface, much of which it neither uses nor needs, and an implementation that is scattered across all of those ancestors. To understand what one method call does, a reader must work out which class in the chain actually provides it, and what the intervening classes have overridden. The interface is large and the implementation is not hidden, which makes the subclass a shallow module in the sense that matters, however deep its hierarchy.
Better to compose complex logic from modules that each own their implementation outright. Composition keeps the relationship between two units explicit and visible at the point of use, where inheritance makes it implicit and distributed across a hierarchy. Erring on the side of composition over inheritance tends to lead to code designs that are more expressive and have better evolvability.
A notable exception to this rule is domain modeling. In this use case, inheritance hierarchies can be quite useful for modeling real-world taxonomies and ontologies. (This was the original intent of object-oriented programming, after all.)
In most other use cases, inheritance hierarchies should be kept shallow, or inheritance avoided altogether.
Polymorphism over conditionals
Long chains of if/else or switch/case statements that branch on the type, category, or state of an object are a common code smell. They are brittle because every time a new variant is added, each such chain must be found and updated. They also spread type-discriminating logic across the codebase, making it hard to locate all the places where a given type affects behavior.
Object-oriented polymorphism provides a more expressive and extensible alternative. Rather than asking "what type is this object?" and switching on the answer, define a common interface or abstract base class and let each concrete type implement that interface with its own behavior. The calling code then invokes the interface method, and the correct behavior is dispatched automatically at runtime.
This is the mechanism behind the Open-Closed Principle – "open for extension, closed for modification" – one of the five SOLID principles introduced in SOLID. A conditional chain must be edited every time a new variant is added; a polymorphic design is extended by adding a new implementation of the interface, without touching the existing dispatch code. In multi-paradigm and functional code, the equivalent mechanism is a hook point or an overridable callback – a function accepted as a parameter to vary behavior – rather than a class implementing an interface, but the principle is the same: add new behavior by supplying a new implementation, not by editing a central dispatch point.
This is not a rule against all conditional logic. Simple conditions for genuine business decisions are perfectly appropriate. The heuristic applies specifically to conditionals that discriminate on the type or category of an object in order to select different behavior – a pattern that is almost always better expressed through polymorphism. Doing so also opens the design to extension without modification: new variants can be added by implementing the interface rather than by editing existing chains.
A related pressure: prefer guard clauses (early returns) over if/else chains. When a method branches on an error or edge case, return early and leave the main path unindented. if/else chains tend to grow over time into tangled conditional logic, and where the branches select behavior based on an object’s state or type, polymorphism is the better tool.
Law of Demeter
The Law of Demeter, sometimes called the "principle of least knowledge", is a guideline for reducing coupling between abstractions. It states that a method should only interact with: the object itself; its direct fields; arguments passed to the method; and objects it creates directly. It should not "reach through" one object to call methods on another object obtained from it.
Violations often manifest as chains of calls like order.getCustomer().getAddress().getCity(). Such chains make the calling code brittle, because it now depends not just on the Order abstraction but also on the internal structure of Customer and Address. If any intermediate representation changes – for example, the concept of an address is refactored – all such call chains throughout the codebase must be updated.
The fix is usually to add a delegating method to the intermediate object: order.getDeliveryCity(). This keeps the traversal internal to the abstraction, where it belongs, and presents a stable interface to callers.
The Law of Demeter reinforces encapsulation. Good abstractions hide their internal structure. By following this principle, callers are encouraged to respect those boundaries, resulting in code that is less brittle and easier to evolve independently.
Objects and data structures
In object-oriented design, there is a fundamental and often overlooked distinction between two different kinds of construct: objects and data structures.
Objects hide their internal data and expose behavior through methods. Callers ask the object to do something and trust it to manage its own state. The implementation – how data is stored, what algorithms are applied – is an internal concern that callers do not need to know about.
Data structures expose their data directly, via public fields or simple accessors, and have little or no significant behavior. Any logic that operates on the data lives in separate code that works with the structure.
These constructs are not in competition – both are useful in different contexts. But they are complementary opposites, and conflating them into "hybrids" produces poor designs. A class that exposes its internal data through getters and setters and also contains significant business logic is neither a clean object nor a clean data structure. Such hybrids attract additional responsibilities over time, making them harder to reason about and change. Choose one or the other: hide the data and expose behavior, or expose the data and keep behavior elsewhere.
Where you do hide data behind behavior, follow the Tell, Don’t Ask principle: tell an object to do something, don’t ask it for its data and then decide what to do with it. Decisions based on an object’s state SHOULD live inside the object itself. Exposing state through getters so that callers can inspect it and act on it re-introduces the hybrid — the object becomes a data structure again, with the logic that should belong to it scattered across its callers.
Value objects
Primitive types – strings, integers, booleans, dates – are versatile, but they carry no domain meaning. A function signature like createUser(string, string, int) tells callers nothing about what the arguments represent. Is the first string a username or an email address? In what format should it be passed? What unit is the integer measured in?
Prefer value objects over raw primitives for domain concepts. A UserId, EmailAddress, or MonetaryAmount type communicates intent unambiguously. It also enforces its own invariants – a valid email format, a non-negative monetary amount – keeping validation logic in one place rather than scattered across every call site that receives a raw string or number.
This is sometimes called avoiding primitive obsession: the tendency to represent meaningful domain concepts with generic language types rather than dedicated wrappers. Value objects make function signatures self-documenting and make misuse harder, because the type system can reject a value of the wrong kind passed in the wrong position.
Value objects should be immutable. Once created with valid state, a value object should not be modifiable. This eliminates an entire class of bugs related to shared mutable state and makes value objects safe to pass between components without defensive copying.
The same principle applies to collections. A class that contains a collection SHOULD contain no other instance variables — give each collection its own class so that the behaviors that operate on it (filtering, mapping, validation) have a home. This keeps collection logic out of the classes that merely use the collection, and treats the collection itself as a domain concept rather than a raw container.
Encapsulating boundary conditions
Boundary conditions – range checks, upper and lower limits, off-by-one calculations – are among the most error-prone parts of any program. They are easy to get subtly wrong, and when the same boundary logic is scattered throughout the codebase, inconsistencies inevitably creep in.
Encapsulate boundary conditions in dedicated abstractions. A DateRange class that captures the semantics of inclusive and exclusive bounds, or a PageSlice that encapsulates the logic of page numbers and offsets, puts the boundary logic in a single, testable place. Any code that works with the concept uses the abstraction and trusts it to handle the edge cases correctly, rather than duplicating the same defensive checks at every call site.
Static vs non-static methods
Static methods – methods that belong to the class rather than to any instance of it – are sometimes a tempting shortcut. They require no instantiation, are globally callable, and seem to simplify utility-style operations. In languages like Java, they are a common way to group functions that have no natural object to belong to.
However, static methods come with trade-offs. Because they cannot be overridden through inheritance or replaced through dependency injection, they introduce a form of tight coupling that is difficult to break. Code that calls a static method directly is tightly coupled to that specific implementation, making it harder to substitute a different behavior in tests or alternative deployments.
Non-static methods, by contrast, are invoked on an instance. That instance can be injected, substituted, or mocked, which makes the code that uses it easier to test and evolve. Non-static methods also participate in polymorphism, so behavior can be varied by supplying a different implementation of the same interface.
Prefer non-static methods as the default. Reserve static methods for genuinely stateless, context-free utility functions where the lack of substitutability is an acceptable trade-off – for example, pure mathematical calculations or simple string transformations where no alternative implementation would ever be needed.
Keep methods and classes focused
The measure of a well-designed class or method is not its length but its focus. A class or method SHOULD have a single responsibility — one reason to change. It SHOULD be as long as it needs to be to fulfill that responsibility completely, and no longer. Function length covers the general argument; this section applies it to classes and their methods.
Imposing arbitrary length limits on classes and methods is counterproductive. When a fixed ceiling is enforced, the tendency is toward unnecessary extraction: methods and classes are split purely to stay under the limit, even when the extracted code has no coherent responsibility of its own. Each extraction adds a new entity, a new name, and a new dependency between the parts that were previously together. This increases the dependency chain and raises the overall complexity of the system — the opposite of what the rule was meant to achieve.
A method that is long because it is doing one complex thing well is better than several short methods that each do a fragment of it and must be read together to be understood. Extract a method when the extracted code has a clear, independent responsibility — a name that describes what it does without reference to the caller — not merely to reduce line count.
Similarly, a class that is large because it encapsulates a single, cohesive concept is preferable to several small classes wired together with boilerplate. A class that accumulates many instance variables MAY be gathering unrelated state, which is a sign that it should be split — but split along the fault lines of responsibility, not at an arbitrary line count.
Don’t abbreviate names to keep entities short. Abbreviations save a few keystrokes at the cost of readability. If a name feels too long to repeat, the method is probably reused heavily — which suggests duplication, or that the class has too many responsibilities. If you can’t find a concise, descriptive name, something is wrong with the abstraction.
Concurrency
Concurrency – writing code that executes in parallel across multiple threads, processes, or asynchronous tasks – introduces a category of complexity that is qualitatively different from ordinary sequential logic. Bugs in concurrent code can be intermittent, environment-dependent, and extremely difficult to reproduce and diagnose. For this reason, concurrency deserves deliberate design attention rather than being treated as an implementation detail.
Separate concurrency from business logic
The single most important rule for concurrent code is to keep the concurrency mechanics separate from the business logic they are threading through. A function or class that simultaneously manages thread lifecycles, synchronization primitives, and domain behavior is doing too many things. It is harder to read, harder to test, and harder to reason about.
Extract the concurrency infrastructure – thread pools, task queues, executors, async wrappers – into its own layer. Business logic should be written as if it were single-threaded, and composed with the concurrency layer at a higher level. This separation makes it possible to test the business logic in isolation, without the non-determinism of concurrent execution.
Shared mutable state
The fundamental source of concurrency bugs is shared mutable state – data that is readable and writeable by more than one thread or task at the same time. Race conditions, data corruption, and deadlocks all trace back to unsynchronized access to shared mutable data.
The most reliable way to avoid these problems is to eliminate shared mutable state wherever possible. Two complementary strategies help here:
Immutability. Objects that cannot be modified after construction are inherently thread-safe. They can be shared freely across threads without synchronization. Prefer immutable data structures and value objects in concurrent contexts. When state does need to change, produce a new value rather than mutating the existing one.
Message-passing. Rather than sharing state between concurrent components, pass messages. Each component owns its own private state and communicates with others only by sending and receiving messages. This is the model underlying actor frameworks, channels in CSP-style concurrency (Go, Kotlin coroutines), and event-driven architectures. It eliminates shared mutable state by design.
Synchronization
When shared mutable state cannot be avoided, it must be protected with synchronization mechanisms – locks, mutexes, semaphores, atomic operations, or language-level constructs like synchronized blocks.
Keep synchronized sections as small as possible. Only the minimal critical section – the exact reads and writes that must be atomic – should be inside the lock. Holding a lock across large blocks of logic increases contention, reduces throughput, and raises the risk of deadlock.
Be wary of acquiring multiple locks. Any code that must acquire more than one lock at a time is at risk of deadlock if other code acquires the same locks in a different order. If multiple locks are genuinely necessary, establish and document a consistent acquisition ordering across the codebase, and follow it without exception.
Designing thread-safe classes
Not every class needs to be thread-safe, and making one thread-safe is not free. Decide deliberately, per class, rather than by blanket policy.
When to make a class thread-safe. Base the decision on how the class is actually used: will instances be exposed to concurrent write/write or read/write access from your own code? If so, the class must be thread-safe. If a class is only ever used from a single thread, or only ever read concurrently after safe construction, synchronizing it adds cost for no benefit. A library or shared component whose usage context is not known in advance should offer both variants – see "Thread-safe wrappers" below – rather than guessing.
Synchronizing critical sections. Where shared mutable state cannot be avoided (see "Shared mutable state" above), the standard technique is to make the relevant fields private and mark the code that reads and writes them as a critical section, guarded by a lock. Only code that controls access to the data through its own methods can enforce that access is safe – a public mutable field cannot be protected by any amount of synchronization elsewhere in the class. On platforms where only some primitive read/writes are atomic by default (for example the JVM, where long and double are not), treat those types with the same care as any other shared mutable field.
Immutable objects. Immutability (see "Shared mutable state" above) works best for small value types – simple data carried by the object, with no identity beyond its value. A mutating-looking method returns a new instance rather than altering the existing one, which is what makes the object safe to share across threads without synchronization. The trade-off is allocation: an immutable design under high-frequency mutation can generate many short-lived objects and add garbage-collection pressure. This is rarely a reason to abandon immutability, but it is a reason to profile before assuming the trade-off is free (see "Optimization as a source of over-engineering" in Decomposition).
Thread-safe wrappers. Where a class cannot be made thread-safe directly – commonly because it comes from a third-party library you cannot modify, or because you want to offer both variants from your own code – wrap it. A thread-safe wrapper accepts the same operations as the object it encloses, delegating each one through a synchronized method. This is the same structure as the decorator pattern, and it is how many standard-library collection types offer a synchronized view over an unsynchronized implementation.
The cost of synchronization. Synchronization is not free: a synchronized method invocation is meaningfully slower than an unsynchronized one on most platforms, and unnecessary synchronization adds thread contention – blocking and unblocking – for no correctness benefit. Do not synchronize a class that is never exposed to concurrent access. Conversely, do not skip synchronization on a class that is, in order to avoid this cost: an intermittent, hard-to- reproduce concurrency bug (see "Testing concurrent code" below) is far more expensive than the overhead of a lock.
Testing concurrent code
Concurrency bugs are notoriously difficult to test because they are often non-deterministic – they may appear only under specific timing conditions, on specific hardware, or under load. A test suite that passes consistently in a development environment may fail in production.
Test concurrent code rigorously and with specific tooling. Run tests repeatedly and under stress conditions to expose race conditions that manifest only intermittently. Use thread sanitizers and concurrency analysis tools where available. Design business logic to be testable in isolation from concurrency infrastructure, so that the bulk of correctness testing can be done deterministically in a single-threaded context.
Mental models
When working with an unfamiliar language feature, library, or piece of infrastructure, it’s tempting to build up a catalog of memorized rules and edge cases: this function behaves this way, that flag does that, this combination doesn’t work for reasons you’ve stopped questioning. This approach works, up to a point. But it doesn’t scale, and it doesn’t transfer. Every new edge case is a new fact to memorize, and none of those facts help you reason about the next situation you haven’t seen before.
The alternative is to build a mental model – a small, coherent picture of the system’s core primitives and the principles that generate its behavior. A mental model doesn’t need to be complete or perfectly accurate to be useful. It needs to be small enough to hold in your head, and accurate enough that you can use it to predict how the system will behave in situations you haven’t encountered.
Consider the difference between memorizing a list of shell quoting rules – when to use single quotes, when to use double quotes, how backslashes behave in each context, what happens with nested quotes – and instead understanding that a shell processes a command line through a sequence of expansion phases (brace expansion, tilde expansion, parameter expansion, command substitution, word splitting, and so on) applied in a fixed order, and that quoting simply suppresses specific phases. The second approach lets you derive the answer to a quoting question you’ve never seen before, rather than needing to have seen it already.
Building a model
Building a mental model of a system usually means understanding it at one layer of abstraction below the one you normally interact with. You don’t need to understand a language runtime’s bytecode to build a useful model of the language’s execution semantics, but you do need to understand the execution semantics to reason confidently about the code you write in that language.
A useful mental model typically captures:
- The core primitives the system is built from, and how they compose.
- The rules or phases that govern how those primitives combine to produce behavior.
- The invariants that always hold, and the boundaries of what the model does and doesn’t cover.
Reading the source of the systems and dependencies you rely on is one of the most effective ways to build an accurate model – see Reading dependency source. Documentation and specifications are another source, though they tend to describe behavior in terms of individual rules and cases rather than the generative principles behind them; deriving the model yourself, by reading source and experimenting, often produces a more durable understanding than reading a description of the model secondhand.
Where to invest
Building a mental model has an upfront cost, and it is not worth paying for every system you touch briefly. It pays off for the languages, frameworks, runtimes, and tools you use daily, where a durable understanding compounds over months and years of use, replacing an ever-growing catalog of memorized special cases with a small set of principles that keeps working as you encounter new situations.
A good mental model also changes how you debug. Rather than pattern-matching a symptom against a list of remembered causes, you can reason from the model’s primitives to the space of possible causes – and recognize, when the observed behavior doesn’t fit the model, that either the model is wrong or you’ve found a genuine bug.
References
- 16 Bit Terminal (2024). The First Book of Byte-Sized Tech.
- Bay, J (2008). Object Calisthenics.
- Constantine, L L, and Yourdon, E (1979). Structured Design: Fundamentals of a Discipline of Computer Program and Systems Design.
- Durand, W (2013). Object Calisthenics.
- Elhage, N (2024). Computers Can Be Understood.
- InfoWorld. Design for Thread Safety.
- NoComplexity. Design Principles for Reducing Complexity.
- Oracle (1999). Code Conventions for the Java Programming Language.
- Ousterhout, J, and Martin, R C (2024). A Philosophy of Software Design vs. Clean Code.
- Raymond, E S (2003). The Art of Unix Programming.
- Siddiqi, Z (2024). Good Software Development Habits.
- Stack Overflow (2021). Why SOLID principles are still the foundation for modern software architecture.
- Twitter. Java Style Guide.