TS-2: Software Design Philosophy

This technical standard sets out a philosophy for thinking about qualities in software design. It describes nine software qualities that are universally applicable to every type of software system, whatever the programming language, whatever the target deployment environment, and whether the system is distributed or single-node.

Defining "quality"

It is worth taking a moment to define what we mean by "quality". It is one of the most nebulous terms in software design.

Quality means different things in different contexts. It is relative to the business domain. A banking system and a social media app have completely different quality requirements. Quality means different things to different people, too. Even within the same product, different stakeholders will have different perceptions of quality. Users will value functional completeness, correctness, and reliability. Product managers want features that drive business metrics. Developers want maintainable, testable code. Operators want good observability and lots of automation.

Even so, a useful working definition of quality emerges.

Quality is widely taken to mean the degree to which a software product consistently meets the needs and expectations of its users (external quality) while being constructed in such a way that sustains rapid, confident evolution (internal quality).

Taking this definition, quality isn’t one thing. It’s a multifaceted concept that encompasses both what users experience and how a thing is built, maintained, and operated. This definition gives us two broad categories of software quality.

  • External quality is what users experience. Quality attributes under this category include functional correctness, performance, and reliability.
  • Internal quality governs how a system evolves. Quality attributes under this category include modularity, complexity, and maintainability.

ISO/IEC 25002:2024 gives us a similar categorization. It distinguishes between external metrics and internal metrics, which emphasize concrete quality attributes that can be reduced to measurable thresholds. But the standard definition adds a third category, quality in use, which captures real-world user satisfaction and business outcomes, factors that are more subjective and require qualitative assessment.

Internal and external qualities are deeply interconnected. Internal quality determines for how long external quality can be sustained over the lifetime of a software product. Without good internal quality, external quality tends to decay over time. Adding features becomes exponentially harder, bug fixes introduce new bugs, and production incidents become more frequent and severe.

The best teams understand this symbiosis and invest in both external and internal quality simultaneously.

Quality attributes

This technical standard will cover nine core qualities that are applicable to all software designs. In no particular order, these qualities are the following.

  1. Functional completeness. Whether a system provides all of the functions its specification calls for.
  2. Functional correctness. Whether a system’s behavior stays correct, even after error conditions.
  3. Runtime quality. How well a system meets the non-functional constraints it operates under.
  4. Reliability. A system’s ability to handle and recover from failures of all kinds.
  5. Usability. How well a system serves the people who use it.
  6. Habitability. What it is like for developers to work in a system’s code.
  7. Conceptual integrity. To what degree a system’s design forms a unified, consistent whole.
  8. Modifiability. The ease with which a system can be adapted to changing requirements.
  9. Simplicity. How well a system minimizes unnecessary complexity in both its interface and implementation.

This is not an exhaustive list of software quality attributes. The focus here is on the most elementary of qualities that are intrinsic to all software designs. These qualities are highly abstract, fundamental, and ubiquitous. Rarely are they specified in a system’s requirements, and they seldom show up in acceptance tests. Yet they are all of importance in every software system.

Other qualities such as availability, latency, and scalability are more concrete, derived, and variable. They are downstream of the nine qualities covered in this technical standard. They matter enormously, but how much they matter is a property of a system’s context rather than of the discipline of software design itself. Their relative weighting and target metrics belong in a system’s requirements specification. Their verification belongs in acceptance tests.

It’s best not to view these nine 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 (runtime qualities) tend also to result in system designs that are simple, modifiable, and reliable. Taken as a whole, these quality attributes form a coherent software design philosophy.

The nine qualities divide into two groups. Qualities 1 to 5 are external. They are observable at runtime, in the way a system behaves for its users in production. Qualities 6 to 9 are internal. They are properties of the design itself, visible to the people who build and maintain a system rather than to the people who use it.

The focus of this technical standard is the end result. It’s about what good software architecture looks like, rather than the means of achieving it. But there is some coverage of the software design process, too. This is unavoidable. High quality software can be delivered only through a development system — standards, practices, design, collaboration and teamwork — that itself is designed to enable safe, rapid, sustainable evolution of software. All products are shaped by the methods and tools used to make them. So, topics such as iterative and incremental development, feedback loops, and domain modeling are mentioned briefly, without elaboration, in this technical standard.

Functional completeness

Functional completeness is the degree to which a system provides all of the functions its users need to accomplish their specified tasks and objectives. It is a metric that is calculated by observing how much of a system’s expected behavior it actually exhibits. A gap between what a system does and what its specification says is should do is a defect to be closed.

ISO/IEC 25010 places completeness under the broader characteristic of functional suitability, which means the degree to which a system provides functions that meet the stated and implied needs of its users. Besides functional completeness, there are two other elements to functional suitability.

  • Functional correctness. Whether the delivered functions produce correct results, to the degree of precision the requirements call for. Correct behavior is preserved even after error conditions. (This is the subject of the next section.)
  • Functional appropriateness. Whether the functions specified are the ones that actually accomplish the user’s objectives. A system can cover its specification exactly yet still have low value if the requirements were wrong in first place. This is a concern for requirements engineering more than it is for software design, but it still has some relevance here.

A moving target

There is an obvious objection to measuring functional completeness at all. Much modern software – especially web-based software-as-a-service and other continuously-delivered systems – is perpetual work-in-progress, continuously evolving in response to ever-changing requirements. Such products are never truly complete.

But that objection mistakes what completeness is measured against. It is measured against the requirements agreed for the release in hand, not against everything the product might one day do. A continuously-delivered product still has an agreed specification at every release point. At any given moment, a software system either fully covers its specified requirements, or it does not. It is either complete or incomplete.

Completeness might be a moving target in modern software delivery, but that makes it no less important. Indeed, it may be the most important quality attribute of all. After all, if a user cannot do the tasks they expect to be able to do, the system has no value.

Completeness is the baseline quality requirement, the minimum that every software delivery practice should aim to meet.

Continuous integration and delivery

If completeness is measured against the specification agreed for the release in hand, then staying complete means keeping the implementation and the specification in lockstep with each other. Completeness is therefore not only a property of the software, but also of the delivery process the maintains the software and its specification.

The only practical way to keep the system synchronized with its specification is to evolve both together, one small step at a time. The alternative — batching requirements into big-bang releases — is proven to have the effect of widening the gap between implementation and specification over time.

Continuous integration and, better still, continuous delivery, are the best practices for maintaining completeness. Continuous integration is the practice of integrating every developer’s work-in-progress into a shared trunk at frequent intervals – at least daily – with each small increment of construction automatically built and verified. Continuous delivery is the practice of keeping the evolving software on the trunk in a permanently releasable state, such that the latest revision could be shipped to production at a moment’s notice.

If the requirements specification is maintained in parallel with the software, revised continuously alongside the same small increments of construction, then the implementation and its specification stay in lockstep. (The details of the methodology are covered in TS-1.) This is the most reliable delivery process for maintaining functional completeness in a software system.

The New Jersey style

What follows from the practices of continuous integration and delivery is that it becomes unnecessary to specify a whole system up-front, before it is designed and constructed. The better practice is to target a small but critical subset of functionality for the first release of a software product — a minimal viable product (MVP). From that baseline the system’s capabilities can be incremented, and its design iterated to fit, driven by feedback from real users testing the evolving software in real-world scenarios.

This is consistent with Richard P Gabriel’s famous 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 talk given 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"), which 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"), which 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").

In the New Jersey style, a release that fully covers its current specification (ie. it is functionally complete) may still have a user interface that is a little rough around the edges, and an internal design that remains a work-in-progress. But it is just good enough to deliver the current featureset, and to get feedback from real users experiencing those features in real-world situations, informing the next build increment.

Iterative and incremental development

The worse-is-better principle of starting with a narrow featureset and the simplest possible design, and from there incrementing that featureset and iterating the design driven by user feedback, requires an iterative and incremental development process.

Iterative and incremental development is a process model in which software is built through repeated cycles of development, with each cycle delivering small, working incremental changes. It combines incremental build with iterative design, which means the design evolves to accommodate the continuously evolving featureset. This process enables short feedback loops with real users, allowing requirements to emerge and the design to evolve in lockstep.

