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: Application Architecture and TS-2: Software Design Qualities.
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.
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.
Abstraction depth
In most types of abstraction, the complexity that is hidden behind the facade – the "common pattern" that is abstracted away – is replaced by an identifier of some kind, like a function name or class name.
For code to remain understandable, the names given to abstractions SHOULD accurately express all the important details of the logic and data they hide. The important details are everything that users of the abstraction need to understand.
Creating good names for abstractions is easier to do when those abstractions are small, simple, and focused.
For this reason, shallow abstractions are preferred to deep ones. A shallow abstraction is something like a function with a single responsibility. The function does just one very specific thing. It has a simple interface that clearly expresses the function’s expected behavior, without revealing the technical details of its internal implementation.
A deep abstraction may also have a simple interface, but it hides significant complexity. The user sees a straightforward interface but the component does substantial work.
Deep abstractions can be very desirable for some requirements. Consider, for
example, a file I/O library where you call read(file) and the library handles
buffering, caching, and error handling internally. Or a database client where
you call query() and it handles connection pooling, parsing, optimization, and
networking. These are deep abstractions, because they hide a lot of
implementation detail. But they also have simple interfaces, because much of
their implementation detail does not need to be surfaced to users. These are
good abstractions.
But generally, deep abstractions, which hide complex operations, tend to be quite difficult to express with sufficient precision and accuracy. Consider, for example, a single function call that hides hundreds of lines of logic, including important business rules, and that has multiple interactions with various external systems. It would be hard to come up with a name that expresses all of the abstraction’s important behavior – stuff that is important to users of the abstraction. Such an abstraction would also be pretty difficult to debug, to reuse in different contexts, and to modify and extend.
It’s much more important for an abstraction to have a simple interface than a simple implementation. Though we ought to be wary of over abstracting things, deep abstractions can be effective as long as their interfaces can be made to be simple and stable. The goal of abstraction is primarily to create simple and stable representations of discrete units of logic and/or data – however simple or complex those extracted units of logic and data are.
Thus, we should not put arbitrary limits on abstractions. We should not, for example, cap the number of statements allowed in a function body, or restrict the number of protected or private methods within a class. Abstractions should be as deep as they need to be to sufficiently hide their internal implementation details.
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.
Leaky abstractions
Deep abstractions tend to be leaky. Because deep abstractions encapsulate lots of complexity, there’s a greater chance that some of that complexity will necessarily be exposed through the abstractions' public interfaces. Implementation details leak out through the interface.
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.
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 shallow the 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.
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.
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.
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.
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.
Wherever possible, use language features that express the domain problem directly rather than forcing readers to translate between low-level mechanics and high-level intent.
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 inversion of control principle: high-level modules should not depend on low-level modules directly. Both should depend on abstractions.
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.
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!
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.
Inline code comments should not be confused with inline API documentation, such as Javadoc comments or Python docstrings. These serve a different purpose to general inline comments.
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.
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.
Object-oriented design
The following guidance extends the general advice on abstraction to the specific patterns and pitfalls of object-oriented programming.
Composition over inheritance
Inheritance is a design pattern supported by object-oriented programming languages that enables abstraction. However, inheritance encourages high levels of abstraction. Since shallow abstractions should be preferred over deep ones, it follows that deep inheritance hierarchies should be avoided.
Better to compose complex logic from lots of small, shallow abstractions. 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 should be shallow, or 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 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.
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.
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.
References
- Object Calisthenics — William Durand: A practical walkthrough of nine object-oriented design heuristics, with examples. Several of those heuristics inform the guidance in Object-oriented design.
- Object Calisthenics — Jeff Bay: The original essay, from The ThoughtWorks Anthology (2008).