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

Modifiability is one of the primary objectives in software design, and simplicity is what makes it possible.

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.

Complexity has a tendency to compound over time. As a system acquires features, dependencies accumulate between its parts, and so it becomes progressively harder to change.

John Ousterhout, in A Philosophy of Software Design (2018), identifies three symptoms of 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 increasingly inaccurate.

The primary bottleneck on modifiability is the developer’s memory capacity. As complexity compounds in a system, it becomes increasingly unlikely that any single person can hold all the relevant factors in mind while making a change to that system. This slows the velocity at which change can be made. And a system becomes increasingly brittle, as changes introduce new defects, further slowing future modification.

Our capacity to understand what we have built is the binding constraint on what we can continue to build.

The greatest limitation in writing software is our ability to understand the systems we are creating.

– John Ousterhout
A Philosophy of Software Design (2018)

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

Good tooling can offset some of the symptoms of complexity. High-level languages, type systems, static analysis, debuggers, observability platforms, and increasingly AI-assisted development, all of 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. Simpler 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 modes, 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 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-known 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 performance 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.

Complexity is not always so easily categorized. Nevertheless, identifying the form of complexity is the first step we MUST take before we can properly manage it. The form that complexity takes determines what we can do about it.

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

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 on reducing it, we should 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, and global versus local).

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

A good 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 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 continuously developed system, in which essential complexity continuously expands, the goal should be for accidental complexity to be continuously reduced, as a counter-balance.

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 necessary for maintaining modifiability. The interface can be a little rough initially, as long as the implementation is simple. A simple implementation will allow the interface to be easily changed once the users' requirements are better understood, through feedback of their experience of using the software.

And yet, simple interfaces are important too. 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. Focusing design effort on the interface — on usability — is a useful constraint that can force good design choices through the implementation. For example, if you prioritize making software fast and responsive — runtime qualities that are observed through the interface — this will force simplicity in the implementation. It also tends to make for more focused, functionally suitable products. The effort required to make software fast forces difficult decisions about what functions to include and what to leave out.

So both simplicity of the interface and simplicity of implementation, but for different reasons and the relative emphasis placed on each will vary. And 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.

To keep alignment between the interface and the implementation, turn to domain modeling. A well-design domain model will serve as the bridge between the interface and the implementation. 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.

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 management 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 — shows where rework is most needed.

Over-engineering tends to make systems rigid, and therefore less modifiable. On top of the initial costs of the extra engineering effort, over-engineering leads to higher engineering costs for the vendor over the long-term, because of the system’s increased resistance to future change.

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.

Third, a point related to YAGNI, avoid prematurely optimizing a system for performance or other quality attributes that it has not yet demonstrated it requires. Doing so almost always involves adding new complexity. 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).

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

– Donald Knuth

That said, some strong arguments can be made for premature optimization. There are plenty of examples of optimization strategies that involve reducing complexity, by taking things away or decoupling parts. These can be well worthwhile doing early. And you still want to deal with your main non-functional requirements, which are typically cross-cutting concerns, through big up-front design, not iterative design. Setting performance and other runtime quality constraints from the start also plays the useful role of a forcing function to make the customer think carefully about prioritization of features.

Finally, prefer to choose old, boring technology over new, exciting technology. This is where over-engineering enters the level of the technology stack. In most domains, technology preferences should be biased toward mature, stable, well-understood tools. Every technology carries both known unknowns ("we don’t know what happens when this database hits 100% CPU") and unknown unknowns. Old, boring technology has more of those unknown unknowns converted at least to known unknowns, maybe better. Critically, older, enduring technology is proven through extensive real-world usage, and more of its failure modes have been uncovered, documented, and compensated for.

Related to the last point, beware technical sprawl. Organizations should constrain themselves to a narrow pool of pre-approved technologies in their production stacks. The question changes from "which tool is best for a given problem in isolation?" to "which tool occupies the least-worst position across the most of our problems?" The bar for adopting something new is that it should solve a specific, long-standing problem that existing tools do not.

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 a system into parts gives us modularity — components that can each be understood, changed, and tested on their own. Modularity is a good thing for simplicity. But, as always, its about balance. Excessive modularity can result in high coupling, a bad thing.

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. The blast radius of a change is a direct consequence of coupling. modifiability is primarily a property of coupling.

Besides weaker malleability, high coupling has other symptoms. It can show up in slow builds (often a sign of circular dependencies), brittle tests (test break when you rename an internal class, a sign of tests being coupled to implementation detail rather than behavior), blocked feature delivery (due to cross-team dependency), and risky releases (requiring synchronization of multiple service deployments, or deployments that frequently require rollback).

In fact, coupling impacts almost all other quality attributes. It impacts resilience, for example, by determining how far a failure propagates. Coupling — many forms of which can be measured cheaply through static code analysis — can be used as a proxy metric for the cognitive load a codebase imposes on the people developing and operating it. And coupling tends to go hand-in-hand with cohesion. A well-chosen module boundary tends to produce high cohesion within a module and low coupling with components that depend on it. All these factors determine the habitability of a codebase.

Coupling is unavoidable. All modular systems have coupling. It’s how modules talk to each other. The design objective in the implementation is not for zero coupling, but for a pragmatic degree of modularity that gives optimal low coupling, enough to make a system sufficiently modifiable for the requirements of its domain.

So far, we’ve defined coupling in broad terms. But coupling actually takes many forms. 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 form 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 grow in size — this form of complexity is particularly prone to compounding.
  • Semantic coupling. Two or more components share a concept, eg. the attributes that a customer order contains. The components 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 that dependencies take 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