Stripped of its methodological trappings, the iterative and incremental loop is very simple. Progress comes from repeatedly assessing the current state, moving a short distance towards a goal, and revising your understanding in light of the result. Then repeat.

Find out where you are.
Take a small step towards your goal.
Adjust your understanding based on what you learned.
Repeat.

– Dave Thomas

Iterative and incremental development processes are especially effective at delivering functionally complete, correct, and appropriate software because the development and real-world testing of the continuously evolving software supports the discovery of its requirements. Requirements gathered before any software exists are, at best, informed guesses, and stakeholders routinely discover what they actually want only by reacting to something concrete. Putting working software in front of real users, and observing how they respond to it, is what turns those guesses into a specification that is proven to have value to those users.

When to use iterative development? You should use iterative development only on projects that you want to succeed.

– Martin Fowler

Data-driven design

To recap: functional suitability is defined as a system whose actual behaviors match those that are specified (it is "complete"), those behaviors are in turn the ones that user’s expect (it is "appropriate"), and those behaviors produce correct results (it is "correct"). It is achieved through a continuous loop of incremental build and iterative design, driven by real-world user validation of the evolving software.

Let’s briefly turn to functional appropriateness. It is more of a concern for requirements engineering than it is for software design. But since appropriate requirements are best discovered through a process of incremental build and iterative design, the two are bound together.

Determining what features are appropriate is an empirical question. Data-driven design is the practice of grounding requirements analysis in observed user behavior, rather than in opinions and assumptions about what users need. It requires collecting and analyzing telemetry data on how a software system is actually used in the real world, and feeding that back into decisions about what to build/change/remove next.

A/B testing is one of numerous methods that support data-driven design. It involves comparing multiple versions of a feature against predefined metrics, with users randomly assigned to experience one version, to establish which performs better on average. Other quantitative methods include telemetry and usage metrics, staged roll-outs behind feature flags, funnel and cohort analysis, and session replay and heatmaps. Qualitative methods include usability testing, user interviews, and the analysis of support tickets and error reports.

Data-driven design is not possible without iterative and incremental software delivery. Shipping small increments creates the opportunity to learn from real usage. Instrumenting those incremental releases with A/B testing and observability tools, for example, converts that opportunity into concrete knowledge about user needs and behavior.

Without measurement, an iterative and incremental process merely produces frequent releases without the feedback loops, because the value delivered by each release remains unverified.

Big design up-front

The alternative approach is big design up-front. This tries to resolve uncertainty and ambiguity in requirements by front-loading more analysis and specification work before construction of the software begins. The subsequent build may still be done incrementally, allowing for stakeholders to inspect the evolving software at regular checkpoints, but the goal is to avoid needing to deviate significantly from the original plan.

This can be a good approach in scenarios where a release cannot be easily patched in the next iteration. Safety-critical and regulated systems, systems with irreversible or expensive migrations (eg. a core data model or a public API contract), and systems that must satisfy a fixed external constraint (eg. a legal requirement or a hardware interface) are all examples of domains that benefit from resolving as much uncertainty as possible before committing to an implementation.

But no amount of analysis substitutes for contact with reality. Assumptions about user needs remain unverified until users actually use the thing. And the cost of being wrong about the requirements compounds with every layer of design and implementation built on top of unverified assumptions.

Validating and iterating on requirements through delivery of working software in small increments reduces delivery risks, by surfacing wrong assumptions made in the requirements while there is still little cost sunk into them, and while the cost of course-correcting remains relatively low.

All software systems benefit from some degree of big design up-front and some degree of iterative and incremental delivery. The relative weight given to each depends on how expensive it is to change course later. The right balance may even vary between different parts of the same system design. So the two approaches are best applied selectively, component by component, rather than adopted wholesale as a single philosophy across the delivery of an entire system.

Piecemeal growth

The New Jersey software design philosophy, supported through an iterative and incremental delivery process, instrumented with data-driven design techniques, is analogous to Christopher Alexander’s piecemeal growth philosophy in urban design.

Alexander’s philosophy is that urban architecture that strives for functional completion and technical perfection from the start often proves to be less less effective at meeting the changing needs of its human occupants over the long term, compared to buildings that prioritize simplicity and tolerate some imperfection.

Piecemeal growth is well suited to modern software, most of which is now developed and operated continuously throughout its lifetime. Piecemeal growth is the best way to deliver appropriate, complete functions for users. But it requires an unconventional approach to project management, in which software systems are delivered, not as one-off projects with a fixed cost and end date, but as products that continuously evolve for as long as they’re being used.

Building software is like building a house that you will never stop remodelling and extending, all the while people are living there. Each extension must be sympathetic to what is already there, else the house degrades into an incoherent warren of additions, losing its conceptual integrity. Ongoing construction work must be done while the place remains habitable to its occupants (developers, operators, and users).

Quality does not happen at the end of an assembly line. It emerges from the entire delivery system. Piecemeal growth through an incremental build model, supplemented with continuous iteration of both the requirements and the design, done in response to feedback from real users testing the evolving software in real-world scenarios, is the only system that is proven to consistently deliver high quality software.

One cannot inspect quality into a product.

– W Edwards Deming

Functional correctness

Correctness means that a system always performs its functional requirements correctly.

A system exhibits correctness when the following conditions are true.

  • When the actual behaviors always match the expected behaviors, as specified in the system requirements. All operations complete predictably, without silent failures or undetected errors.
  • When all state remains valid and consistent, even after error conditions. Data is correctly processed and maintained throughout its entire lifecycle, thus preserving the correctness of future operations made on that data.

For a system to consistently behave correctly, it must be designed to maintain valid state across all operations and outcomes, including those involving errors.

This requires that all possible states, including failure states, be predicted and planned for in a system’s design. There should be no unhandled edge cases that could trip the system into an invalid state. When errors do occur, as they inevitably will, the system must recover itself to a valid state, for example by rolling back a partial operation, returning an error to the caller, or entering a safe degraded state.

Consistency

Consistency is a constituent of correctness. Consistency is a property of a system’s state. Consistent state means values held in different places agree with one another. Correctness is a broader property. It means behavior conforms to the specification. Consistent state is a prerequisite for correct behavior.

In distributed systems, it is common for some consistency to be traded for availability and other qualities. The design objective often becomes one of the weaker consistency models, such as eventual consistency, which means that replicas are permitted to hold different values for a time, provided they eventually converge on the same value.

A system is said to be correct if it is eventually consistent by design. But a system is incorrect if inconsistency arises unexpectedly, and the system fails to self-correct.

State management

Data integrity – the assurance that data is correct, complete, and unchanged except through authorized operations – is a critical aspect of correctness.

Key to achieving data integrity is good state management. Managing state is among the hardest problems in software design, and most correctness defects are ultimately defects of state.

Two design principles do much of the work in maintaining correct state.

  • Minimize mutable state. State that cannot change cannot become inconsistent. Reducing the amount of mutable state in a system removes whole categories of defects – synchronization bugs, conflicting writes, stale reads – rather than merely defending against them.
  • Maintain one source of truth. State MAY be held in more than one place, even within a single service, eg. a read model alongside the write model. What matters is that exactly one copy is authoritative, and that every other copy is derived from it along a defined synchronization path. What SHOULD be avoided is peer copies, each written to independently. However carefully such copies are kept in step, they will diverge. Sometimes that divergence will appear to be random.

Building on the second point, derived values are a common source of inconsistency. Suppose a client receives a bank balance from a server, and a "spendable balance" is derived from it. To guarantee correctness, the derived value must be calculated on demand, rather than it being stored alongside the original for fast retrieval. A slightly less reliable alternative is to recalculate the derived value whenever the source data changes. But sooner or later one such recalculation will fail, and the application will be left holding two numbers that disagree.

As ever, there are trade-offs. If the derivation is genuinely expensive to calculate, caching the result — and extending or invalidating the cache to balance performance against consistency — may be the right design. But caching should be arrived at as a deliberate design choice, and added as a performance optimization to a state management system that already reliably maintains correct state.

