TS-2: Software Design Qualities
This technical standard establishes nine core qualities for evaluating software design. In no particular order, these qualities are:
- Completeness:
Whether a software system provides all the functions its users need. - Correctness:
A system’s ability to maintain valid, consistent state. - Performance:
How well a system operates within its specified constraints — its runtime quality attributes taken as a whole (latency, throughput, availability, security, and the rest), of which performance in the strict sense (speed and capacity) is one. - Reliability:
A system’s ability to handle and recover from failures of all kinds. - Experience:
How a system is perceived by its users. - Habitability:
How easy a system is for developers to maintain and evolve. - Cohesiveness:
To what degree a system’s design forms a unified, consistent whole. - Changeability:
The ease with which software can be adapted to changing requirements. - Simplicity:
How well a system minimizes unnecessary complexity in both its interface and implementation.
These software design qualities are universal. They have an impact on every abstraction level within a solution’s architecture, and they are relevant to all types of software – whatever the runtime environment, whatever the programming language, and whether the system is distributed or single-node.
No priority should be assumed from the order in which these qualities are presented. Their relative importance will differ between business domains and architectural styles. But all these qualities are of importance in every software system to some degree.
It’s best not to view these qualities as being mutually exclusive. In fact, there’s much overlap between them and they reinforce one another. For example, the design constraints necessary to make a system fast and responsive tend also to result in system designs that are simple, easy to change, and fault-tolerant.
That is the general case, not the universal one. Some pairs of qualities do pull against each other, and where they do, the design has to choose deliberately rather than by default. The closing section on trade-offs covers the recurring conflicts and how to settle them.
Taken as a whole, these quality attributes form a cohesive software design philosophy.
This technical standard also touches upon the software design process itself. Topics such as iterative and incremental development, feedback loops, and domain modeling are mentioned. After all, these are important methods and tools to help us achieve our design goals. But the focus of this technical standard is the end result – what good software design looks like – not the means of getting there.
Related standards include TS-5: Application Architecture, which covers architectural patterns for single-node applications; and TS-7: Code Design, which covers low-level concerns such as code formatting and exception handling.
Completeness
The quality of completeness is defined here as a system that provides all of the important functions that its target users need or want.
All software – but especially web-based software-as-a-service and other continuously-delivered systems – is perpetual work-in-progress. Modern software systems are continuously evolving, responding to ever-changing requirements. Software can never be "complete", in this sense.
Therefore, rather than aiming for completeness, it is better to target a small but critical subset of functionality for the first release – a minimal viable product (MVP). From that baseline the system’s capabilities can be incremented, and its design iterated, driven by user feedback.
This is consistent with Richard P Gabriel’s principle of worse-is-better. This design philosophy states that it is better to start with a minimal creation that solves the customer’s core problem, while leaving rough edges that will require refinement in the future.
The concept of worse-is-better originated in a famous talk by Gabriel at the 1989 European Conference on the Practical Applications of Lisp. In that talk, Gabriel described two contrasting software design philosophies:
- The MIT/Stanford approach (the "right thing"): Prioritizes correctness, consistency, completeness, and simplicity, with a strong emphasis on doing things properly, even if doing so adds complexity.
- The New Jersey style ("worse-is-better"): Emphasizes simplicity in implementation over simplicity of the interface, and tolerates some inconsistencies and incompleteness if that makes the system easier to build and maintain.
In the talk, Gabriel suggested that Unix and C were examples of the worse-is-better design philosophy. These were not superior technologies from a technical perspective, Gabriel argued, but nonetheless they were outperforming (and outliving) more elegant but more complex systems like Lisp ("the right thing").
This approach of starting simple and iterating based on feedback is best achieved through iterative and incremental development. This is a process model in which software is built through repeated cycles of development, with each cycle delivering small, working incremental changes. This process enables short feedback loops with real users, allowing requirements to emerge and the design to evolve – making the software easy to change in response to what is actually needed.
Stripped of its methodological trappings, the loop is very simple:
Find out where you are.
Take a small step towards your goal.
Adjust your understanding based on what you learned.
Repeat.– Dave Thomas
The idea of incremental development of software is analogous to Christopher Alexander’s piecemeal growth philosophy in urban design. Architecture that strives for functional completion and technical perfection from the start is often less adaptable, and therefore less effective at meeting the needs of its human occupants over the long term, than architecture that prioritizes simplicity and that tolerates some imperfection.
When to use iterative development? You should use iterative development only on projects that you want to succeed.
– Martin Fowler
Letting data decide
If completeness is defined by what users actually need, then determining what they need is an empirical question, and one that opinion answers badly.
Data-driven design is the practice of grounding design decisions in observed user behavior rather than in assumption. It means collecting and analyzing data on how the software is actually used, and feeding that back into decisions about what to build, change, or remove. A/B testing is the most familiar technique: comparing versions of a feature against predefined metrics, with users randomly assigned between them, to establish which performs better in practice rather than in argument.
This complements the case for iterative delivery made above. Shipping small increments creates the opportunity to learn from real usage; instrumenting them is what converts that opportunity into knowledge. Without measurement, an iterative process merely produces frequent releases of software whose value remains unverified.
Under- and over-engineering
If completeness is a moving target, the practical question becomes how much engineering a given increment deserves. There are two ways to get this wrong.
Under-engineering is a solution that fails to meet the requirements actually in front of it. It results from cutting corners, from rushing, or from not understanding the problem domain well enough. Under-engineered software lacks capabilities its users need, is prone to defects, or fails under loads it was always going to encounter.
Over-engineering is the opposite failure: adding complexity or capability beyond what the current requirements justify. It shows up as elaborate patterns applied to straightforward logic, abstractions pitched at a higher level than the problem calls for, optimization in advance of any evidence of a performance problem, or infrastructure built for a scale the product has not reached.
Two causes account for most over-engineering:
- Perfectionism. Aiming at an ideal architecture, or at a textbook-perfect application of a pattern, and pursuing it past the point of usefulness. The guard against this is to focus on solving real problems elegantly, rather than on the elegance of the solution as an object in itself.
- Future-proofing. Over-investing in the prediction and prevention of problems that have not yet occurred – writing a bespoke implementation rather than taking a dependency, or building for portability across platforms the system will never run on. This is premature optimization in a different guise. The guard against it is to solve today’s problems, and to trust that good design will keep tomorrow’s affordable.
We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.
– Donald Knuth
Over-engineering should not be confused with feature creep, though the two are often conflated. Feature creep is the accumulation of unnecessary functionality; over-engineering is unnecessary complexity in the implementation of whatever functionality exists. A system can be simple in what it does and baroque in how it does it, or vice versa.
Note also that deliberate, temporary under-engineering is a legitimate strategy under the worse-is-better philosophy described above – shipping something simpler than the eventual solution, then optimizing specifically where evidence shows it is needed. What distinguishes this from the failure mode is that it is a conscious choice, made with knowledge of what has been deferred.
How good is good enough?
Between these two failures, where should a team aim? "Quality" is easy to invoke and hard to pin down, and one person’s careful engineering is another’s over-engineering.
A useful answer comes from the DevOps Research and Assessment (DORA) program, which found that the highest-performing organizations score well simultaneously on throughput (how quickly changes reach production) and stability (how often those changes break, and how quickly the team recovers). Crucially, these were not found to trade off against one another. Teams that ship quickly are, as a rule, the same teams that ship safely – because the practices that make change safe are also the practices that make it fast.
This gives a working definition: quality is good enough when the software can be changed quickly and safely. Below that bar, defect rates and rework will slow delivery to a crawl. Above it, further investment in internal quality yields diminishing returns against the requirements actually in hand.
This definition is useful precisely because it is empirical rather than aesthetic. It does not ask whether the design is beautiful, or whether it follows a particular set of patterns. It asks whether the team can still move. It also reframes the familiar assumption that quality must be traded against speed: over any timescale that matters, quality is what makes speed possible.
Correctness
Correctness means that a system reliably performs its functional requirements. A system exhibits correctness when:
- The actual behaviors always match the expected behaviors, as specified in the system requirements. All operations complete reliably, without silent failures or undetected errors.
- All state – including state following error conditions – remains valid and consistent. Data is correctly processed and maintained throughout its entire lifecycle.
For a system to consistently behave correctly, it must be designed for valid state across all possible conditions – including those involving errors. All possible states – including failure modes – must be predicted and planned for in the system’s design. There should be no unhandled edge cases that could trip the system into an invalid state.
When errors occur, the system must recover to a valid state. This may mean rolling back a partial operation, returning an error to the caller, or entering a safe degraded state. But it must never leave data or the application in an inconsistent or invalid state.
Data integrity – the assurance that data is correct, complete, and unchanged except through authorized operations – is a critical aspect of correctness.
As a general rule-of-thumb, correctness should not be sacrificed for any other quality. For example, it will often be better to deliberately design a system to be slower for some operations, than to speed things up in a way that risks the system entering an invalid state. For example, it will typically be better to implement additional validation checks that slow down a write operation, but which ensure that the application state remains consistent and valid.
Of course, there are problem spaces where other qualities may be prioritized differently. In distributed systems, for example, consistency of replicated data (a form of correctness) is often intentionally sacrificed for higher availability and fault tolerance.
State management
Managing state is among the hardest problems in software design, and most correctness defects are ultimately defects of state. Two rules do most of the work.
Minimize mutable state. State that cannot change cannot become inconsistent. Reducing the amount of mutable state in a system removes whole categories of defect – synchronization bugs, conflicting writes, stale reads – rather than merely defending against them.
Maintain one source of truth. A single piece of state should not be stored in two places within the same service. However carefully the copies are kept in step, they will diverge, at times that will appear random.
Derived values are the usual temptation here. Suppose a client receives a bank balance from a server, and a "spendable balance" is derived from it. The correct default is to calculate the derived value on demand, rather than storing it alongside the original. Recalculating it whenever the source changes seems equivalent, but it is not: sooner or later one of those recalculations will fail to run, and the application will be left holding two numbers that disagree.
As ever, there are trade-offs. If the derivation is genuinely expensive, caching the result may be the right design – extending or invalidating the cache to balance performance against consistency. But this should be arrived at deliberately, as an optimization applied to a design that was correct to begin with. The default for any state management design is consistency; performance optimizations are iterated in from that baseline, not assumed at the start.
Defensive programming
A good programmer is someone who always looks both ways before crossing a one-way street.
– Doug Linder
Public interfaces to software components should be programmed defensively: raising an exception whenever a component receives input it cannot handle, rather than proceeding on the assumption that the input is well formed.
The purpose is to convert silent corruption into loud, immediate failure. An invalid input that is quietly accepted will produce an invalid state somewhere downstream, at a point far removed from the mistake that caused it. Failing at the boundary forces the defect to be fixed where it originated, and makes the source of a problem obvious when one does occur.
This connects correctness to reliability. A component that refuses invalid input is one that cannot be driven into an inconsistent state by its callers, which means failures stay contained rather than propagating through the system.
Performance
A software design is fit-for-purpose if it meets both its functional and non-functional requirements.
While correctness is the measure of whether a system meets its functional requirements, performance – in the broad sense used for this design quality – is the measure of how well a system meets its non-functional requirements.
Non-functional requirements define constraints within which a system is expected to operate in the real world. These are dynamic qualities of a system, observable at runtime, such as latency, throughput, resource utilization, availability, usability, responsiveness, and security and compliance. All these factors, assessed in aggregate, determine how well a system operates overall, in production environments and under real usage conditions – and ultimately, how users perceive and experience it.
For example, a functional requirement may be that a system allows users to search for a product by its stock keeping unit (SKU) code, and a non-functional requirement may be that it returns search results in less than one second while supporting up to 1,000 concurrent searches. A system that meets both requirements will be both correct and performant.
The specific quality attributes that matter will vary significantly from one software project to another. A high-frequency trading system has radically different latency requirements than a batch processing pipeline. A consumer-facing web application has different availability expectations than an internal administrative tool.
However, it is critical that acceptance criteria for all non-functional requirements are clearly specified up-front and incorporated into the system’s architecture from the start. The reason for this is that non-functional requirements tend to be difficult to retrofit later.
An iterative and incremental development approach is desirable for delivery of functionality, as it allows functional requirements to be discovered and refined in response to early feedback from users experiencing the incomplete software for themselves. But the specification of non-functional requirements benefits from a bigger degree of design work being done up-front.
The reason for that is that non-functional requirements are cross-cutting concerns that tend to permeate every layer of a system’s architecture. They are said to be "architecturally significant" requirements – meaning systems must be designed for them from the start. When non-functional requirements change, the implementation tends to involve deep changes throughout multiple layers of the software. By contrast, functional changes tend to be more isolated (if the architecture is good).
Consider, for example, security. A software system is said to be secure when it is protected against unauthorized access, data breaches, and other security threats. Security is not a feature that can be added to a completed system. It is an architectural concern that affects:
- Authentication and authorization mechanisms.
- Data encryption and transmission protocols.
- System isolation and privilege boundaries.
- Audit logging and monitoring.
- Network topology and access controls.
Regulatory compliance is another example. Well-known examples of regulations that affect the delivery of software services include the General Data Protection Regulation (GDPR) in the European Union and the UK, the Health Insurance Portability and Accountability Act (HIPAA) in the United States, and the Payment Card Industry Data Security Standard (PCI DSS) for organizations that handle payment card transactions. Each of these regulatory frameworks imposes specific requirements around data retention, deletion, privacy, encryption, and audit trails. These requirements often impact core architectural concerns, things like database schema, which trigger wider system refactorings when they’re changed. For example, GDPR’s "right to be forgotten" (the ability to delete all personally-identifiable information about a person) will need to be carefully considered in the design of a system’s underlying data model.
Thus, attempting to retrofit non-functional requirements late in the delivery of a new software system, almost as an afterthought, often results in expensive redesigns, incomplete implementation, or technical debt.
Correctness, security, compliance, and performance are not qualities you can achieve incrementally through iteration. They are top-down, architectural concerns. Specify them clearly at the beginning of your project, make them explicit in your acceptance criteria, and build them into your design from the start.
Reliability
Reliability is closely related to performance. However, while performance measures how well a system operates within specified constraints, reliability is more specific: it addresses a system’s ability to handle and recover gracefully when failures occur. This distinction makes reliability important in every software system, and worth calling out as a distinct concern.
Reliability covers multiple concerns in software design. It refers to the degree to which a software system consistently performs its intended functions correctly, without failures or errors, over a specified period of time and under specified conditions (such as load). Since systems operate under conditions of operational uncertainty, designing for reliability means planning for the unexpected.
Key to reliability is the ability of a system to recover from failures, and so to continue operating, at least with partial functionality, in exceptional circumstances. This quality is known as fault tolerance.
A system with high fault tolerance will have high availability. Availability is defined as the proportion of time that a software system is operational and accessible to users. It is often measured as a percentage of time, called uptime.
While reliability is often discussed in the context of software-as-a-service and other continuously-operated systems, where downtime directly impacts users, it is equally important for all software. Shrinkwrapped products, internal tools, batch processing systems, and embedded systems all benefit from being designed with fault tolerance and failure recovery in mind. The specific failure modes and acceptable downtime will vary, but the principle of designing for failure applies universally.
To achieve fault tolerance, systems must anticipate failure modes – things like runtime exceptions, network latency, and other disruptions – and gracefully handle them. All sorts of possible failure modes are explicitly designed to be handled. The system is said to be "designed for failure".
A system built to be fault tolerant is not one that never fails. Rather, the principle of designing for failure is to assume that failures will happen and for the design to have explicit recovery logic for each failure scenario.
The key principle of designing for failure is to plan for a failure that happens in one part of a system to have minimal impact on the rest of the system, so avoiding full outages. A common analogy is the electrical grid, where a failure in one component does not cause the whole grid to fail.
This is the opposite to the approach of trying to prevent failures and make complex systems run perfectly all the time. Such an approach produces inherently brittle systems. Rather than designing for perfection (the MIT/Stanford approach), we should design software for failure.
Designing for failure is especially important in distributed system design. Distributed systems are inherently unreliable because their internal communication patterns involve making network calls. Distributed systems are subject to a much wider range of failure modes than single-node systems, such as network partitions, delayed messages, and node crashes.
In distributed system design, numerous design patterns and operations strategies can help to build in resiliency, including:
- Redundancy: Running multiple instances of a service. Load balancers and circuit breakers distribute processing across multiple instances, and automatically failover to backup instances when primary instances fail.
- Replication and caching: Copies of data are stored in multiple locations, and closer to the services that need the data (so reducing the physical distance between nodes, which reduces latency).
- Retries and timeouts: Timeouts fail requests that take too long, and failed requests are retried in case of transient failures in the network or target service.
- Asynchronous messaging: Reduces direct dependencies between components, reducing coupling and making it easier to scale nodes individually.
Advanced strategies for achieving fault tolerance in mission-critical and safety-critical systems include stand-in systems (a failover system that is entirely independent of the primary system), blue-green deployments and canary releases (deployment strategies in which new versions of a system are tested in production before going live), and chaos engineering (intentionally causing failures in production to test the system’s ability to recover from them).
Monitoring and alerting systems create feedback loops that are critical to the maintenance and operation of software-as-a-service.
Designing for failure needs to be considered at every level of a system’s architecture. At the low-level of code modules, consideration must be given to programming defensively (raising an exception whenever any expectation of any component is not met) and exception handling (to enable graceful degradation from failure modes).
The user experience needs to be considered, too. A reliable system is also one that behaves predictably from the user’s perspective. If an operation is performed correctly, but it is not what the user expected, the system will be perceived as being unreliable, which will impact people’s confidence in it.
Security is also integral to reliability. A system that is compromised or under attack is, from the user’s perspective, unreliable. Security threats – whether unauthorized access, data breaches, or denial-of-service attacks – are failure modes that must be explicitly designed for.
Seeking out failure
Designing for failure requires knowing what the failure modes are, and they are not always obvious. This is what distinguishes software engineering from programming: a deliberate share of the effort goes into anticipating failure rather than into producing behavior.
The term "software engineering" acquired this meaning during the Apollo program, when major systems first had to be built to standards where failure would cost lives. That class of high-integrity system makes the discipline visible, but the underlying practice generalizes. Serious engineering organizations assume failure rather than hoping to avoid it; the Toyota Production System is built on the same assumption, and stops the line when reality contradicts the plan.
In practice this means treating failure discovery as an activity in its own right. Post-mortems examine failures that have already happened. A pre-mortem inverts this: the team assumes a future failure has occurred and works backwards to explain it, surfacing weaknesses while there is still time to address them. Deliberately running doomsday scenarios against a design – asking what happens when this dependency is unavailable, when this queue backs up, when this credential expires – is one of the cheapest ways to find the failure modes that the design has not yet accounted for.
Experience
User experience (UX) is a fundamental aspect of software design that deserves careful consideration. It encompasses how intuitive the system is, how reliably it performs, and how well it anticipates and accommodates the user’s needs.
Poor user experience frustrates users, erodes their confidence in the system’s reliability, and undermines the value that the software delivers – even if the software is technically reliable. Good user experience, by contrast, creates an environment where users can accomplish their goals efficiently, with minimal cognitive burden.
Among all the factors that contribute to user experience, one quality stands out as particularly critical: responsiveness.
Responsiveness is not about how fast the software actually operates in objective terms. Rather, it’s about how fast the software feels to the user. This perception is shaped by the feedback the system provides to the human. Feedback should arrive quickly and it should clearly communicate what is happening.
When feedback is delayed or absent, users perceive the software as slow and unresponsive, even if the actual computation is happening quickly in the background. Conversely, well-designed feedback can make operations that take considerable time feel fast and responsive because the user is constantly informed of progress.
Fast software changes the behavior of its users. It reduces the cognitive friction involved in using the software, allowing users to stay focused on their tasks, and so to be more productive. Conversely, slow software limits us, perhaps more than most people realize. Think, for example, when was the last time you got any meaningful work done on airplane Wi-Fi?
In order to make things fast, you often have to do complex things behind the scenes. Instagram, for example, optimistically uploads users' photos while they are still typing the captions for them, so the subsequent user-initiated upload operation feels instantaneous.
Fast, reliable software also has a competitive advantage. Over time, the perceived speed of Instagram has no doubt played a big role in its success, relative to the many other photo sharing apps that competed in the early days.
Fast software subliminally signals quality and reliability to users.
This signaling of reliability through responsiveness is critical. A system might be technically reliable – with excellent fault tolerance, recovery mechanisms, and high availability – but if its user experience is poor, if it fails to respond gracefully to user actions and changes, users will lose confidence in it. They will perceive it as unreliable and may abandon it for a competitor. Conversely, excellent responsiveness and user experience can build user confidence even in systems that occasionally experience failures, because users recognize that the system is actively responding to their needs.
User experience is fundamentally about attention to detail – and this attention must extend far beyond the visual interface. Every architectural layer of the system impacts the user’s experience: database query efficiency, caching strategies, API design, network communication patterns, and deployment infrastructure all play a role.
To achieve truly fast and responsive software requires conscious design decisions throughout the entire system, not just at the presentation layer. The most beautifully designed interface becomes frustrating if the backend is sluggish. Conversely, a fast backend can be undermined by inefficient frontend code.
Excellence in user experience demands that responsiveness and performance be treated as first-class architectural concerns, not as afterthoughts to be addressed once the functionality is complete.
Developers are users too
The interfaces a system exposes to people are not only graphical. Application programming interfaces – both those a system exposes externally and those its components expose to one another internally – are consumed by developers, and the same standards of experience apply.
An API should be simple, natural, and intuitive enough that a competent developer can work out how to use it from the interface alone. That means descriptive naming throughout, with verb prefixes on methods that perform actions, and names chosen to match how people actually speak about the domain. Where a fluent style suits the language, method names can be chosen so that chains of calls read as grammatical phrases in consuming code.
The test is whether the interface can be used correctly without recourse to documentation. Documentation should reward the reader with depth, not be the price of entry.
As with graphical interfaces, this is a matter of attention to detail, and of prioritizing the happiness of the person on the other side of the interface over strict conformance to any particular pattern or convention. An API that follows every best practice but is unpleasant to use has failed at the only thing that ultimately matters.
Habitability
So far, this technical standard has focused on external qualities: observable characteristics of software that directly impact users and constrain how a system operates. These include completeness, correctness, performance, reliability, and experience. These are qualities that users experience at runtime, either directly or indirectly.
We now turn our attention to internal qualities. These are characteristics of a system’s design that affect how easily it can be operated and modified. These are qualities that users may not directly observe, but they profoundly impact the organization’s ability to deliver value over time.
Of these internal qualities, habitability is the most fundamental.
Today’s computer programs have multiple consumers of their source code: compilers and interpreters, static analysis tools and AI systems, and of course developers and testers. Of these, humans have the most limited working memory and cognitive function – it’s true! We must therefore optimize our programs for human understanding.
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.
– Martin Fowler
Code is an environment in which people work. Habitability refers to how pleasant and welcoming that work environment is. A habitable codebase invites contribution, makes developers feel capable, and reduces development friction.
Software engineering is what happens to programming when you add time and other programmers.
– Russ Cox
Habitability is the characteristic of source code that enables programmers, coders, bug-fixers, and people coming to the code later in its life to understand its construction and intentions, and to change it comfortably and confidently.
– Richard P Gabriel
Patterns of Software (1996)
A habitable codebase exhibits certain unmistakable qualities. Its design is cohesive, its concepts applied consistently. It looks as though it were written by one person, working methodically. It feels familiar. Its abstractions are appropriately sized and well named. Every part is self-contained. Behavior is predictable.
Good documentation really helps, too. Documentation transfers knowledge across generations of code owners – valuable knowledge that would otherwise be lost with time. Documentation acts as a dialog between the original authors and future maintainers. For example, by leaving comments in code to explain tricky logic or to record the rationale for an unusual design choice, you’re signaling to future maintainers that you’ve thought carefully about the problem.
Clarity over brevity
Where the two conflict, prefer clarity to brevity. It is not a design goal to express a program in the fewest possible characters. It is a design goal for code to be as self-explanatory as it can be, without the reader having to consult adjacent annotation or separate documentation to understand what is in front of them.
This has consequences for naming. Use precise, specialized terminology in code and in supporting artifacts, in preference to more common words that could be misunderstood. Avoid abbreviations and acronyms. Respect established precedent in the domain and in the surrounding ecosystem, rather than optimizing terminology for newcomers at the expense of the people who will work in the codebase daily. Include every word needed to remove ambiguity – and omit every word that carries no information.
Brevity is not the enemy, and it need not be bought at the cost of clarity. Expressive type systems and well-chosen language features reduce boilerplate without obscuring intent. What should be resisted is brevity that hides meaning.
A related caution applies to "magic" – behavior that happens implicitly, through convention, reflection, or framework machinery that the reader cannot see at the point of use. Magic makes code shorter at the cost of making it unpredictable to anyone who has not memorized the framework’s rules. It also erodes the confidence of the people working in the codebase: developers who understand how their tools work feel capable, while developers surrounded by machinery they cannot inspect feel alienated from it. Err on the side of the explicit.
Data structures first
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: 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.
The most habitable codebases are truly joyful places. Far from being sterile, clinical environments, they show signs of personality and imagination. You find it in the small details – sly humor in a comment, a playful variable name, or a quirky description for an edgy test.
Habitability is crucial to software quality. Developers spend most of their working hours reading code, normally other people’s. A habitable codebase makes that work pleasant and productive. Happy, productive developers produce higher-quality work.
A habitable codebase has been designed with respect for the time and cognitive effort that other people will one day invest in it. That time and effort will be essential for the long-term success of the project.
Just like maintaining a home, a habitable codebase requires regular housekeeping and tending. As systems are modified and extended, entropy inevitably sets in, and investment in refactoring and maintenance is required to preserve the habitability of the space.
The analogy is worth taking further, because it captures something that most architectural metaphors miss. Building software is like building a house that you will never stop extending. There is no point at which the structure is finished and the occupants simply move in. A new wing is added, a wall comes down, the roof is replaced, and all of it happens while people are living there.
Several consequences follow from this, and each maps onto something already discussed in this standard.
- You cannot design the finished building up-front, because there isn’t one. The best you can do is establish a structure that accommodates extension – which is why architecture should be lightweight and why changeability, not completeness, is the ultimate objective.
- Where you put the load-bearing walls matters enormously. Interior decoration can be redone at will; foundations cannot. This is the gradient of cost described under Changeability, in physical form.
- The extensions must be sympathetic to what is already there. A house extended repeatedly without regard for its existing form becomes an incoherent warren of additions – rooms that can only be reached through other rooms, corridors that lead nowhere, three different window styles on one elevation. This is precisely Brooks’s cathedral, and precisely the loss of cohesiveness described in the next section.
- People have to live in it throughout. Work that renders the building uninhabitable for months is rarely acceptable, however much better the end state. The occupants – the developers and operators – need the place to remain livable while it changes, which is what makes incremental refactoring preferable to demolition.
- Neglect compounds. An unmaintained house does not stay as it is; damp spreads, and the eventual repair costs far more than the maintenance would have. Deferred refactoring behaves the same way.
One man’s crappy software is another man’s full time job.
– Jessica Gaston
The metaphor also sets a limit on itself. Software is very much cheaper to modify than masonry – that malleability is its defining advantage, as discussed under Changeability. The analogy holds for how a system accumulates structure and how that structure constrains what comes next. It should not be taken to imply that change is as expensive for us as it is for builders. Our advantage lies precisely in the fact that it is not.
Habitable codebases are created and maintained through deliberate, incremental effort.
Rules of thumb are not rules
Habitability is a property of the whole, and it cannot be reduced to a checklist. Most design guidance 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 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.
Software developers are drawn to ideas, and therefore prone to designing code to conform to principles rather than designing what demonstrably works. Argue from evidence about the code in front of you, not only from first principles.
Cohesiveness
Cohesiveness – used here as a synonym for what Fred Brooks called conceptual integrity – is the degree to which a software system’s design forms a unified, consistent whole.
A cohesive system is one where all its concepts, and their relationships with each other, are applied in a consistent way throughout. A consistent design philosophy flows through all subsystems. Anywhere you look in the codebase, it demonstrates the same balance of competing forces.
Brooks believed conceptual integrity to be the single most important consideration in the design of software systems.
I will contend that conceptual integrity is the most important consideration in system design. It is better to have a system omit certain anomalous features and improvements, but to reflect one set of ideas, than to have one that contains many good but independent and uncoordinated ideas.
– Fred Brooks
The Mythical Man-Month (1975)
Few modern architects would dispute that cohesiveness is among the most important principles of software design, though we may now rank other qualities – such as changeability or simplicity – as equal or even higher priorities. Regardless of where exactly cohesiveness falls in the hierarchy, its importance is beyond question.
Cohesiveness is achieved, in general, by constraining a system to be built from a small, deliberate pool of languages, infrastructure, architectural and design patterns, and shared libraries. When the system’s vocabulary is limited and consistently applied, developers can build a reliable mental model of the architecture. They can navigate unfamiliar parts of the codebase with confidence, predict the structure of modules they haven’t yet seen, and understand intuitively the full repercussions of making a change. This predictability is a direct enabler of changeability – which we discuss in the next section.
Boring technology
The instruction to build from a small, deliberate pool of technologies has a corollary: prefer technologies that are mature, stable, and well understood.
Boring is not the same as bad. A boring technology is one whose capabilities are well understood – and, more importantly, whose failure modes are well understood. Every technology carries both known unknowns ("we don’t know what happens when this database hits 100% CPU") and unknown unknowns ("it didn’t occur to us that writing stats would cause garbage-collection pauses"). Neither set is ever empty, even for software that has existed for decades. But for new and fashionable technology, the second set is very much larger, and its contents are discovered in production.
Adding a technology to an organization carries a cost that is easy to overlook because it is paid later and by other people. If a system is already built in one language, introducing a second brings complexity that must be weighed against whatever marginal advantage the new language offers for the problem at hand. Polyglot programming is sold on the promise that letting each team choose its own tools locally will make them more effective. The result, more often, is a system nobody can navigate as a whole – exactly the conceptual disunity this section warns against.
The right question is not which tool is best for a given problem in isolation, but which tool occupies the least-worst position across as many of the organization’s problems as possible. Choosing technology this way is what makes it possible to think about larger questions; technology adopted for its own sake is a distraction.
This argues for a default position: choose proven, enduring technologies over innovative but embryonic ones, and reuse existing solutions in preference to building new ones. The bar for adopting something new should be that it solves a specific, long-standing problem that existing tools do not. That situation is rarer than it appears.
Domain modeling
Domain modeling is an extremely useful tool for achieving cohesiveness, too. A well-designed domain model – a clear conceptual framework reflecting the core concepts and relationships in the problem space – provides a shared vocabulary that unifies a system’s design. When the domain model is rigorous and well understood, it acts as a blueprint that guides the naming, structure, and boundaries of every component. The same concepts appear in user-facing interfaces, internal APIs, data models, and documentation.
This alignment between the problem space and the solution space is where true cohesiveness emerges. Conversely, when a system lacks a coherent domain model, or when different parts of the system model the same domain concepts in incompatible ways, conceptual disunity sets in and the design fractures.
A cohesive system tends to be faster to build and test, easier to maintain, and less susceptible to bugs and other types of defect. These are not incidental benefits – they follow directly from the fact that a unified design reduces the number of surprises a developer encounters. When patterns are applied consistently, the cost of learning the system is paid once, and that knowledge transfers across subsystems.
Cohesiveness reinforces habitability. A codebase that looks as though it were written by one person, working methodically, is one that exhibits high cohesiveness. It feels familiar. Its abstractions are consistently sized and consistently named. Behavior is predictable. This is the kind of environment in which developers do their best work.
The difficulty lies in achieving and maintaining cohesiveness over time. The dominant theme throughout Brooks’s The Mythical Man-Month is the question of how best to manage complexity in large-scale software systems. Maintaining conceptual integrity, Brooks argues, is the best way of containing that complexity.
Brooks draws a parallel with European cathedrals, most of which "show differences in plan or architectural style between parts built in different generations by different builders". In software, such conceptual disunity arises not from the passage of centuries but from the decomposition of the design into "many tasks done by many men". The implementation is broken up into small pieces, each designed and constructed independently by different programmers, who each make different choices and trade-offs.
Brooks’s solution is to have one system architect who designs the whole system from top to bottom, so that the "design … proceed[s] from one mind, or from a very small number of agreeing resonant minds". In Brooks’s model, design and implementation are distinct phases: the architect defines the interface to the system, then the builders define the internal implementation. "The separation of architectural effort from implementation is a very powerful way of getting conceptual integrity on very large projects."
In modern practice, we achieve something analogous through shared architectural standards, code review, automated linting and formatting, and – critically – a shared domain model that serves as the conceptual backbone of the system. These are lighter-weight mechanisms than Brooks’s chief-architect model, but they serve the same purpose: ensuring that a unified vision permeates the design.
Cohesiveness is especially difficult to achieve in distributed computing – such as microservice architectures – where several teams may be responsible for different parts of the system. In this case it is necessary for all teams and architects to share a vision for the design, and for that vision to be made explicit and continuously reinforced. Without deliberate effort, each team will drift toward its own conventions, and the system as a whole will lose its conceptual unity.
Real-world examples of systems with high cohesiveness are instructive:
- Unix: The entire design is based on the notion of the "file." Directories, devices, and sockets are all types of files. This single unifying abstraction gives the system extraordinary consistency.
- Smalltalk: Everything is an object. Messages are the universal mechanism for interaction.
- SQL: All data is represented in tables with keys and constraints. A single relational model governs all operations.
- Lisp: Everything is a list. Code and data share the same structure.
In each of these systems, a single organizing principle pervades the entire design. The result is a system that is easier to learn, easier to reason about, and easier to extend – because any new capability must conform to the same conceptual framework.
Cohesiveness has a natural tendency to deteriorate over time, as people come and go, the software is modified and extended to meet changing business requirements, and the original vision is forgotten. Entropy is the default. Preserving cohesiveness requires ongoing investment – in refactoring, in documentation, in architectural governance, and in the cultivation of a shared understanding of the domain model among all contributors.
This ongoing effort is worthwhile. A system that maintains its cohesiveness over time is one that remains comprehensible, maintainable, and ultimately changeable – qualities that, as this standard argues throughout, are essential for long-term success.
Changeability
The ultimate objective in making any software system is for that software to be changeable.
Changeability is the ability to move quickly, change direction easily, and respond to new information – about users, markets, and technology – without high costs or disruption.
Changeability matters because software is built in uncertainty.
To start, the requirements for a software system are rarely fully known at the start of the software development project. No matter how much effort you put into discovering requirements from the target user groups, people have an annoying habit of "changing their mind" about what they want after the software has been developed. This is natural. People have no native ability to specify their requirements for something as intangible as software. We have never evolved such cognitive capabilities.
So any plan made at the start of a software project is, at best, an educated guess about what will be needed when the software is eventually delivered. Requirements written on paper are merely hypotheses. Only real users testing real, working software reveal what’s actually needed. The longer it takes to get software to users, the more time and money spent before testing your assumptions.
Changing requirements can come from other sources, too. Markets shift, competitors move, technology evolves, regulations proliferate. Many categories of software are required to be continuously adapted throughout their lifetimes. Businesses want to be able to respond to all kinds of threats and opportunities, and increasingly that involves changing the behavior of some software that automates parts of their processes.
So, even if we could succeed in specifying up-front all requirements for a complete software system, those requirements would soon enough change anyway.
Unfortunately, changeability conflicts with how many organizations work. Budgeting and planning processes push for big up-front plans based on forecasts, with little room for learning or adjustment.
Traditional delivery approaches optimize for predictability. They treat requirements as fixed, the plan as truth, and change as a problem to avoid.
Walking on water and developing software from a specification are easy if both are frozen.
– Edward V Berard
[…] predictability depends on a precise, accurate, and [a] stable set of requirements. If you cannot stabilize your requirements, the predictive plan is based on sand and the chances are high that the project goes off course.
– Martin Fowler
UML Distilled (Third Edition)
This works well in engineering disciplines where the cost of change is high and requirements genuinely can be determined up-front: constructing a bridge, manufacturing a component, launching a satellite.
Software is different. The cost of changing software is relatively low compared to changing physical things. The clue is in the name: software is soft, malleable. That malleability is one of software’s defining characteristics. Treating change as a threat wastes software’s core advantage over hardware: its malleability.
This malleability is best leveraged through a design philosophy that embraces iteration and change from the start. The "New Jersey style" or worse-is-better approach – introduced earlier in this standard in the context of Completeness, and revisited under Reliability – tolerates incompleteness and rough edges in favor of simplicity and rapid iteration. By starting simple and building incrementally, rather than trying to perfect everything upfront, this philosophy is inherently more amenable to changeability than approaches that attempt to get everything "right" the first time.
All software is a perpetual work-in-progress, especially modern services that run on servers. The team that builds the service usually operates it in production, too. They see real users, real data, and real feedback. They learn continuously and evolve the system in response.
In this context, changeability is vital. Businesses that respond quickly to customer needs – fixing bugs, resolving incidents, shipping features – gain a competitive edge over those that can’t. Furthermore, as discussed in the Reliability section, designing for failure requires the ability to quickly change software to address and fix issues when they occur. Changeability is therefore critical both for innovation and for operational resilience.
Making changeability the primary objective changes how we work. It means embracing change, testing assumptions early, and course-correcting continuously. Success is measured by value delivered to users, not by story points or schedules.
This doesn’t eliminate planning or scope. It means planning at the right level of detail for the right timeframe, and treating the ability to revise plans as a feature, not a failure.
Changeability is the ultimate objective in software delivery. It means shipping software that works, that solves real problems, and that can be changed quickly when those problems turn out to be different from what we expected.
Good architecture is the key to achieving changeability. A good architecture is one that can accommodate, with minimal friction, new requirements in the future – perhaps requirements we could never have foreseen, and which we did not accommodate in the initial design.
A good design is easier to change than a bad design.
– Dave Thomas
[…] if you’re afraid to change something it is clearly poorly designed.
– Martin Fowler
More fundamentally, the objective is for code to be cheap to change – in terms of time, effort, and risk. When change is cheap, organizations can respond quickly to new information and opportunities. When change is expensive, organizations are trapped by their past decisions, unable to adapt, and increasingly unable to compete.
You can’t go fast when everyone is spending their time fighting with the poor decisions of yesterday.
– Adam Chester
The opposite of technical debt isn’t zero debt, it’s compounding gains: each new thing makes it easier and faster to build new things.
– Matt Wensing
Architecture provides a framework into which a design can be applied. The design emerges. Architecture should, therefore, be lightweight. We should start by building the simplest solution that could possibly work, but keep it flexible enough to accommodate future changes.
Not all decisions cost the same
Design decisions are not equally expensive to get wrong, and the cost rises sharply with the level at which the decision is made.
- Low-level details – code formatting, local naming, the layout of an individual function – contribute little to the long-term cost of change. They matter for habitability, and they should be handled consistently, but they are cheap to correct. A formatter fixes them in bulk.
- Mid-level details – the design of data structures, the choice and application of patterns, the boundaries drawn between modules – carry substantially more weight. Errors here accumulate as friction on every subsequent change, and correcting them requires deliberate refactoring across multiple parts of the codebase.
- High-level structure – the architecture itself, the decomposition into services, the fundamental data model – is the most expensive to get wrong. Architectural mistakes tend to be load-bearing by the time they are recognized, because everything built since has assumed them.
The practical consequence is that design attention should be allocated in proportion to this gradient. Time spent arguing about formatting is time not spent on the decisions that will actually determine whether the system remains changeable. Automate the low-level concerns so that judgment can be reserved for the mid- and high-level ones.
This is also the strongest argument for keeping architecture lightweight, as described above. Since architectural decisions are the most expensive to reverse, the sound strategy is to make as few of them as possible, as late as responsibly possible, and to prefer those that leave subsequent options open. Deferring a decision is often cheaper than making it well too early.
Evolve, don’t rewrite
A complex system that works is invariably found to have evolved from a simple system that worked. The inverse proposition also appears to be true: a complex system designed from scratch never works and cannot be made to work. You have to start over, beginning with a simple system.
– John Gall
The Systems Bible
If changeability is the objective, it follows that we should meet new requirements by incrementally refactoring the software we already have, rather than by rewriting it from scratch. Rewrites should be reserved for cases where there is no incremental path – typically when an underlying language, framework, or platform is on a deprecation path.
This is where the gradient described above has teeth. A serious architectural mistake is the one category of design failure that can genuinely exhaust the incremental options – where the structure is wrong in a way that no sequence of local refactorings will correct, and the choice really is between replacement and permanent friction. That such situations exist is precisely why architectural decisions warrant the care they do. But they are rarer than the urge to rewrite would suggest, and the burden of proof belongs on the party proposing demolition.
The reasoning is economic. The cost of a rewrite is routinely underestimated, and the value embedded in an existing codebase routinely undervalued. Working software encodes years of accumulated knowledge: every bug fixed, every edge case handled, every hard-won lesson about how the domain actually behaves. Much of that knowledge exists nowhere else – not in the specifications, not in the documentation, and not in anyone’s memory.
Joel Spolsky identified why the urge to rewrite is so persistent, and why it is usually mistaken:
Programmers always want to throw away the code and start over – as they think the old code is a mess. And here is the interesting observation: they are probably wrong. The reason that they think the old code is a mess is because of a fundamental law of programming: it’s harder to read code than to write it.
– Joel Spolsky (2000)
Code that appears to be a mess is often code whose reasons have simply been forgotten. Some of it is genuinely poor, and should be refactored. But the asymmetry between reading and writing means our judgment here is systematically biased toward demolition.
This favors a particular disposition toward the systems we maintain. The better model is the gardener, tending and reshaping something living and already rooted, rather than the architect clearing ground for a new development. Growth is continuous, incremental, and responsive to conditions – which is precisely what changeability demands.
Note that this is an argument about how to change software, not an argument for changing it reluctantly. Evolutionary design requires that every part of the stack remain evolvable – schemas, interfaces, deployment topology, and the code itself. A system that can only be changed by being replaced is not evolvable at all.
Changeability covers:
- Scalability: The ability of a software system to handle increasing workloads by adding resources such as processors, memory, and storage.
- Extensibility: The ability of a software system to easily accommodate changes or additions to its functionality, without requiring major refactorings.
- Maintainability: The ease with which a software system can be updated, repaired, or enhanced over its lifetime. It is affected by factors such as code readability, modularity, and documentation.
- Manageability: Closely related to maintainability, this is the ease with which a software system can be controlled and operated by system administrators.
- Stability: The ability of the architecture to remain consistent and predictable over time, even as the requirements of the system change. So, a stable architecture is one that can accommodate new features (extensibility) without requiring major refactoring of the underlying logic structure and data model.
- Evolvability: The ability of the architecture to evolve over time. Evolvability is not necessarily contradictory to stability. A good design will demonstrate both characteristics. It will support the addition of new features without requiring major refactorings, and it will also evolve over time to accommodate changing non-functional requirements such as greater scalability or performance under heavier load.
Just because the architecture is supposed to be stable, it does not mean it should never change.
– Gereon Hermkes
- Visibility: Closely related to the quality of observability, visibility refers to the ease with which a software system can be monitored and diagnosed at runtime in production environments.
Simplicity
How do we design software that is inherently changeable, easy to optimize for performance and fault-tolerance, and that provides great user experience?
The key is simplicity.
How well a software solution is made – its internal structure, its data flows, its architectural patterns – directly determines how easily it can be changed, debugged, tested, and deployed. A simple design can be modified with confidence; a complex design resists change. Similarly, designing for simplicity often leads naturally to better performance, higher reliability, and improved user experience. Simplicity in design is therefore the foundation upon which all other software qualities rest.
The reason simplicity matters so much is that our capacity to understand what we have built is the binding constraint on what we can build.
The greatest limitation in writing software is our ability to understand the systems we are creating.
– John Ousterhout
A Philosophy of Software Design (2018)
As a system acquires features, dependencies accumulate between its parts, and it becomes progressively harder for anyone to hold the relevant factors in mind while making a change. This slows development and produces defects, which slow development further. The limit is not the machine’s capacity but the practitioner’s.
Good tools help. Static analysis, type systems, debuggers, observability platforms, and increasingly AI-assisted development all extend how much complexity a person can work with productively. But there is a ceiling on what tooling alone can achieve, because tools help us cope with complexity rather than remove it. A simpler design raises the ceiling itself: it allows larger and more capable systems to be built before understanding becomes the bottleneck. This is why design effort cannot be substituted with better instruments, and why simplicity repays investment in a way that tooling alone does not.
But what do we mean by "simplicity"?
Simplicity in software is not a perfectly measurable property. Unlike correctness, a software system can be more or less simple, and assessing it requires careful, qualitative judgment rather than metrics alone. Yet despite this subjectivity, simplicity remains essential. A system with a simple design is one where the relationships between its components are clear, where the purposes of different modules are distinct, and where unnecessary complexity has been deliberately eliminated.
It seems that perfection is reached not when there is nothing left to add, but rather when there is nothing left to take away.
– Antoine de Saint Exupéry
Simplicity is the ultimate sophistication.
– Leonardo da Vinci
Simplicity is the soul of efficiency.
– Austin Freeman
The word "sophistication" is worth dwelling on, because it runs against the instinct that sophisticated work should look sophisticated. A simple design is usually the more expensive of the two to arrive at: it requires understanding the problem well enough to know what can safely be left out. Complexity is often what a design looks like before that understanding has been reached.
There are two distinct but interconnected dimensions to simplicity. Interface simplicity is about managing the essential complexity of the problem domain – exposing only the minimum set of concepts and behaviors users must understand. Implementation simplicity is about minimizing accidental complexity – avoiding unnecessary structural choices in how we solve the problem. A well-designed system exposes only essential complexity to the user while hiding accidental complexity in the implementation.
This distinction comes from Fred Brooks’s 1986 paper No Silver Bullet. Essential complexity is inherent to the problem domain and cannot be designed away: a payroll system is complicated because payroll is complicated. Accidental complexity is complexity we introduce ourselves, through the way we choose to implement a solution. The goal of software design is to minimize accidental complexity while managing essential complexity as clearly as possible.
An important consequence follows from this. The more complex the problem space, the more important it becomes to keep the solution space simple. A system modeling a genuinely intricate domain already carries a heavy load of essential complexity, and has little headroom for accidental complexity on top. A small, simple problem domain, by contrast, can absorb a certain amount of clumsiness in its solution and still remain tractable. Complex problems require simple solutions; simple problems merely tolerate complex ones.
Since essential complexity tends to grow over the lifetime of a system, as the domain it models grows richer, the budget for accidental complexity shrinks correspondingly. Continuous refactoring is therefore not optional housekeeping but a structural necessity.
An evolving system increases its complexity unless work is done to reduce it.
– Meir Lehman
Interface simplicity requires discipline. It means resisting the temptation to add features users might someday want, and instead focusing on what they genuinely need right now. Companies that deliver software perceived as fast and responsive tend to have very focused product offerings – not by accident, but because the effort required to make software fast forces difficult decisions about what to include and what to leave out. Linear, for example, delivers a better user experience than heavier enterprise applications precisely because it has stripped away non-essential features. This sends a signal to users that you have thought carefully about what matters to them.
Perhaps equally important is consistency in interface design and behavior. Consistency allows users to intuitively predict how the system will behave based on their previous experience with it. This predictability is a key aspect of usability and accessibility. Interface simplicity is also closely related to responsiveness – a fast, responsive interface provides immediate, consistent feedback for user actions, building confidence and making the software feel more capable.
Coupling
Implementation simplicity turns on a single critical factor: coupling – how tightly the different parts of the system depend on each other. High coupling makes systems brittle and difficult to change; low coupling makes them flexible and adaptable. This is not about the volume of code. A large codebase can be simple if its components are loosely coupled with clear purposes; a small codebase can be impenetrably complex if its parts are tightly coupled.
Coupling is not itself one of the design qualities. It is the primary means by which we measure and reason about one of them. Simplicity, as noted above, resists direct measurement: it is a qualitative judgment. Coupling is far more tractable. Dependencies can be counted, their direction traced, and cycles among them detected – often mechanically. Coupling is therefore the most useful instrument we have for turning an assessment of simplicity from an opinion into an observation.
Dimensions of coupling
Coupling is not a single number but a family of related properties. The ones that matter most in practice are:
- Efferent coupling: The number of other components a given component depends upon. High efferent coupling means a component cannot be understood, tested, or moved without dragging much of the system along with it.
- Afferent coupling: The number of other components that depend upon a given component. High afferent coupling is not inherently bad – shared foundations are supposed to be depended upon – but it does mean that changing the component is expensive, so its interface should be small and stable.
- Directionality: Whether dependencies flow consistently in one direction. Cycles between components are especially damaging, because they eliminate any possibility of understanding, testing, or deploying either component alone.
- Temporal coupling: Whether components must execute in a particular order, or within a particular window, for the system to behave correctly. This form is easily overlooked, because it is invisible in the dependency graph.
- Data coupling: Whether components share a mutable data structure, schema, or representation. Shared state is coupling even when there is no direct call between the parties, and a shared database schema is one of the most common sources of hidden coupling in practice.
- Control coupling: Whether one component directs the internal behavior of another – typically via flag arguments or mode parameters. This makes the callee harder to reason about in isolation, because its behavior is no longer a property of the callee alone.
Interpreting the measurements
The principle is to err on the side of loose coupling, while being deliberate about which things to couple tightly. Couple together things that are intended to change together. If two components almost always change in tandem, making them dependent on each other acknowledges this reality. But if they are independent concerns that merely happen to coexist, keep them decoupled so either can be changed without affecting the other. A system with a simple implementation is easier to maintain and extend, and therefore more likely to satisfy users' needs over time as functionality is iterated in response to feedback.
This is why coupling must be interpreted rather than merely minimized. A measurement of coupling is a question, not a verdict. A tight coupling between two components that genuinely change together is good design; the same measurement between two unrelated concerns is a defect. The number does not distinguish between these cases – only knowledge of the domain does.
A useful diagnostic question, therefore, is not "how much coupling is there?" but "does the coupling that exists follow the lines along which this system actually changes?" Where the two align, the design is simple in the sense that matters. Where they diverge – where a routine change requires edits scattered across components that have no conceptual relationship to one another – the design is complex, whatever the raw dependency counts happen to say.
The goal, then, is well-managed coupling rather than minimal coupling. This distinction matters because the pursuit of low coupling as an end in itself produces its own kind of complexity. A system decomposed into too many components, each defensively isolated from the others, forces developers to trace a single behavior across many files and indirections. Excessive modularity is a form of over-engineering, and it degrades simplicity just as surely as a tangled monolith does. Be pragmatic about where the boundaries between components are drawn.
The same caution applies to abstraction. Abstractions are the primary tool for hiding accidental complexity, and used well they leave behind code that expresses only the essential complexity of the business domain. But every abstraction is itself a thing to be learned, and a layer to be traced through when diagnosing a problem.
This test is easy to state and easy to fail. Elaborate patterns applied to simple logic, or layers of indirection introduced in anticipation of requirements that never arrive, add complexity without hiding any. Dependency injection is a common example: valuable where object construction is genuinely complicated or genuinely needs to vary, but frequently applied with a granularity that adds ceremony without separating anything worth separating. Design patterns should be adequate to the problem – no more.
Coupling and the other qualities
Although coupling is most closely associated with simplicity, it is a cross-cutting diagnostic, and several of the other design qualities can be read through it:
- Changeability: The blast radius of a change is a direct consequence of coupling. Cheap change requires that the parts affected by a given requirement be few, and be known in advance.
- Reliability: Coupling determines how far a failure propagates. The electrical grid analogy used earlier in this standard is fundamentally a statement about coupling – a fault in one component does not bring down the whole because the components are not tightly bound to one another. Asynchronous messaging improves fault tolerance precisely because it reduces coupling.
- Cohesiveness: Coupling and cohesion are two views of the same decomposition. A well-chosen module boundary produces high cohesion within the module and low coupling across it. When a decomposition is wrong, both measurements degrade together.
- Habitability: A component that can be read and understood without holding much of the rest of the system in working memory is a component that is loosely coupled. Coupling is, in this sense, a proxy for the cognitive load the codebase imposes on the people working in it.
Because coupling cuts across the qualities in this way, it is worth measuring and monitoring continuously, and worth treating a rising trend as an early signal of design decay – long before the effects become visible as missed deadlines or production incidents.
Aligning interface and implementation
A coherent design requires consistency between interface and implementation. This is where domain modeling becomes critical. A well-designed domain model – a clear conceptual framework reflecting the core concepts and relationships in the problem space – can serve as a bridge between interface design and implementation. When the domain model is well-designed, the interfaces the system exposes naturally align with the internal structure. Users encounter concepts that make sense from their perspective, and those same concepts are reflected faithfully in the implementation. This alignment reduces cognitive load on both sides.
When interface and implementation are misaligned – when the system exposes one set of concepts but is internally organized around a different model – the design becomes unnecessarily complex. Bridges and translation layers are required. Both the interface and the implementation become harder to understand and harder to change.
Because software is perpetual work-in-progress, and because requirements will always evolve in response to user feedback and changing circumstances, changeability is the ultimate objective in software design. Simplicity is what makes this possible – it makes code cheap to change. Simple software is inherently adaptable; complex software resists change, leaving organizations trapped by their past decisions. The effort you invest in simplicity at the design stage will pay dividends throughout the software’s lifetime.
Trade-offs
The nine qualities largely reinforce one another. A simple design tends also to be reliable, changeable, and cohesive, and the effort spent on one is rarely wasted on the others. This is the general case, and it is why the qualities are presented as a single design philosophy rather than a menu.
It is not the universal case. Some pairs of qualities pull against each other directly, and no amount of skill dissolves the tension. Every design resolves those conflicts somehow. The distinction that matters is between a conflict resolved deliberately and one resolved by accident — by whichever quality happened to be on the mind of whoever wrote the code that week.
Where the qualities conflict
The recurring conflicts are worth naming, because they are predictable enough to anticipate rather than discover:
- Security against experience. Multi-factor authentication, short session lifetimes, re-authentication before sensitive operations, aggressive rate limits — each buys security with friction the user pays for directly. This is the most common conflict in ordinary business software, and the one most often settled without anyone noticing a decision was made.
- Performance against simplicity. Caching, denormalization, batching, and hand-tuned code paths all buy speed by adding state, duplication, and coupling. A cache is a second copy of the truth, and every second copy is an invalidation problem.
- Performance against portability. Tuning for a specific runtime, database engine, or instruction set buys throughput by binding the system to the thing it was tuned for.
- Correctness against performance. Relaxing consistency — eventual consistency across replicas, reading a value that may be stale — buys latency and availability with a weakened guarantee about the state of the system.
- Completeness against simplicity. Every feature added to serve an unmet need costs interface simplicity, and the cost compounds. This is the tension behind the discipline described in the section on simplicity: focusing on what users genuinely need now, rather than what they might someday want.
Resolving a conflict
Three rules apply when a design meets one of these tensions.
First, try to dissolve it before accepting it. Many apparent trade-offs are artifacts of a particular design rather than properties of the problem. Passkeys are the standard illustration: for years, stronger authentication meant worse usability, until a design arrived that improved both at once. A trade-off is real only once a genuine attempt to avoid it has failed. Reaching for the compromise first is how designs acquire costs nobody had to pay.
Second, defer to the priority order set by the requirements. Which qualities matter most is a business question, settled during requirements specification and recorded there — see TS-1: Software Requirements Specification. The design’s job is to honor that ranking, not to re-decide it. Where a conflict surfaces that the requirements did not anticipate, or where honoring the ranking turns out to be disproportionately expensive, escalate it rather than quietly picking a winner. A design decision that silently overrides a stated priority is indistinguishable from a defect.
Third, record the compromise and its reasoning. What was given up, in exchange for what, and under which constraints. A design doc is the natural home for this — see TS-3: Design Docs. Without the record, the compromise reads to a future maintainer as an oversight, and gets "fixed."
That last point has a corollary. A trade-off is decided under the constraints of its moment: the cost of hardware, the maturity of a library, the size of the team, the load the system was then carrying. Those constraints move. A compromise that was correct when it was made can quietly stop being correct without anything in the code changing, which is why the reasoning is worth more in the record than the decision is.
References
- Wiegers, K (2022). Plan for Tradeoffs: You Can’t Optimize All Software Quality Attributes. Stack Overflow. — Adapted from Wiegers' Software Development Pearls. The source for the position that quality attributes unavoidably conflict, and that the resulting compromises have to be decided explicitly rather than left to the implementation.