We’ve already said that coupling can’t be avoided, and that a simple implementation is one with a pragmatic balance of coupling that gives a suitable degree of malleability in the software, as required in the domain. We can now extend this objective to state that the concern of software architecture is to make decisions about what forms of coupling are acceptable, and so where coupling is allowed to exist in a system.

The goal, then, is to manage coupling deliberately, not simply to reduce it. Indeed, pursuing low coupling as an end in itself can simply shift complexity elsewhere. For example, a system decomposed finer than the problem requires — into too many components, each defensively isolated from the others — will force developers into tracing behaviors across many files and layers of indirection. This is the excessive modularity that leads to high coupling, mentioned earlier. This is a classic example of over-engineering, where dogmatically pursuing the goal of a design principle — in this case, low coupling — actually increases complexity.

Rather than striving for the lowest amount of coupling, the approach should be to manage coupling deliberately through strategic decision-making about the trade-offs between the different forms of complexity.

Most day-to-day programming decisions are concerned with developmental coupling. 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. Deliberate coupling is beneficial here. If they are independent concerns that merely happen to coexist, keep them apart.

Another rule-of-thumb, it is generally okay to strongly couple to things that are stable. Focus your efforts on isolating the things that are volatile. For example, the SQL language hardly ever changes, certainly not in a breaking way, but a database scheme is much more volatile. You should hide the schema behind a repository pattern, but the repository component itself might directly execute SQL.

At the architectural level, choosing between architectural patterns is primarily a decision about trading undesirable forms of coupling for more desirable forms of it. 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 direct calls between them. Event-driven architectural patterns don’t remove coupling, they only move it to where it is easier to manage.

The pipes-and-filters pattern trades developmental coupling for semantic coupling. The most well-known implementation of this pattern is Unix, where separate programs, developed and maintained entirely independently of each other, can be chained into pipelines so that one program’s output becomes the next one’s input. The programs composed into a pipeline are coupled though a lightweight contract, the text stream. This makes the coupling cheap, which is why it is so easy to configure pipelines by swapping programs. Unfortunately, text streams are weakly typed and lossy, so the coupling is semantic. All programs in a pipeline must agree on the data formats required for their input and output. This is a form of coupling that deterministic tools are not generally good at identifying.

The optimal forms of coupling in a system vary between domains, and throughout the lifetime of a single system as its requirements, scale, and production loads evolve.

Coupling is not only a technical property, either. It is shaped by how the people building a system are organized, and how they collaborate. In distributed systems, team boundaries tend to become service boundaries, whether or not that was the intended design. Team topologies is another lever, a less obvious one, in the fight against complexity through coupling.

Appropriate abstraction

After the diligent management of coupling, the next most powerful tool we have to fight complexity is abstraction.

Abstraction is the primary tool for hiding accidental complexity. Done well, an abstraction leaves behind code that expresses only the essential complexity of the domain, hiding all the implementation details behind a small domain-oriented interface.

But every abstraction is itself a thing to be learned, and a layer of indirection to be traced through when diagnosing a problem. As always, use of abstraction must be balanced.

The heuristic is that introducing an abstraction is worthwhile only when the complexity added by the abstraction is less than the complexity of the problem it hides. Easy to say, but abstractions often fail this test. It is not uncommon to see elaborate patterns applied to simple logic, o layers of indirection introduced in anticipation of requirements that may or may not one day arrive. Overzealous use of dependency injection is a code smell that flags potentially unwarranted abstraction. Dependency injection is valuable where object construction of the dependency is genuinely complicated, or where the behavior of the parent genuinely needs to be variable dynamically. But it is frequently done for the sake of separating things that are not worth separating.

More generally, the heuristic is that all design patterns should be adequate to the problem, and no more.

Measuring complexity

Complexity must be continuously designed out of long-lived systems, to counter the dangerous effects of complexity compounding through continuous change in its requirements.

Continuous management of complexity requires understanding of what sorts of complexity we’re dealing with. This requires measurement. Complexity must be continuously measured.

Coupling is the easiest thing to measure. 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.

Cyclomatic complexity is a useful companion metric. While coupling metrics represent complexity in the static structure of a system, cyclometric complexity represents the the complexity of the runtime sequence of a single unit of code, typically a function or method. It does this simply by counting branch points in that unit of code. Cyclometric complexity is a proxy for how hard a component is to inhabit and to 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.

Also worth measuring are deployment metrics. The DORA metrics should be treated as a baseline for measurement: deployment frequency and lead time for changes as measures of change velocity, balanced against change failure rate and failed deployment recovery time as measures of the stability of the system through change. See How good is good enough?.

But concrete metrics only get you so far in an assessment of complexity in a system. A codebase can show low efferent coupling, no dependency cycles, and healthy cyclomatic complexity throughout, and still be built on three inconsistent models of the same domain, for example. Many forms of complexity cannot be quantified automatically. They require manual, qualitative judgment.

Ultimately, whether a system is considered to be simple or not, is a matter of opinion. Even quantifiable metrics need judgment applied in their interpretation. Dependency graphs, for example, reveal both bad and good developmental coupling, and don’t distinguish between them. Whether the coupling in a system takes the forms along which the system actually changes is a judgment that requires deep understanding, not only of the solution, but also of the problem — it requires both architecture and domain expertise.

It is RECOMMENDED to choose a small set of concrete metrics as proxies for an overall measure of complexity, to measure these things continuously, as using them as the basis for an early warning system that alerts for compounding complexity.

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