Defensive programming

Input validation is a primary line of defense against invalid state. It is not limited to validation of end-user input via the user interface layer. Input validation MUST be applied to all components within a software system, at every boundary where data crosses from one component into another, whether a function call, a message on a queue, a response from another internal service, or a record returned from a database.

A good programmer is someone who always looks both ways before crossing a one-way street.

– Doug Linder

Public interfaces to internal software components MUST be programmed defensively. This means 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 programming 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 is the essence of defensive programming. It 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. This is covered in more depth in the section on reliability.

Runtime quality

A software design is fit-for-purpose if it meets both its functional and non-functional requirements. While functional completeness and functional correctness are the measures of whether a system meets its functional requirements, runtime 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. They specify target metrics for dynamic qualities of a system, which are the qualities that are observable at runtime.

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 runtime qualities 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.

Architecturally significant requirements

It is critical that acceptance criteria for all non-functional requirements be 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 behavior and operations, as it allows functional requirements to be discovered and refined in response to early feedback from users experiencing the software for themselves. But the specification of non-functional requirements benefits from a more big design up-front approach.

The reason is that non-functional requirements tend to be cross-cutting concerns that permeate every layer of a system’s architecture. They are said to be architecturally significant requirements, because changing such requirements tends to necessitate significant redesign effort.

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).

Examples

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 easily bolted on to an existing system, because it cuts across all of the following concerns — and more.

  • 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 often results in expensive redesigns, incomplete implementations, and accumulation technical debt that drags on the longer-term delivery of new value to users.

Security, compliance, and the other runtime qualities are not things 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 a runtime quality that is important enough to warrant being treated as a distinct property.

Reliability is a special case. While other runtime qualities are measures of how well a system operates under normal conditions, reliability addresses a system’s ability to handle and recover from failures and other abnormal conditions. Reliability means the capacity of a system to absorb a fault, carry on in some reduced form, and return to normal afterwards. It is an ISO/IEC 25010 term, and resilience is colloquially used as a synonym.

Reliability is singled out here because, unlike the other runtime qualities, its importance does not vary much from one system to the next. How much performance, or scalability, or even security a system needs is derived from its business context. These qualities should be pinned down in requirements specifications and acceptance tests. But all software systems are required to be reliable. At the end of the day, software automates real-world processes, and so it only has value if that automation is reliable. Yet, reliability is commonly neglected in delivery contracts.

Reliability is closely related to functional correctness. Indeed, ISO/IEC 25010 defines faultlessness, aka maturity, as a sub-characteristic of reliability. Faultlessness means a system performs its operations without fault under normal operation. A system that behaves correctly all of the time is, by definition, a highly reliable system.

ISO/IEC 25010 defines three other sub-characteristics of reliability.

  • Fault tolerance is about operating as intended despite the presence of hardware or software failures. This may involve masking faults so that failures never reach users at all. Typically this involves having a stand-by system, like a backup server, take over. Or it may involve graceful degradation, whereby the system continues to operate but with reduced capability until the fault is resolved.
  • Recoverability is about returning to full service afterwards. Recovering from an exceptional condition may involve the correction of invalid state.
  • Availability is typically measured as the proportion of time that a software system is operational and accessible to users (uptime). A system that fails often (ie. has low fault tolerance) can still have good availability if the time-to-recovery is short (ie. it has high recoverability).

Designing for failure

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 other categories of software. Shrinkwrapped products, internal tools, batch processing systems, and embedded systems all benefit from being designed for fault tolerance, recoverability, and availability. The specific failure modes and levels of acceptable downtime tolerated will vary, but the principle of designing for failure applies universally to all software systems.

To achieve fault tolerance, systems must anticipate their failure modes – things like runtime exceptions, network latency, and other disruptions – and handle them, so that a failure in one component does not cascade into other components of the system, so avoiding full outages. This is a key principle of designing for failure. A common analogy is the electrical grid, where a failure in one component does not cause the whole grid to fail.

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 opposite approach is to try to make a system run perfectly all of the time, to prevent failures from happening at all. This is not realistic. This approach produces inherently brittle systems. That’s because failures mode simply cannot be avoided. Software runs on hardware that conks out, over networks that drop packets, alongside dependencies that change, and in the hands of users who do things nobody predicted. Perfect operation is not a property that any software system can achieve in real-world contexts.

In other words, designing software for reliability is about assuming that unexpected things will happen, no matter how well made the system, and planning for that in the design.

Resilience in distributed systems

Designing for failure is especially important in distributed system design. Distributed systems are inherently brittle because their internal communication patterns involve making network calls, which means they’re subject to a much wider range of failure modes than single-node systems due to network partitions, delayed messages, and node crashes.

In distributed system design, numerous design patterns and operations strategies can help to build in resiliency, including the following.

  • Redundancy. Running multiple instances of a service. Load balancers distribute processing across multiple instances, and circuit breakers stop calls to failing instances.
  • 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.

Redundancy can be taken further. A standby system is a replica held ready to take over – hot, warm, or cold, according to how much state it keeps current. Where a common-mode defect would defeat an identical replica, diverse redundancy substitutes the main system for an independent implementation of the same specification, preferably built and maintained by a separate team. This is an advanced strategy, also known as N-version programming, that is mostly confined to mission-critical and safety-critical domains, because the cost of building and maintaining a second implementation is rarely justified elsewhere.

Release strategies contribute too, by limiting the blast radius of a change. Canary releases expose a new version to a small fraction of real traffic before it reaches everyone, so that a regression is caught while its impact is still small. Blue-green deployments keep two production environments and cut over between them, which makes rollback a mere routing config change rather than a whole new redeployment. Chaos engineering goes further still, injecting failures deliberately in production or production-like environments, to test whether the system recovers the way its design claims it will.

Underpinning all of these patterns is the quality of observability and the practice of monitoring, which determine the ease with which issues can be diagnosed at runtime in production environments. Such visibility is critical for reliability — in all systems but especially distributed ones.

Let it crash

Designing for failure needs to be considered at every level of a system’s architecture. At the low level of code modules, this means programming defensively. This is the practice of raising an exception whenever an internal component receives input it cannot handle, rather than proceeding with its operation on the assumption that the caller has provided valid input. The name is an analogy with defensive driving, which is about assuming that all other drivers on the road will behave carelessly, and in response driving your own car in a way that reduces the harm other drivers could do to you.

The fail fast principle governs what happens when a defensive check trips: raise the failure at the point of detection, rather than continuing with normal operations with state that is known to be invalid. Doing the opposite, allowing normal operations to continue, would risk putting the system into an invalid state, turning local faults into more obscure failure modes downstream, potentially leading to system-wide failures.

Offensive programming pushes the principle harder. It specifies deliberately refusing to paper over any condition that indicates a bug.

Design by contract (DbC) is a related design principle from Bertrand Meyer’s work on the Eiffel programming language. It means designing routines to declare their preconditions (what the caller must guarantee), postconditions (what the routine guarantees in return), and the invariants that hold throughout. A violated precondition is a bug in the caller. A violated postcondition is a bug in the routine. Because each obligation belongs to exactly one party, the contract also says where not to look when diagnosing bugs, reducing the time-to-fix.

The combination of defensive programming and failing fast forces programming mistakes to be quickly fixed at their source, rather than being allowed to cascade into bigger failure modes that are harder to detect and diagnose. These tactics are highly effective for maintaining functional correctness, and they are foundational design patterns in reliability engineering. Failing fast localizes faults, and exception handling at well-chosen boundaries turns those failures into graceful degradation rather than outage.

Erlang formalizes this as let it crash: processes fail fast, supervision trees restart them, and the system degrades gracefully instead of corrupting itself.

Trust and perception

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 – unauthorized access, data breaches, denial-of-service attacks – are other categories of failure mode that must be explicitly designed for.

Software engineering

Designing for failure requires knowing what the failure modes are, even when they are not obvious. This is what distinguishes software engineering from mere development. In software engineering practices, a deliberate share of the overall effort goes into anticipating failure.

The term software engineering acquired this meaning during the Apollo space program, when major systems first had to be built to standards where failure would cost lives. The Toyota Production System is another famed system designed for high levels of fault tolerance.

Software engineering is what happens to programming when you add time and other programmers.

– Russ Cox

In practice, this means treating failure discovery as an activity in its own right. Post-mortems examine failures that have already happened. Pre-mortems and threat modeling workshops turn this into a predictive activity. 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 a design has not yet accounted for.

Usability

Usability is the measure of how well a system serves the people who use it, whether they can achieve what they came to do, at an acceptable cost in time and effort, without the system frustrating them on the way.

Usability is another runtime quality that should be treated as a special case, because usability is such a subjective quality. ISO/IEC 25002:2024 places this under the broader category of quality in use, which covers quality attributes that require qualitative assessment, such as real-world user satisfaction and business outcomes.

Nevertheless, some quantitative measurement is possible. Task completion rates, time-on-task, and user error rates are all quantities that can be observed through usability testing. Other concrete metrics can be gathered from artifacts like support tickets and error logs.

Functional suitability is the bedrock of usability. A system that does not do what its users need cannot be made usable, no matter how well its interface is designed. If a system models its domain well, it will be inherently usable by domain experts. Its concepts, its vocabulary, and the steps it asks users to take will already match the way those users think about their own work. Conversely, if the model is wrong, no amount of interface polish will gloss over it.

Usability concerns all types of human-computer interface. Graphical user interfaces (GUIs), command-line interfaces (CLIs) and terminal user interfaces (TUIs), and application programming interfaces (APIs) are the main types of human-computer interface. Other categories and sub-categories include voice and natural language (VUI), touch and gestural, spatial and immersive (AR/VR), assistive technologies, and physical/embedded interfaces such as hardware controls. (Specific usability requirements for some of these are covered in other technical standards.)

Responsiveness

Among all the factors that contribute to usability, one 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 not only by latency and throughput, but by the feedback the system provides to the user.

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. Well-designed feedback can make operations that take considerable time feel fast and responsive because the user is constantly informed of progress.

Fast, responsive 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 software responsive, 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 has a competitive advantage, too. 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.

Learnability

A system is learnable to the degree that someone using it for the first time can build a working mental model of it quickly. Onboarding tools, contextual help, and user documentation all support learnability. But effort should first be put into making the user interface as intuitive as possible before turning to these bolt-ons.

Inclusivity

Inclusivity is the degree to which a system can be used by people across the full range of abilities, circumstances, and equipment it is meant to serve. Accessibility is the biggest sub-category of inclusivity. Connection quality, device capability, language, and domain literacy are other factors in inclusivity.

Semantic document structure, keyboard operability, focus management, and the availability of text alternatives are the classic presentation-layer concerns.

The Web Content Accessibility Guidelines define testable success criteria at graded conformance levels, so an inclusivity requirement can be written as an acceptance criterion, eg, "WCAG 2.2 Level AA". Much of it can be verified automatically, too.

User error protection

Reliability is concerned with handling the ways a system can fail. Usability is concerned with handling the different ways that a system’s users can make mistakes.

The strongest form of protection is to make an error impossible to input. Input fields with validation controls, and operations that become available only in the contexts in which they are valid, are example UI design methodologies.

The next line of defense is to make user errors recoverable. Undo is the general mechanism, and it is often more valuable than a confirmation dialog — which users are conditioned to dismiss without reading. Reserve confirmation for operations that genuinely cannot be undone.

Where an error can neither be prevented nor undone, what remains is the message. A useful one says what happened, what the consequence is, and what the user can do next.

A cross-cutting concern

Usability is a cross-cutting concern. It is not localized only to the presentation layer. Design decisions made in database query efficiency, caching strategies, API design, network communication patterns, and deployment infrastructure, all ultimately show through in the user’s experience.

To achieve truly fast and responsive software requires conscious design decisions throughout the entire system, not just in the user interface. The most beautifully designed interface becomes frustrating if the back-end is sluggish. Conversely, a fast back-end can be undermined by inefficient front-end code.

Responsiveness, learnability, error protection, and inclusivity MUST, therefore, be treated as first-class architectural concerns.

Habitability

So far, this technical standard has focused on external qualities — functional completeness, correctness, runtime quality, reliability, and usability. These are the qualities that users experience at runtime. We now turn our attention to the internal ones, beginning with habitability.

Internal qualities 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 software vendor’s ability to deliver value to their customers over time.

Of all internal qualities, habitability is the most fundamental. In broad terms, this is synonymous with the concept of developer experience.

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. 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.

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 abstractions are appropriately sized and well named, and every part is self-contained. A newcomer can find where a change belongs, make it, and tell what else it affects. Where the code could not be made obvious, someone has written down why it is the way it is.

A habitable codebase has been designed with respect for the time and cognitive effort that other people will one day invest in it. It is crucial to maintaining wider software quality. Software 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.

The following design principles help to make habitable codebases.

Code design

Habitability starts with the quality and consistency of the code itself: clarity in naming, in the use of language constructs, and in the shape of data structures. It means using terminology derived from the business domain correctly and consistently, developing from a small vocabulary of common design patterns, and maintaining good documentation that carries forward knowledge that is not captured in the code alone.

This is the subject of TS-7.

Maintenance

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.

Habitable codebases are created and maintained through deliberate, incremental, perpetual effort.

The most habitable of codebases are truly joyful places to spend time. 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.

Supporting artifacts

Code alone cannot carry all the knowledge needed to work in a system safely. Documentation is therefore one of the load-bearing pillars of habitability. READMEs, architecture decision records, runbooks, and inline comments, all reduce the cognitive effort required to work in a codebase confidently.

The same principle extends to other supporting artifacts, including changelogs, issue trackers, and test suites read as executable specifications. Each is a durable record that reduces a system’s dependency on any one person’s memory.

People first

So far, habitability has been framed as a property of the code and other durable artifacts. But it also extends outward to the people and the teams around that code.

Habitability is an organizational-level concern. A system that nobody but its original author can safely change will eventually stop serving its users, because its rate of change will slow down, or stop happening at all.

Where knowledge of a critical part of the system lives in one person’s head, that part of the system is fragile, whatever the quality of its code. To maintain habitability, the bus factor must be considered in the design and documentation. The bus factor is the number of people who would need to be unexpectedly unavailable – hit by the proverbial bus – before critical knowledge is lost and the system can no longer be safely changed.

The best guard against the bus factor is to regularly rotate code ownership, and to do so abruptly with no time for formal handover. This is the strongest test that knowledge is transferred from the authors and maintainers of the code into persistent artifacts owned by the organization. The new code owners will depend entirely on documentation, the code itself, and other durable artifacts to pick up the knowledge they need to continue the work.

Conceptual integrity

Conceptual integrity, a term coined by Fred Brooks, is the degree to which a software system’s design forms a unified, consistent whole.

A system with conceptual integrity 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.

Conceptual integrity is not the same thing as cohesion, though the two are easily confused. Cohesion, as defined by Constantine and Yourdon in Structured Design (1979), measures how strongly the elements inside a single module belong together in the service of a single responsibility. Conceptual integrity is scoped to a system as a whole. As defined by Fred Brooks, it is a qualitative view of whether one set of ideas governs the design everywhere.

Real-world examples of systems with high conceptual integrity 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, and 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.

A system with conceptual integrity 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.

Conceptual integrity reinforces habitability. The two properties tend to travel together, though neither guarantees the other. A system may have high conceptual integrity, but rot in documentation, tests, and other supporting artifacts can still make for a poor developer experience.

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)

Domain modeling

Conceptual integrity 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.

Domain modeling is a particularly effective tool for achieving conceptual integrity in a system’s design.

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 conceptual integrity emerges. Conversely, when a system lacks a coherent domain model, or when different parts of a system represent the same domain concepts in incompatible ways, conceptual disunity sets in and the design fractures.

The fight against entropy

The greatest difficulty lies in maintaining conceptual integrity over time. Conceptual integrity has a natural tendency to deteriorate, as people come and go, as the software is modified and extended to meet changing business requirements, and as the original product vision is forgotten.

Entropy is the default direction of travel for all systems. Preserving conceptual integrity 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 — as a counterweight to entropy.

In The Mythical Man-Month, Fred 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," Brooks writes.

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 a system. These are lighter-weight mechanisms than Brooks’s chief-architect model, but they serve the same purpose — to ensure that a unified vision permeates every part of the design.

Conceptual integrity is especially difficult to achieve in distributed computing, such as microservice architectures, where several independent 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.

Conceptual integrity is preserved only through continuous, deliberate maintenance. That ongoing effort is worthwhile. A system that holds its conceptual integrity over time is one that remains comprehensible, maintainable, and ultimately modifiable. A system with a unified design is more likely to be successful in the end.

Modifiability

Modifiability, also called 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.

As a quality attribute, modifiability captures multiple concerns. Each of them is a facet of one question: what does it cost, in time, effort, and risk, to make a system do something different?

  • Extensibility. The ability of a software system to easily accommodate changes or additions to its functionality, without requiring major refactorings.
  • Maintainability. The cost of keeping a software system working over its lifetime – repairing defects, applying security patches, and upgrading dependencies.
  • Portability. The ease with which a system can be migrated to new runtime environments, eg. alternative cloud hosting infrastructure, other database management systems, etc.
  • Manageability. The cost of changing a running system without changing its code – reconfiguring it, deploying a new version, and rolling that version back when it turns out to be faulty. Deployment and configuration are the last mile of every change, so a change that cannot be delivered to production safely is not a cheap change, now matter how cheap it was to change the code.
  • Stability. The ability of the architecture to remain consistent and predictable over time, even as the requirements of the system change. A stable architecture is one that can accommodate new features (extensibility) without requiring major refactoring of the underlying logic structure and data model. (Stability in this sense is a property of the design, not of the running system — it is not a synonym for reliability.)
  • 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 to accommodate changing non-functional requirements such as greater scalability or better performance under heavy load.

The case for adaptive delivery

There’s a strong argument for saying that modifiability is the most valuable quality in software design, because most software is made under conditions of uncertainty.

To start, the requirements for a software system are rarely fully understood at the start of a development project. No matter how much effort we put into discovering requirements from the target users, people have an annoying habit of changing their mind about what they want — after software has been delivered to them.

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 is spent before assumptions made in the software’s requirements and design are tested.

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 business 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.

Predictive versus adaptive delivery

Unfortunately, modifiability 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.

It is for this reason that traditional delivery approaches optimize for predictability. They treat requirements as fixed, the plan as truth, and change as a problem to be avoided.

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, like constructing a bridge, manufacturing a component, or 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. Malleability is one of software’s defining characteristics.

Embrace change

Treating change as a threat wastes software’s core advantage over hardware. Better to embrace change rather than attempt to follow a predictable delivery plan through detailed roadmaps, big up-front design, fixed-scope contracts, gantt charts, critical-path scheduling, and change advisory boards.

Software’s inherent malleability is best leveraged through a design philosophy that doesn’t try to craft the perfect solution up-front, but instead embraces change and iteration from the start — this is the the New Jersey design philosophy.

All software is a perpetual work-in-progress, especially modern services that run on servers. The team that builds a 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 the context of software-as-a-service, modifiability is not a nice-to-have, but vital for commercial success. Businesses that respond quickly to customer needs – fixing bugs, resolving incidents, shipping features – gain a competitive edge over those that can’t.

Designing for failure requires the ability to quick change software to address and fix issues in production as soon as they occur. Modifiability is a critical quality for achieving operational resilience.

Embracing change 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. It requires businesses to change their expectations, too. It becomes necessary for software delivery to be measured in terms of meaningful value provided to its users (the business’s customers), rather than measured through proxy measures such as story points burnt or milestones met.

Design for change

A good software design 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

Designing for change requires lightweight architecture. The broad principle is to make the fewest structural commitments necessary to deliver the current increment, deferring architectural decisions until requirements emerge that justify them.

Lightweight does not mean unplanned. Deferral of architectural decisions applies only to the decisions that are cheap to revisit later. Some architectural decisions should still be made ahead of time. Big up-front design remains preferable for the the load-bearing decisions, which are the ones that tend to derive from non-functional rather than functional requirements. Quality attributes like security and scalability tend to cut across a whole system, touching multiple components in multiple layers. Requirements like these are expensive to retrofit because they cannot typically be delivered through local, incremental change.

So, the lightweight parts of an architecture should be those components that deliver on the current functional requirements. These are the parts of a system that we want to be the most modifiable. Lightweight structures and simple communication patterns in the business layers of a system architecture allow for evolutionary design of those components, supporting changing business requirements.

A note on rewrites

Changing functional requirements should be met by refactoring the software we already have. But sometimes rewrites will be warranted where there is no incremental path forward, typically when an underlying language, framework, or platform is on a deprecation path, or where the structure of the system is wrong in a way that no sequence of local refactorings will rectify.

Everywhere else, the argument against is that a rewrite discards knowledge that is not written down anywhere else. Working software encodes years of accumulated learning — every bug fixed, every edge case handled, every hard-won lesson about how the domain actually behaves.

Joel Spolsky identified why the urge to rewrite is nonetheless so persistent.

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)

Unfamiliar code reads as bad code. The asymmetry between reading and writing biases our judgment toward demolition.

The better design philosophy is to think of software evolution as close to gardening than to construction. A garden is tended and reshaped continuously while it lives, responding to conditions rather than to a plan drawn before anything was planted. That only works if every part of the stack stays evolvable – schemas, interfaces, deployment topology, and the code itself. Wherever any one part of a system is not modifiable, pressure for a rewrite accumulates.

Just because the architecture is supposed to be stable, it does not mean it should never change.

– Gereon Hermkes

Simplicity

Simplicity is the soul of efficiency.

– Austin Freeman

Simplicity is the ultimate sophistication.

– Leonardo da Vinci

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.

Modifiability is one of the primary objectives in software design, and simplicity is what makes it possible. Simplicity matters for modifiability because our capacity to understand what we have built is the binding constraint on what we can continue to build.

Simplicity is the foundation on which all other software design qualities depend. Designing for simplicity tends to bring higher reliability, better performance, and improved user experience, because each of those depends, in the end, on someone understanding the system well enough to get these things right.

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 so it becomes progressively harder for anyone to hold all the relevant factors in mind while making a change. This slows development velocity. It also makes a system brittle, so change tends to introduce defects, further slowing delivery.

John Ousterhout, in A Philosophy of Software Design (2018), identifies three symptoms of this compounding complexity.

  • Change amplification. A simple-sounding change requires edits in many places.
  • Cognitive load. A developer must hold a large amount of context in mind before they can make a change with confidence.
  • Unknown unknowns. It is not possible to predict the full consequences of making a change. Estimates are therefore increasingly inaccurate.

Good tooling can offset the effects of complexity. High-level languages, type systems, static analysis, debuggers, observability platforms, and increasingly AI-assisted development, all these things can extend how much complexity a developer can work with productively. But there is a limit to what better tooling alone can achieve.

Tools raise our tolerance for complexity. They do not reduce the complexity itself.

Beyond what tooling can achieve, only better system design can further raise the ceiling on what can be built. Simple designs allow for larger and more capable systems to be built.

Software development is a perpetual fight against complexity. Design must be continuously reapplied to prevent complexity from compounding in a system over time. Outages, data breaches, and other disaster scenarios tend to arise, not from any single failure, but from the compound effects of multiple failures. Richard Cook’s paper How Complex Systems Fail (1998), which draws from research into healthcare and aviation systems, observes that highly complex systems tend to run permanently with latent failures inside them. Each individual flaw is insufficient on its own to cause disaster, but multiple flaws compound to create the conditions for disasters. Such systems routinely operate in a degraded state, held together by human adaptation to their common failure scenarios, rather than by structural integrity.

To protect against systems decaying to such extreme levels of complexity, design must be applied continuously, rather than through sporadic corrections. Continuous iterative design, through refactoring, is the only defense against complexity compounding into disaster. For this reason, Ousterhout advocates a zero-tolerance stance toward all three of his symptoms of complexity. He says we must immediately respond to any hint of a symptom of compounding complexity, rather than waiting for the complexity to compound further.

What do we mean by "simplicity"?

Before we look into how we can manage simplicity, we should first define what we mean by it.

Broadly, 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

We can draw on some well-documented mental models to further refine our definition of simplicity. The first model distinguishes between interface simplicity and implementation simplicity.

  • Interface simplicity concerns the concepts and behaviors a system exposes to the people and programs that use it. A simple interface asks its users to understand the minimum set of concepts the problem genuinely requires. This is closely related to the concept of functional suitability.
  • Implementation simplicity concerns the structure behind that interface — how the parts of a solution are arranged, and how tightly they depend on one another.

This categorization identifies the location of complexity in a system. The second model, which comes from Fred Brooks’s classic 1986 paper No Silver Bullet, identifies the cause of complexity.

  • Essential complexity is inherent to the problem domain and cannot be designed away. A payroll system is inherently complicated because payroll is complicated.
  • Accidental complexity is complexity we introduce ourselves, through the way we choose to implement a solution. Broadly, accidental complexity comes from two sources.
    • Global complexity is related to the principle of conceptual integrity, referring to how well a single conceptual framework is applied across a whole system. Consistency of architectural styles, design patterns, and communication patterns are all relevant here.
    • Local complexity refers to things like code duplication, sprawl, dead abstractions, and the accumulated technical debt of decisions never revisited.

Brooks’s distinction between essential and accidental complexity is disputed. Moseley and Marks, in Out of the Tar Pit (2006), argue that much of what Brooks classed as essential is in fact state-induced, and so it is accidental complexity that derives from our design choices. For example, they argue that everything a system does in pursuit of performance — caches, indexes, denormalization, partitioning — is, in fact, accidental. Even though performance requirements come from the problem space, the particular forms of complexity that arise are determined by the design trade-offs we make in the implementation of the requirements.

We should not be overly concerned with the exact categorization of complexity. Outside of academia, for the practical application of software architecture in the real-world we can simply think of all specified requirements, both functional and non-functional, as being derived from the problem space, and so the minimum design needed to satisfy the requirements is the essential complexity in the software. It is the goal of requirements engineering to establish what that essential complexity is, and to minimize it. Everything else is accidental complexity, and minimizing it is primarily the responsibility of software architects (who focus on global complexity) and programmers (who focus on the local).

This separation of responsibilities between requirements (essential complexity) and design (accidental) is not always so clear-cut. Nonetheless, it is a useful mental model for guiding our approach to achieving simplicity in our software systems. We should first identify the form that complexity takes before choosing how to reduce it. For example, if we identify essential complexity in a system, we should probably first look at the requirements specification in our efforts to reduce it.

We can combine both mental models in our identification of complexity in a system. For example, essential complexity may show up in the implementation or the interface, or both. A system may have a highly complex implementation that is essential to achieving the functional and non-functional requirements placed upon it. For example, a system required to sustain a high write throughput with strong durability guarantees will need partitioning, replication, and a consensus protocol — complexity that can’t be designed away. Similarly, a well-factored codebase, one with low accidental complexity through all its layers, may still expose high complexity in its interface, if such complexity is essential to expose the operations and behaviors of the business domain.

Accidental complexity, too, may show up in the implementation or the interface, or both. We can think of poor usability as being accidental complexity that’s leaked into the interface. Incidental coupling, dead abstractions, and inconsistent use of patterns are all examples of local accidental complexity in the implementation. Two services that model the concept of a "customer" differently, or a single cross-cutting concern handled in different ways in different services, are examples of global accidental complexity in the implementation.

We must first identify and properly categorize complexity in a system, before we can manage it properly.

Managing complexity

Simple software is more expensive to achieve than complex software. Complexity — in all its forms — is often the result of insufficient effort being put into the requirements and design. Complexity must be managed. The management of complexity is a perpetual overhead for the software vendor.

Before we decide how we will manage complexity, we must first appreciate that the objective is not to eliminate all complexity. Neither essential complexity nor accidental complexity can be eliminated. Any system that does something, anything, has some essential complexity in it. We can’t refactor away all accidental complexity, either, because this is the outcome of trade-offs having been made between competing constraints and properties. If different design choices are made, accidental complexity would simply resurface in different forms.

Good system design is about managing complexity — finding the right balance of it — not eliminating it entirely.

In deciding how we will manage complexity, and where we will focus our efforts, we can draw on our mental models of where complexity is located in a system (interface versus implementation) and when it surfaces in the development lifecycle (essential versus accidental).

Managing complexity is done throughout the software development lifecycle, but two phases are particularly critical: requirements analysis, for managing essential complexity, especially in the interface; and design, for managing accidental complexity, especially in the implementation.

For new systems, and for major new requirements in existing systems, we should shift left the management of complexity in the delivery lifecycle as much as we can. This means tackling the essential complexity first, reducing that as much as possible through requirements refinement. Once essential complexity cannot be reduced further, accidental complexity is what remains under our control, and squeezing it out of the design is where the remaining returns will be.

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

In existing systems, the first thing to appreciate is the forms that complexity already takes. For example, what is the relative balance between essential and accidental complexity? It helps to picture them as being placed on opposite sides of a set of scales. In many systems, you’ll find one side will weigh more than the other. This determines where requirements and/or design effort will be best spent. You’ll get the highest returns — for habitability, modifiability, and other qualities — by first investing in reducing the most weighty form of complexity.

Essential complexity
Accidental complexity

Another rule-of-thumb is that 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 so has little headroom for accidental complexity on top. By contrast, a small, simple problem domain can absorb a certain amount of clumsiness in its solution and still remain tractable.

Complex problems require simple solutions. Simple problems 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 becomes necessary to keep accidental complexity — sprawl, cruft, technical debt — in check. In an ever-expanding system, in which essential complexity continuously expands, the goals should be for accidental complexity to be continuously reduced.

An evolving system increases its complexity unless work is done to reduce it.

– Meir Lehman

But where essential complexity is low, and where the domain suggests the requirements will remain stable, you may get away with allowing some accidental complexity to accumulate. With stable requirements, it’s reasonable to assume a system will not be required to change much, so there may be little return to be had from refactoring away accidental complexity to support future change. In these systems, effort may be better spent on holding the interface steady.

Consider, also, the relative balance of complexity in the interface versus the implementation. The New Jersey style prioritizes the simplicity of the implementation over the interface, because simple implementations are essential for maintaining modifiability. The interface can be a little rough initially. A simple implementation will allow the interface to be easily changed once the users' actual requirements are better understood, through feedback of their experience of using the software.

And yet, a simple interface signals to users that you have thought carefully about what matters to them — that the software is functionally suitable for their needs. This extends to the runtime qualities observed through the interface. Companies whose software is perceived as fast and responsive tend to have very focused product offerings, because the effort required to make software fast forces difficult decisions about what functions to include and what to leave out.

A simple interface also buys consistency in design and behavior, which is central to usability. A small, coherent set of UI concepts lets users predict how the system will behave from their previous experience of it.

So both simplicity of the interface and simplicity of implementation, but for different reasons and the relative emphasis placed on each will vary — between projects, and even at different points of a single system’s lifespan. But the two are not inseparable. On the contrary, it is important for the interface and the implementation to be aligned. This means that a coherent set of concepts, originating from the business day, flow through the whole system, through both its interface and implementation.

Domain modeling is the tool to reach to, to keep alignment between the interface and the implementation. A well-design domain model can serve as the bridge between the two. When the model is sound, the interfaces a system exposes align naturally with its internal structure. Users encounter concepts that make sense from their perspective, and those same concepts are reflected faithfully in the implementation. The alignment reduces cognitive load on both sides — users and developers. See the section on conceptual integrity for domain modeling’s wider role in holding a design together.

When interface and implementation are misaligned — when a 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, and both sides become harder to understand and harder to change.

Balancing complexity

The objective is to engineer just enough complexity into a system to deliver on the requirements.

Over-engineering is engineering more than the problem requires. It originates in requirements when user needs are imagined but unverified. It also originates in the implementation, eg. through design of unnecessary abstractions, excessive modularization, and unused configurations — anything that reduces, rather than increases, the system’s malleability.

Under-engineering is engineering less than the problem requires. It originates in requirements that fail to elicit capabilities that users need and expect. It also originates in the implementation, eg. through failure to design in suitable error handling and security procedures.

(Over-engineering should not be confused with feature creep. The two concepts are often conflated. Feature creep refers to functionality accumulating because stakeholders keep asking for it, though each individual feature request may be reasonable on its own. It is a product failure, more than it is one of requirements specification and design.)

Balancing complexity is a nuanced judgement, calibrated by feedback and improved through time and hands-on experience with the system. We can also turn to some broad principles to help us get the balance about right.

First, in keeping with the New Jersey design philosophy, it is generally preferable to err on the side of under-engineering, rather than over-engineering. Prefer to ship something simpler than planned, then improve it where evidence — gathered through various feedback loops — proves rework is needed. Over-engineering tends to have greater costs.

Second, to guard against over-engineering, apply the principle of YAGNI — "you aren’t gonna need it". Solve today’s problems, and trust that good design will keep tomorrow’s problems affordable to solve. This principle pushes back on the tendency toward perfectionism — pursuing design purity that brings no business value — and future-proofing — making assumptions about specific requirements for change in the future, and prematurely optimizing the system to accommodate those changes, before the changes are known.

Coupling

Implementation simplicity, in its local dimension, is primarily a question of how a system is divided into parts, and how those parts communicate with (and so depend on) one another. The decomposition of system into parts gives us modularity — components that can each be understood, changed, and tested on their own. But excessive modularity can result in high coupling.

Coupling — to what degree modules depend on one another and must be changed in unison — is the source of much complexity. A highly coupled system is inherently brittle and difficult to change. Coupling is unavoidable. All modular systems have coupling. It’s how modules talk to each other. The objective is not for zero coupling, but for a pragmatic degree of modularity that gives optimal low coupling, enough to make a system sufficiently flexible and adaptable.

So far, we’ve defined coupling in broad terms. But coupling actually takes many forms. And we must pay attention to all forms of coupling in our perpetual battle against complexity.

Michael Nygard categorizes coupling into five types.

  • Operational coupling. Runtime coupling, eg. an application that won’t even start up when its database server isn’t available. A single flaky shared service can cause downtime across many dependent applications. This is the enemy of fault tolerance. A system cannot degrade gracefully while it carries operational coupling.
  • Developmental coupling. This is about change. It bites as soon as more than a few people are working simultaneously on the same system. This coupling is where two components have to be changed together. Modification in one forces a coordinated modification in the other. The moment two components share a function or a data schema, neither can evolve independently. This problem gets exponentially more complicated as systems and teams grow in size and complexity.
  • Semantic coupling. Two or more components share a concept, eg. the attributes that a customer order contains. The component may not share a single line of code, but they still can’t change independently, because they have to agree on the meaning of things to be able to work together. This is difficult to reason about, because no deterministic tooling will warn you about this type of coupling.
  • Functional coupling. Different parts of a system address the same problem in different ways, eg. two slightly different implementations of "calculate discount" or "validate email address". The result is inconsistent behavior in different parts of the interface, which looks like a bug but isn’t caught by tests.
  • Incidental coupling. Coupling that exists for no good reason at all, eg. a call from one module reaching right across a system to grab a value that it has no business knowing about. This is spaghetti code — the absence of architecture. It is pure accidental complexity, pure cost, and buys nothing in return.

Those five categories describe what kind of dependency exists between two components. A second vocabulary, drawn from structured design and static dependency analysis, describes the shape the dependency takes in the code. These properties cut across all five of the above categories, but they are most useful when analyzing a codebase for developmental coupling.

  • 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 rest 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 a 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.

The connections between modules are the assumptions which the modules make about each other.

– David Parnas

Not all coupling is bad. By definition, all software has coupling. Coupling is how the parts of a software system talk to each other. Software architecture is about making deliberate decisions about how and where that necessary coupling is done.

It follows that the goal is well-managed coupling, not minimal coupling. Pursuing 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 layers of indirection. This excessive modularity is a form of over-engineering — a decomposition finer than the problem requires — and it degrades simplicity just as surely as a tangled monolith does.

The heuristic is to couple together the things that are intended to change together. If two components almost always change in tandem, a dependency between them acknowledges reality. If they are independent concerns that merely happen to coexist, keep them apart.

Different architectural patterns prioritize managing different categories of coupling. Building a complex system is not about removing coupling entirely, but about trading one type of coupling for another. For example, event-driven systems trade operational coupling for developmental coupling — components are coupled by contract, in the form of data message structures, rather than by their operational runtimes, since there are no more direct calls between them.

You can’t eliminate coupling. You can only move it to where it is easier to manage.

As a rule-of-thumb, strong coupling is acceptable where the thing you’re coupling to is stable. For example, the SQL language hardly ever changes, and certainly not in a breaking way, but a database schema is much more volatile.

Coupling is most closely associated with simplicity, but it is a cross-cutting diagnostic, and several of the other design qualities can be read through it. The blast radius of a change is a direct consequence of coupling, which makes it a measure of modifiability. It determines how far a failure propagates — asynchronous messaging improves fault tolerance precisely because it decouples — which makes it a measure of reliability. A component that can be read without holding much of the rest of the system in working memory is a loosely coupled one, so coupling is also a proxy for the cognitive load a codebase imposes on the people working in it, and hence for its habitability. Coupling and cohesion, meanwhile, are two views of the same decomposition: a well-chosen module boundary produces high cohesion within the module and low coupling across it, and when a decomposition is wrong, both degrade together. Because coupling cuts across the qualities in this way, a rising trend in it is an early signal of design decay generally, and not only of complexity in the narrow sense.

Coupling is not only a technical property, either. It is shaped by how the people building a system are organized, and team boundaries tend to become service boundaries whether or not that was the intended design — see TS-5: Application Architecture. A related effect operates at the level of process rather than team topology: the more people who must be involved in a single design decision, the harder it becomes to arrive at a simple result. Keeping the design process itself lean is therefore a lever on complexity in its own right.

Now we understand the broader forms that coupling can take, we can appreciate that symptoms of coupling extend far beyond weaker malleability. Look for these smells in your coupling audits.

  • Slow builds. Often a sign of circular dependencies.
  • Complex test setup. Tests need to construct much more context than the component-under-test should require.
  • Brittle tests. Tests break when you rename an internal class — a sign of coupling to implementation details, rather than to behavior.
  • Blocked teams. Teams move at the pace of the slowest team, because of cross-team dependencies.
  • Lockstep platform releases. Shared code demands that every dependent service take a new version at the same time.
  • Long parameter lists. A function with eight parameters probably knows too much about its callers.
  • Risky releases. Deployments that frequently require rollbacks.

Appropriate abstraction

Coupling is managed, in practice, through abstraction. Abstraction is the primary tool for hiding accidental complexity, and it is also what confines a dependency to a chosen place and allows a component to be understood on its own. Used well, an abstraction leaves behind code that expresses only the essential complexity of the domain, and it holds the two dimensions of simplicity in alignment: a small interface in front, an implementation that absorbs the detail behind it.

But every abstraction is itself a thing to be learned, and a layer to be traced through when diagnosing a problem. It buys a reduction in change amplification at the price of an addition to cognitive load — two of the three symptoms described above, traded off against each other.

Hence the test. Introducing an abstraction is worthwhile only when the complexity added by the abstraction is less than the complexity of the problem it hides.

This test is easy to state and easy to fail. Unwarranted abstraction — elaborate patterns applied to simple logic, or layers of indirection introduced in anticipation of requirements that never arrive — adds complexity without hiding any. It is over-engineering in its most common form, and it usually arrives by the route YAGNI warns about: future-proofing against a change nobody has asked for. Dependency injection is a common example — valuable where object construction is genuinely complicated or genuinely needs to vary, but frequently applied at a granularity that adds ceremony without separating anything worth separating.

It is a coupling failure as much as a complexity one. An abstraction that hides nothing does not remove the dependency underneath it, it renames it. The semantic coupling survives the indirection, and no tooling will warn you about what remains. Excessive modularity is the same mistake made at the scale of components rather than of types.

Indirection is not abstraction. One adds a layer; the other removes a concern.

Design patterns should be adequate to the problem, and no more.

Premature optimization

The other common form of over-engineering is premature optimization, which buys speed the system has not been shown to need. The earlier discussion of essential and accidental complexity has already anticipated it: the objection Moseley and Marks raised against Brooks was exactly this point, that everything a system does in pursuit of performance — caches, indexes, denormalization, partitioning — is accidental complexity, produced by our design choices rather than handed to us by the problem.

The mechanism is state, and it is never free. Speeding up a system almost always means adding some. A cache duplicates a value that already exists elsewhere. Denormalization duplicates data across boundaries that were previously independent. Batching couples requests that were previously handled in isolation. Each of these buys performance by tightening coupling somewhere in the system — data coupling in the first two cases, temporal coupling in the third, and temporal coupling is the form that leaves no trace in the dependency graph.

That cost is often worth paying, but it is a cost rather than a free improvement, and it should be paid deliberately, once a real performance problem is in evidence — not speculatively, alongside the rest of the design. This is the New Jersey preference for under-engineering, applied to performance: ship the simpler thing, and let measurement rather than anticipation justify the rework.

The stopping condition comes from the requirements. Runtime qualities are specified up-front, with acceptance criteria attached, so a system that meets its latency and throughput targets is finished. The temptation to keep tuning past that point adds coupling that no requirement asked for.

One qualification. Not every optimization adds state. The cheapest ones take work away — a query that no longer runs, a feature that no longer has to be supported — and, as noted earlier, the effort of making software fast tends to force precisely those decisions about what to leave out. Optimization of that kind buys performance and simplicity together. It is optimization that buys speed with state that has to be justified by evidence.

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.

– Donald Knuth

Boring technology

Over-engineering enters at the level of the technology stack, too, and not only at the level of the code. Conceptual integrity asks that a system be built from a small, deliberate pool of languages, infrastructure, patterns, and libraries. Simplicity adds a corollary about which technologies belong in that pool. Prefer the ones that are mature, stable, and well understood — what Dan McKinley called boring technology.

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 that principle warns against.

The right question is therefore 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. It is the same YAGNI discipline applied to the stack rather than to the design: adopt for a problem you have, not for one you anticipate.

Measuring complexity

Simplicity in software is not a perfectly measurable property. Unlike correctness, which is more binary in nature, a software system can be more or less simple. So, assessing simplicity requires careful, qualitative judgment rather than concrete metrics.

Coupling is more tractable. Dependencies can be counted, their direction traced, and cycles among them detected — often mechanically, using static analysis tools built for the purpose. A dependency graph makes efferent and afferent coupling visible at a glance, and cycle-detection algorithms can flag circular dependencies. Coupling is therefore the most useful instrument we have for turning an assessment of simplicity from an opinion into something closer to an observation.

Cyclomatic complexity is a related, narrower metric, measuring the number of independent paths through a single unit of code — a function or method — by counting its branch points. It is a proxy for how hard that unit is to reason about and test. A function with a cyclomatic complexity of 15 has at least 15 distinct paths that tests would need to cover for full branch coverage. But it says nothing about coupling between components, and a codebase can score well on it while still being tightly coupled and hard to change. So cyclomatic complexity complements structural coupling metrics rather than substituting for them.

What no metric reaches is the global dimension. A codebase can show low efferent coupling, no dependency cycles, and healthy cyclomatic complexity throughout, and still be built on three incompatible models of the same domain — because each of those models is internally consistent, and only the relationships between them are confused. This is the part of simplicity that resists measurement, and it is why the assessment cannot be delegated to tooling entirely.

Even where coupling can be counted, the count is a question rather than a verdict. Tight coupling between two components that genuinely change together is good design; the identical measurement between two unrelated concerns is a defect. No number distinguishes the two cases — only knowledge of the domain does. So the useful question is not "how much coupling is there?" but whether the coupling that exists follows the lines along which the system actually changes, and that is a judgment about the domain rather than a reading taken off a dependency graph.

That simplicity resists direct measurement does not mean a team should track nothing. A small set of context-specific proxies is worth choosing deliberately and watching over time. The common candidates are the number of components, the number of interfaces between them, and — from the DORA delivery metrics — the lead time for a routine change and the mean time to restore service after a failure. None of them measures simplicity itself. But a worsening trend in any of them is an early warning that complexity is accumulating faster than it is being managed, well before the effect shows up as missed deadlines or production incidents. Treat the trend as the signal rather than the absolute value, since what counts as a reasonable number of components or interfaces is entirely context-specific.

Trade-offs

The nine software design qualities discussed in this technical standard — functional completeness, correctness, runtime quality, reliability, usability, habitability, conceptual integrity, modifiability, and simplicity — reinforce one another.

The principle of simplicity underpins everything. A simple, habitable design is one that can be changed cheaply, so emerging requirements can be built incrementally and put in front of users early. The feedback that comes back sharpens the requirements, which is what makes the next increment of development more functionally suitable. Everything else becomes possible when a system is simple in what it does (its interface) and how it works (its implementation).

Taken together, the nine design qualities covered in this technical standard form a complete, coherent software design philosophy.

But sometimes these qualities are in tension. Classic examples of trade-offs made in software design are listed below.

  • Security versus usability. Multi-factor authentication, short session lifetimes, re-authentication before sensitive operations, aggressive rate limits — all these security procedures necessarily add friction to the user experience.
  • Performance versus 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 a truth, and every copy is an invalidation problem.
  • Performance versus 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 versus performance. Relaxing consistency — eventual consistency across replicas, reading a value that may be stale — buys latency and availability, but with a weakened guarantee about the state of the system.
  • Functional completeness versus simplicity. Every feature added to serve an unmet need costs interface simplicity, and that cost compounds with every feature added.

It is the role of the software architect, in collaboration with business stakeholders, to resolve these conflicts. Trade-offs should be pragmatic, without emotional attachment to any particular principle over another.

As a general rule, 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 (eg. by adding validation checks) than to speed things up in a way that risks the system entering an invalid state. The core principle here is that the purpose of software is to automate real-world processes, and software fails in that core purpose when it is not correct. Reliability — the ability of a system to recover to a correct state following an error condition — serves the same end.

Beyond that, three rules of thumb help to resolve design trade-offs.

  1. Try to dissolve the conflict first, by coming up with a design that improves both qualities that are in tension. Passkeys are an excellent example of a design innovation that simultaneously improved security and usability, where the two were previously in conflict.
  2. Otherwise, defer to the priority order set by the requirements. Which software design qualities matter most SHOULD be negotiated with business stakeholders and captured in a system’s requirements specification. Where a conflict surfaces that the requirements did not anticipate, or where honoring the existing ranking would be disproportionately expensive, the design decision SHOULD be escalated to a collective decision involving all stakeholders. See also TS-1.
  3. Either way, record the compromise and its reasoning. An architectural decision record, or equivalent durable design documentation, is the natural home for this. See also TS-3.

How good is good enough?

Every one of these qualities can be pursued past the point of usefulness, which raises the question of how good is good enough.

A useful answer comes from the DevOps Research and Assessment (DORA) program, which found that the highest-performing software houses score well simultaneously on throughput — how quickly changes reach production — and stability — how often those changes break, and how quickly the system recovers.

Crucially, the two properties — throughput and stability — were not found to trade off against one another. Teams that ship quickly tend also to be the teams that ship safely. Why? Because the practices that make change safe are also the practices that make it fast.

That gives a working definition. A design is good enough when the software can be changed quickly and safely, both in how it works (its implementation) and in what it does (its interface).

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 in hand.

The quality bar is therefore set by the level of change that a system is required to absorb. A prototype is good enough at a standard that would be negligent in a production-grade payments ledger.

References