TS-5: Application architecture
This technical standard sets out principles and best practices for designing and implementing standalone software applications.
An "application" is defined here as a software component that is deployed in a single operation, is usually maintained in a single repository, and is designed to perform a specific function or set of functions. Many applications will be designed to run in a single process (but there are plenty of applications that are designed to spawn multiple processes for performance reasons). An application may be a standalone executable, a library, or a service or microservice within a larger distributed system.
In client-server systems such as web applications, the client-side application may specialize in only the user interface, while the server-side application specializes in data processing, business logic, and state persistence. GUI applications tend to require a specialist architecture, which is outside of the scope of this technical standard. See TS-18: Web GUIs for guidance on designing client-side web applications.
Related technical standards include TS-2: Software design qualities, which describes general software architectural design best practices and standards; and TS-7: Code design, which covers concerns such as code formatting and low-level design patterns.
See also TS-46: Distributed data and caching, which is an important consideration in application architecture.
Horizontal layers
Applications SHOULD adopt a layered architecture. A layered architecture involves organizing code into conceptual layers that each represent distinct concerns.
An excellent model for separating concerns into layers is provided by Eric Evans in his "blue book" on domain-driven design. The following image is taken from that book.

Evans proposed four conceptual layers in an application’s architecture:
- The user interface or presentation layer is responsible for showing information to the user and interpreting the user’s commands. (The "user" may be any external actor, whether a person or another computer process.)
- The application layer coordinates the application’s jobs. This is a thin layer that does not contain any business rules, but which exists to coordinate tasks requested by users via the UI layer above, delegating much of the work to services or objects in the domain layer below. The application layer may manage state related to the progress of operations ("ui state"), but should not manage state related to domain objects ("application state").
- The domain or model layer is where the business domain or problem space is modeled as a series of interconnected objects, using object-oriented programming constructs. The domain layer captures business rules and logic, and manages the state of business entities (though the technical details of persisting domain state is delegated to the infrastructure layer).
- The infrastructure layer provides general technical capabilities that support the higher layers. For example, abstractions of the database access layer, file system access, and network communication protocol should be implemented in the infrastructure layer. Interfaces for messaging services, logging tools and monitoring services, and other external systems, are other candidates for inclusion in an application’s infrastructure layer.
state".
A key constraint of domain-oriented architecture is that software components within each conceptual layer depend only on components in the layers below it. The objective of this constraint is to isolate the domain model from the rest of the application code.
Separating the domain layer from the infrastructure and user interface […] allows a much cleaner design of each layer. Isolated layers are much less expensive to maintain, because they tend to evolve at different rates and respond to different needs.
– Eric Evans
Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)
The following is an iteration on the domain-based layered architecture. It uses the same four conceptual layers, but with different names and one additional layer. This naming convention is RECOMMENDED by this technical standard:
- I/O: Conceptually equivalent to the UI layer in domain-based architecture, the I/O layer is responsible for capturing input from users (or other clients) and returning responses. This layer parses incoming messages, validates input data, and maps user requests to commands, event handlers, or services in the application’s kernel, below it. The I/O layer may handle communication with the outside world via multiple different protocols and technologies. For example, an API application may support I/O via a combination of HTTP, WebSockets, gRPC, and message queues. An application may also support a command line interface (CLI) alongside a graphical user interface (GUI), for example.
- Kernel: The kernel is the core of the application. All input to, and all output from, the application should pass through this layer. The application kernel controls everything that happens in the application, just as an operating system kernel controls everything that happens in an operating system. Conceptually, the kernel is equivalent to the application layer in domain-driven design. The kernel should be relatively thin, acting mostly as a mediator between the I/O handlers (in the layer above) and the domain services and objects (in the model layer below). The application kernel should not hold state related to the domain model, but it may hold UI state that reflects the progress of tasks being undertaken for users.
- Model: This layer encapsulates a model of the application’s business domain or problem space. (Models, in this context, are not data models, such as those generated by ORM tools; they are domain models, which are a representation of the business domain and its rules.) The domain model is coded using object-oriented constructs, which encapsulate business rules and represent entities, events, and other concepts from the real world. This layer is also responsible for managing business state (though responsibility for persisting this state is delegated to external systems such as databases).
- System: The system layer is analogous to the infrastructure layer in domain-driven design. It provides abstractions to the runtime platform of the application, and to components in the wider system in which the application operates – things like databases, message queues, other services running on the same local network, as well as external services running on remote systems.
- Vendors: This is an optional layer, which is not present in the horizontal layers of domain-driven design. This layer provides facades to third-party libraries that are installed locally in development environments and bundled with the production instances of the application. (Such dependencies are commonly managed using a package management system, but this is not a requirement.) It may also provide facades to third-party APIs and other external services. This allows application code, in the layers above, to import the facades rather than the underlying dependencies, making it easier to replace vendor (third-party) components with alternative implementations or alternative services.
Conceptually, the first four layers map directly to the four layers of domain-driven design architecture:
- UI, presentation → I/O
- application → kernel
- domain, model → model
- infrastructure → system
The fifth layer – called "vendors" – is a pragmatic addition that recognizes the reality that modern software applications tend to have a very high dependence on application frameworks, third-party libraries, and remote web services. The inclusion of this layer is intended to encourage application developers to reduce coupling on vendor-specific libraries and third-party APIs by abstracting them behind facades. The interfaces of those facades are defined by the application and captured in the vendors layer.
As in domain-driven design, components within each layer SHOULD depend only on components in the layers below them:
In domain-driven design, layers may be "skipped" in communication between components. This technical standard adds an extra constraint on the design of application kernel layer: all input to, and all output from, an application should pass through its kernel. Thus, all communication between the I/O layer and the deeper layers of the system MUST pass through the kernel.
This constraint is intended to make the application kernel the central point of control for the whole application. It enforces the requirement that the kernel layer singularly defines everything that the application does.
One of the advantages of this design is that the layers are stacked in alphabetical order. If the layers are represented as directories in the codebase, they will be listed in alphabetical order in most filesystems by default, and so the visual representation of the layers in the code will match their conceptual positioning in the architecture.
.
├── IO
│ └── ...
├── Kernel
│ └── ...
├── Model
│ └── ...
├── System
│ └── ...
└── Vendors
└── ...Vertical slices
An extension of this layered architecture adds vertical slices through the top three layers, organizing the main application-specific code into modules. For example, an application may be composed of three modules: users, products, and orders. Each module has its own I/O and application kernel, and also its own model that represents a subdomain of the overall domain.
Critically, the modules SHOULD NOT be allowed to call each other directly. Instead, modules should communicate indirectly (and ideally asynchronously, using messages or events) via a channel provided by the system layer.
This design constraint reduces coupling between modules, making it easier to maintain and scale an application. For example, it becomes possible to incrementally extract modules into separate services, so decomposing a system from a modular monolith to a distributed service-oriented design.
The filesystem for a modular monolith’s source code might look like the below scheme. The filesystem reflects the conceptual architecture, with each module encapsulated in its own directory, and the horizontal layers of the architecture represented as subdirectories within each module. The global layers – system and vendors – are represented as top-level directories, extracted from the modules.
.
├── Modules
│ ├── <ModuleA>
│ │ ├── IO
│ │ │ └── ...
│ │ ├── Kernel
│ │ │ └── ...
│ │ └── Model
│ │ └── ...
│ ├── <ModuleB>
│ │ ├── ...
│ │ └── ...
├── System
│ └── ...
└── Vendors
└── ...Feature flags
Feature flags (aka. feature toggles) MUST be a foundational part of the architecture of every non-trivial software application.
One of the biggest challenges in delivering software updates to users incrementally through continuous integration and continuous deployment is that release is required to be decoupled from deployment. Changes in code and configuration are continuously integrated and deployed to production systems, but the activation of the behaviors that those changes enable happens separately.
The ultimate aim of continuous deployment is to be able to ship, at (almost) any point in time all current work-in-progress without unfinished features being made accessible to users.
It is strongly RECOMMENDED to separate deployment from release because they are two distinct concerns. Deployment — how and when code moves from your repository to production infrastructure – is an engineering concern . Release — when customers gain access to new functionality — is a business concern . These are distinct activities with different cadences and decision-makers.
Decoupling release from deployment means that features are decoupled from code. Code can be deployed to production while a feature remains disabled for some or all users. This enables teams to merge frequently into a shared trunk, deploy continuously, and release strategically based on business readiness.
There are significant costs to not decoupling release from deployment. The costs show up in the excessive overhead involved in planning and managing code integrations and deployments/releases. Different choices are made in the delivery process. Code is either held back until its ready for release, leading to long-lived feature branches, integration hell, and big-bang releases; or releases are delayed while you wait for a planned feature that’s taking longer to develop than expected. Alternatively, features may be delivered to users before they are fully complete and before they offer real value.
Decoupling releases from deployments allows for shorter, regular release cadences, and so a more consistent and predictable flow of value delivery to users. Features are also given space to be developed at the appropriate pace for their level of complexity, resulting in fewer corners being cut to catch release trains and lower accumulation of technical debt. Developers can work on a mix of long- and short-term projects simultaneously.
How do we decouple release from deployment? Broadly, there are two approaches.
- Bottom-up implementation.
- Feature flags.
Bottom-up implementation involves applying changes first in the lower tiers of the application architecture — the infrastructure, domain, and services layers. Only when a new behavior is ready for release is it integrated into the application’s user interface.
Feature flags are implemented in the UI layer, commonly through a middleware system. Simply, feature flags are conditional logic that checks for a condition — typically the existence of a configuration variable, usually an environment variable in hosted software — before invoking the requested service. Thus, operations can be toggled on and off simply by adjusting application or environment config.
A combination of both bottom-up implementation and feature flags may be useful in some scenarios.
Of the two approaches, feature flags offer the greatest flexibility. Unlike the bottom-up integration approach, feature flags do not impose constraints on the sequence in which code changes are made. They afford developers more flexibility.
Feature flags dramatically improve development velocity. Every feature can be
developed behind a flag, allowing small, focused pull requests that are easy to
reason about and review. There is no need for long-lived feature branches,
complex Git workflows, or painful rebasing. Instead, developers open small PRs,
merge them directly into main, and deploy them continuously. The feature
toggle separates the engineering cadence (daily deployments) from the release
cadence (whenever the business is ready).
Feature flags also support advanced devops strategies including canary channels, staged roll-outs, and A/B testing.
Start simple. To implement feature flags, start with something like a configuration file or database table that maps feature names to the release state. Better, this configuration can be done in environment variables, which allows feature releasing to be differentiated by environment.
As your needs grow — for example, if you want to use feature flags to manage staged roll-outs or A/B testing – you can migrate to a specialist feature flag system. But the fundamental pattern remains the same. Ultimately, all a feature flag is a piece of code written in a conditional check.
if (isFeatureEnabled("my-cool-new-feature")) {
// New feature logic.
} else {
// Previous behavior or fallback.
}Dependencies
Dependence on third-party libraries and services SHOULD be kept to a minimum. Each dependency adds complexity, potential security vulnerabilities, and maintenance overhead.
Excessive dependencies can lead to "dependency hell", where conflicting or outdated dependencies make it difficult to build, test, and deploy an application. They also make it harder to roll back to previous versions or to check out and rebuild any earlier version of the application.
Drinking game for web devs:
(1) Think of a noun
(2) Google<noun>.js
(3) If a library with that name exists — drink– Shay Friedman
It’s not uncommon for a new Ruby on Rails project to install a hundred or so
dependencies out-of-the-box, before you’ve even written a single line of
application code. Creating a new React app (npx create-react-app my-app)
similarly results in a large number of dependencies being installed by default –
hundreds of modules, accounting for hundreds of MBs of disk space.
Every dependency you add gives some degree of control over your application to a third party – someone you don’t know and whom you will probably never meet. This has all sorts of maintenance and security implications.
In 2016 a prolific developer of JavaScript libraries suddenly removed them all
from the NPM repository. One of which, named left-pad, was a tiny library used
by thousands of other libraries, including mainstream tools and frameworks like
Babel and React. The removal of this tiny library broke thousands of builds
around the world, causing widespread disruption.
In 2024 a critical vulnerability (CVE-2024-3094) was discovered in a widely-used compression library called XZ. The library was used by, among others, the OpenSSH daemon, meaning there was a potential backdoor into many of the world’s servers via a supposedly secure data transport protocol.
Each dependency you add — including everything bundled with your application framework — MUST be carefully considered and justified. Before adding a dependency, verify its provenance, examine the dependency tree, and review its code and tests.
Keep dependencies updated. Incremental updates are much easier to manage than allowing them to accumulate. And regularly audit dependencies for security vulnerabilities using automated tools. Vulnerabilities may surface after you’ve installed them.
These checks are more critical for components that will become part of your application’s runtime, than it is for development and operations tools that are used only during the build and test phases of your development workflow. But even these tools should be carefully considered. After all, they have access to your source code and your build environment, and they represent an increased attack surface for supply-chain attacks.
Application frameworks
Application frameworks are dependencies that provide a skeleton structure for building applications. Common examples include Express.js, Django, Ruby on Rails, and Spring.
When adopting a framework, align your application architecture with the patterns and best practices that the framework enforces. For example, if using a Model-View-Controller (MVC) framework, structure your application around MVC principles.
Choose a framework based on your application’s specific requirements, not on its feature count, popularity, or what colleagues are using. Select a framework you understand well. Prefer frameworks with a proven track record of stability and longevity over newer alternatives.
Vendor facades
To minimize coupling on third-party frameworks and libraries, applications SHOULD implement facades for all vendor dependencies. These facades SHOULD be implemented in a dedicated vendors layer of the application’s architecture.
This approach isolates your codebase from external library implementations. If a third-party library’s API changes or must be replaced with an alternative, only the facade requires updating. The rest of your application remains unchanged, as it depends solely on the facade’s interface.
Ideally, even framework components should be surfaced in an application through facades, the interfaces to which are specified by the application. This has the effect of better decoupling application code from any particular framework. But in practice this is not always practical. It is very difficult, for example, to build a React GUI without strong coupling to the React framework.
Graceful degradation
Applications MUST fail gracefully when external dependencies are unavailable or performing unacceptably (eg. high latency).
Graceful degradation means your application continues to function — albeit with reduced capability — when a dependency fails or becomes unreliable. Rather than crashing or presenting broken functionality to users, the application should detect the failure and adapt its behavior appropriately.
For example, if a feature depends on an external API that becomes unavailable, your application might disable that feature temporarily while preserving core functionality. A recommendation engine could fall back to a simpler algorithm. A real-time messaging service might queue messages locally until the connection is restored. And a caching layer can serve stale data when a downstream service is slow, allowing users to proceed rather than waiting indefinitely. And so on.
Implementing graceful degradation requires anticipating failure modes for each critical dependency. Define what the minimal viable behavior is when that dependency fails, and implement fallback logic accordingly.
Set reasonable timeouts to prevent users from being stuck waiting. Log failures so your operations team can investigate. And test failure scenarios regularly — do not assume graceful degradation code works if it has never been executed during testing or in production.
Favor proven technology
Prefer technology with a long track record of production use over newer alternatives, and this applies to every technology choice an application makes — not only application frameworks, but databases, languages, protocols, runtimes, and message brokers as well. A technology that has survived years of real-world use has already had its rough edges found and fixed by others, has a mature ecosystem of tooling and documentation, and has a larger pool of engineers who already know it.
Old technologies are "sharks" — not dinosaurs. A shark is a very old technology, but it’s still around because it works extremely well. It’s not a living fossil, it’s a fossil that’s still alive because it’s really good at what it does.
– Dan McKinley
This is not a call to reject new technology outright. It is a requirement that newer alternatives clear a higher bar of justification before displacing an established, working technology. Replace a proven technology only for a concrete, well-evidenced reason — a hard scaling limit, a missing capability the domain genuinely requires, or an unacceptable cost — not because the alternative is fashionable or because a competitor uses it.
Triggers for adopting new technology
Introducing a new technology has a cost — the learning curve, the operational burden of running one more system, and the long-term maintenance commitment. That cost SHOULD be weighed against a concrete problem, not incurred on spec. Reserve new technology adoption for cases where an existing part of the stack has hit a real limit:
- Cost. An existing solution has become prohibitively expensive to run or license at the application’s current or projected scale.
- Scale. An existing solution has hit a hard technical ceiling — throughput, latency, data volume — that reengineering effort cannot resolve.
- New requirements. A new customer or business requirement cannot be met by any technology already in the stack, however it is reconfigured.
Each of these triggers raises an explicit build-vs-buy question: extend or reconfigure what you already run, build the missing capability in-house, or adopt a new off-the-shelf technology or vendor service. Building in-house avoids adding an external dependency but commits the team to owning the capability’s maintenance indefinitely; buying (or adopting an open-source alternative) shifts that maintenance burden outward at the cost of the dependency risk described earlier in this section. Make the choice explicit and record the reasoning — do not let a new technology enter the stack by accretion, one convenient library at a time, with no one having weighed the alternative.
Technology selection criteria
Weigh a technology choice against a consistent set of criteria that deliberately mixes technical and business factors, rather than on a single axis such as raw performance or popularity:
- Performance and scalability. Does it meet the application’s current and foreseeable throughput, latency, and data-volume requirements?
- Cost. What is the total cost of ownership — license or usage fees, infrastructure to run it, and the engineering time to operate and maintain it?
- Reliability. What is its track record for uptime and correctness, and what support or SLA backs it when something goes wrong?
- Support and maturity. Is it actively maintained, with a healthy community or vendor behind it, and documentation and tooling mature enough that the team is not the first to hit a given problem?
- Flexibility and interoperability. Does it integrate cleanly with the rest of the stack, and does it avoid locking the application into a proprietary format or protocol that would be costly to migrate away from later?
No technology will score best on every criterion. The point of naming them explicitly is to make the trade-off visible and deliberate, rather than implicit in whoever happened to propose the technology.
Adoption process
Adopt a new technology only once the team fully understands why it is needed, what it does, and how it works — not on the strength of a conference talk or a vendor’s marketing. Where that understanding is not yet there, invest in building it before committing production traffic to the technology.
Pilot new technology at a small scale before committing to it broadly. Run it against a narrow, low-risk slice of real production traffic or data first, and use that pilot to validate the technology’s operational characteristics — failure modes, performance under real load, and the operational burden of running it — before extending it to critical paths. A technology that looks good in a proof-of-concept can still fail this test once it meets production conditions.
Larger organizations SHOULD establish a structural process for evaluating and introducing new technology — for example, an architecture review — rather than leaving each team to adopt independently. This keeps the technology selection criteria above applied consistently, and keeps the organization’s technology portfolio from fragmenting into a different stack per team.
Design for change: prefer open standards
Where a choice exists between a proprietary technology and one built on an open standard, prefer the open standard, and prefer an implementation that is free and open source (FOSS) over a proprietary one. This is a complement to the Vendor facades pattern described above: facades mitigate lock-in mechanically, by containing the blast radius of a vendor-specific dependency to one layer of the codebase, but they do not eliminate the underlying risk that the facade wraps a proprietary format, protocol, or API that has no alternative implementation. An open standard, by contrast, can in principle be implemented by more than one vendor, which keeps a migration path open even where no facade was written.
This preference is not absolute. A proprietary technology may still be the right choice where it offers capabilities no open alternative provides, or where the switching cost it introduces is one the organization has explicitly accepted. But the default, all else being equal, SHOULD favor the option that keeps the application portable.
Application frameworks
Application frameworks — such as Laravel, Django, Ruby on Rails, and Spring — provide a pre-built skeleton for structuring applications. They bundle together conventions for routing, data access, configuration, testing, and other common concerns, giving teams a shared foundation on which to build.
Frameworks as platforms
One of the strongest arguments for adopting a framework is standardization. When multiple applications within an organization are built on the same framework, the framework becomes a shared platform. Developers can move between projects with minimal ramp-up time. Common patterns for logging, error handling, authentication, and deployment are consistent across the organization’s application portfolio. Shared tooling, training materials, and operational knowledge compound over time.
In this sense, a well-chosen framework reduces the total cost of ownership across a suite of applications, not just a single one.
Decoupling from frameworks
A common mistake is to allow framework-specific code to permeate an entire codebase. Controllers inherit from framework base classes, services import framework utilities directly, and domain logic depends on framework-provided abstractions for things like database access, event dispatching, and configuration. Over time, the application becomes so tightly coupled to the framework that migrating to an alternative — or even upgrading to a new major version of the same framework — becomes a prohibitively expensive undertaking.
To mitigate this, application code SHOULD interact with framework components through facades — thin wrappers whose interfaces are defined by the application, not by the framework. As described in the section on dependencies, this practice applies to all third-party vendor code, but it is especially important for frameworks because of how deeply they tend to integrate into an application’s architecture.
For example, rather than importing a framework’s HTTP request object directly into your application kernel, define your own request interface and write a small adapter that translates the framework’s request into your application’s representation. Your kernel and model layers then depend only on interfaces you control. If you later swap the framework, you update the adapters — not the core of your application.
The same principle applies to framework-provided services such as mailers, queue workers, logging, and cache managers. Each should be accessed through an application-defined interface, with the framework implementation hidden behind a facade in the vendors layer.
In practice, it is not always feasible to fully decouple from a framework. GUI frameworks like React, for instance, are inherently invasive — your component code is framework-specific code. But for server-side application frameworks, the investment in facades pays for itself many times over in reduced migration cost and improved testability.
Frameworks as destinations, not starting points
Application frameworks are too often treated as starting points. A team picks a popular framework and then tries to fit the application into whatever structure and conventions the framework imposes. This frequently leads to contorted designs — business logic forced into framework-prescribed patterns, unnecessary abstractions introduced to satisfy the framework’s opinions, and tight coupling to framework internals that makes future migration away from the framework painful.
A better mental model is to think of a framework as a destination, not a starting point. Consider: if you were building the application from scratch, with no framework at all, what design decisions would you make? How would you organize your code? What conventions would you establish for routing, persistence, configuration, and error handling? Over time, a structure would naturally emerge from those decisions and design constraints. That emergent structure is your framework.
An off-the-shelf framework SHOULD be chosen because it closely matches the design you would arrive at independently. The framework should feel like a formalization of decisions you have already made, or would naturally make, rather than a set of constraints imposed from outside.
Do not try to fit an application into an ill-fitting framework. If a framework’s conventions conflict with your application’s domain requirements, architectural constraints, or performance characteristics, that framework is the wrong choice — regardless of its popularity or the size of its community. A simpler, less opinionated framework (or no framework at all) may serve you better than a feature-rich one that fights your design at every turn.
Services
Service-oriented architecture (SOA) is an approach in which a software application is decomposed into discrete services, each encapsulating the code and data required to perform a complete business function. Each service is a self-contained application in its own right.
Services communicate with each other through well-defined interfaces, promoting loose coupling. Each service can be called with minimal knowledge of how it is implemented internally.
Developing business capabilities as services, rather than as features within a monolithic product, enables organizations to compose customer-facing applications on top of reusable networks of capabilities.
Microservices
Microservices take service orientation to its extreme. Each microservice is small, autonomous, aligned with a single bounded context, and — critically — independently deployable. Independent deployability is the defining characteristic: each service can be updated and released on its own schedule, without coordinating deployments with other services.
But microservices are harder to implement well than many teams realize. The real challenge lies not in the individual services but in the design of the interfaces between them. These interfaces must be well-defined up-front, and kept stable and non-breaking over the long term, since independent deployability and loose coupling depend on it.
Bounded contexts and service interfaces
In large systems, different parts of the domain will model the same real-world concept differently. A "book", for example, means something different in the context of a shop than it does in an order fulfillment system. When different teams are responsible for different parts of the system — and especially when those parts were built at different times, during which the domain model evolved — it is unrealistic to maintain a single unified data model. Instead, each area of the system defines its own consistent model within a bounded context.
Service boundaries and organizational boundaries are not independent variables. Where teams are drawn tends to determine where the interfaces end up, whether or not that was the intended design.
Organizations [that] design systems […] are constrained to produce designs [that] are copies of the communication structures of these organizations.
– Melvin Conway (1967)
The practical implication is that a service decomposition which cuts against the team structure will be fought by the organization at every turn. Either align the teams to the intended architecture, or expect the architecture to drift toward the teams.
The interfaces between services — the protocols and data structures used in their communication — should be treated as their own distinct bounded context.
The language/protocol of the information that we use to communicate between services is a separate bounded context.
– Eric Evans
This means that translation layers are needed at service boundaries. Services must transform domain concepts as they cross from one bounded context to another, ensuring that each service’s internal model remains consistent and self-contained.
Premature decomposition
Do not extract services (micro or otherwise) too early. Until service APIs are stable and well-understood, keep tightly coupled or volatile components together — ideally in the same codebase sharing the same deployment pipeline.
The modular monolith pattern, described in the section on vertical slices, provides a natural stepping stone. Modules can be designed with clear boundaries and indirect communication from the outset, then extracted into independent services incrementally once their interfaces have stabilized.
Premature decomposition locks in unstable interfaces and creates distributed systems problems (network latency, partial failures, eventual consistency) before you have even validated the domain boundaries.
Reactive systems
Services can be modeled as state machines, which means that events produce changes in state, and that is all. The state of a service is the cumulative result of every event it has processed, making it possible to replay the timeline of events to restore state, diagnose production incidents, or reproduce behavior in test environments.
This pattern externalizes accidental complexity and keeps each service focused on domain-level concerns.
This is the essence of a reactive system — a system that mutates domain models in response to events or messages. It is a simple and powerful model, and it is how serious infrastructure (including relational databases) works internally.
Reactive systems require reliable, durable messaging infrastructure. This becomes the primary failure point. Three constraints are critical:
- Ordering: The order of messages MUST be preserved. If messages arrive out of order, services will end up in incorrect states. The system must also account for messages that are lost in transit.
- Determinism: The state of a service can only be mutated via messages. There can be no backdoors — no alternative paths for state changes that bypass the message stream.
- Durability: The messaging infrastructure must be highly available. If it goes down, services cannot process events, and the system stalls.
Reactive design decouples services in both time and space. A service does not need to know where another service is located, or whether it is currently running. It only needs to publish or consume messages. This pairs naturally with microservices, since each service maintains its own unique, unshared state (and typically its own database).
CQRS
Command-Query Responsibility Segregation (CQRS) is a complementary pattern in which the part of the system that handles commands (writes and mutations) is separated from the part that handles queries (reads).
This separation aligns well with reactive and event-driven architectures. Commands produce events that mutate service state, while queries read from optimized projections of that state.
CQRS allows the read and write sides to be scaled, optimized, and evolved independently.
Pragmatic acceptance of antipatterns
The patterns described throughout this standard are heuristics, not laws. Occasionally the clean solution to a problem does not exist, or costs far more than the problem justifies, and the pragmatic choice is to knowingly accept a design that this standard would otherwise call an antipattern — for example, coupling two services to a shared datastore to solve a throughput problem neither could solve alone within budget.
Accepting an antipattern MUST be a deliberate, evidence-driven decision, not a default born of inattention. Before accepting one:
- State the antipattern explicitly. Name what rule is being broken and why the clean alternative was rejected — too expensive, too slow to deliver, or blocked by a constraint outside the team’s control.
- Bound the risk. Identify what could go wrong as a direct result of the coupling or shortcut, and what would have to be true for it to cause an incident.
- Record the decision. Capture the reasoning somewhere durable — an RFC, an architecture decision record, or equivalent — so a future engineer encountering the antipattern understands it was chosen, not missed.
- Revisit periodically. An accepted antipattern is a liability the team carries deliberately, not a permanent exemption. Revisit the decision as the system’s scale or requirements change, and remove the antipattern once a clean alternative becomes affordable.
Know when to use patterns, and know when to use antipatterns. A team that understands why a rule exists is better placed to judge when breaking it is the right call than a team that follows every pattern by rote.
Decommissioning
Every service has a lifecycle that eventually ends. A service that is replaced by a newer solution, or whose business capability is no longer needed, MUST be deliberately decommissioned — not simply left running indefinitely, and not switched off without a plan. An unplanned shutdown risks data loss and breaks any client that still depends on the service.
Decommissioning is a distinct phase of a service’s lifecycle, separate from the day-to-day release cadence, strategies, and rollback mechanics covered in TS-10: Releasing, which govern how a live service ships new versions of itself. Decommissioning instead covers how a service’s life ends, and it requires its own plan.
Planning a decommissioning
Treat decommissioning a service with the same rigor as launching one. Plan for the following before switching anything off:
- Replacement. Identify what, if anything, replaces the service’s capability. A decommissioning without a replacement is a deliberate removal of functionality, and should be confirmed as such with stakeholders before proceeding.
- Data retention. Decide what happens to the service’s data — archived to cold storage for compliance or auditing purposes, migrated into a replacement service’s data model, or deleted outright — and confirm the decision satisfies any regulatory or contractual retention obligations before deletion becomes irreversible.
- Client migration. Identify every consumer of the service — other services, client applications, batch jobs, third parties — and migrate each one to the replacement (or to no longer depending on the capability at all) before the service is switched off. Do not decommission a service that still has active consumers.
- Shutdown. Switch the service off only once the prior three steps are complete: the replacement is live, data is retained or disposed of per the decision above, and no client still depends on the outgoing service.
Communicating a decommissioning
Announce a planned decommissioning to all known consumers well in advance of the shutdown date, with enough lead time for them to complete their migration. Where the service exposes a public or semi-public API, treat this the same way as a breaking API change: a deprecation notice, a sunset date, and — where feasible — a period during which the outgoing service continues to run alongside its replacement so consumers can migrate on their own schedule rather than being forced to cut over on a single date.
Confirm, rather than assume, that traffic to the outgoing service has dropped to zero before the final shutdown. A consumer that missed the deprecation notice will surface as a production incident the moment the service actually stops responding.
Programming languages
A programming language is a technology choice like any other covered in dependencies, and the same defaults apply: favor a proven, well-understood language over a fashionable one, and weigh a new language against the Technology selection criteria rather than adopting it on the strength of a blog post or a colleague’s enthusiasm. But a language choice carries a weight the other criteria in that section don’t fully capture, because a language is not merely consumed by an application — it shapes how the people building the application think about the problem.
A language that doesn’t affect the way you think about programming is not worth knowing.
– Alan J. Perlis
Epigrams on Programming (1982)
Perlis’s point cuts both ways. A language worth learning is one that changes how you think — but a language chosen for a production system is a standing decision to think that way, in that system, for as long as the system lives. The learning value of a paradigm and its cost in a codebase are different questions, and this section addresses the second.
Paradigm fit over paradigm preference
Most mainstream languages support more than one programming paradigm — object-oriented, functional, procedural — to varying degrees, and the choice of which paradigm to lean on within a codebase matters more than which language nominally "is" object-oriented or functional. Neither paradigm is categorically superior; each fits some problems better than others.
Object-oriented design tends to fit problems that are naturally modeled as interacting entities with identity and mutable state over time — a domain model of orders, accounts, and inventory, for example, where an object’s identity persists across state changes and the operations that matter are verbs performed on that identity. See TS-7: Code design for detailed guidance on object-oriented design.
Functional programming tends to fit problems that are naturally modeled as data transformations — parsing, validation pipelines, stream processing, and anything where the correctness of the output depends on composing pure functions rather than tracking the state of long-lived entities. Immutability and the absence of side effects make functional code easier to test and reason about in isolation, at the cost of requiring different idioms for anything that does need to manage state or sequence effects over time.
In practice, most production codebases mix paradigms: an object-oriented domain model with a functional core for data transformation logic, or a functional language reaching for a small amount of mutable state at its edges. Choosing a language SHOULD account for how well it supports the paradigm mix the problem actually calls for, rather than treating "our language is object-oriented" or "our language is functional" as a decision that settles every subsequent design question. A language that supports only one paradigm well forces every problem into that paradigm’s shape, whether or not it fits.
Type system
A language’s type system is one of the more consequential choices bundled into the choice of language, and it is easy to underweight because its costs and benefits are both deferred — paid or earned long after the initial choice is made.
Static typing catches a class of defects — passing the wrong shape of data, calling a method that doesn’t exist on a given type — at compile time rather than in production, and it lets tooling provide accurate autocomplete, refactoring, and navigation. This benefit compounds with codebase size and team size: the larger and longer-lived the system, and the more engineers who will touch code they didn’t write, the more that upfront cost pays for itself. Dynamic typing trades that upfront cost for faster initial iteration, at the cost of pushing type-related defects to runtime and relying more heavily on test coverage to catch what the compiler otherwise would.
Neither is a universal default. A short-lived script or a small, single-owner tool may never accrue enough complexity for static typing’s benefits to outweigh its overhead. A large, long-lived application maintained by multiple teams over years is exactly the case static typing was built for. Where a dynamically typed language is chosen for the latter case, a gradual typing layer — TypeScript over JavaScript, type hints over Python — SHOULD be adopted to recover some of static typing’s benefit without a full rewrite. See TS-36: ECMAScript (JavaScript/TypeScript) and TS-35: Python for language-specific guidance.
Concurrency model
How a language represents concurrent execution — threads with shared memory, an event loop with cooperative async/await, isolated processes communicating by message passing, or lightweight language-managed coroutines — determines which classes of concurrency defect are possible at all, not merely how hard they are to avoid.
Shared-memory threading gives the most direct access to multiple cores but makes data races and deadlocks possible any time two threads touch the same mutable state without disciplined synchronization. An event loop avoids data races on in-process state entirely, by construction, but serializes CPU-bound work onto a single thread, and a long-running synchronous operation blocks every other unit of concurrent work in the process. Message-passing and isolated-process models trade some raw throughput for eliminating shared mutable state as a source of defects altogether.
Weigh the concurrency model against the application’s actual workload: an I/O-bound service handling many concurrent requests is well served by an event loop; a CPU-bound workload that needs to use multiple cores needs either true parallelism (multiple processes, or a language runtime that supports it) or work distributed to a separate compute layer. Choosing a language whose concurrency model doesn’t fit the workload tends to surface as a scaling problem much later, once the mismatch is expensive to unwind.
Ecosystem depth for the domain
A language’s general popularity is a weak proxy for whether it fits a specific application. What matters is the depth of its ecosystem for the domain at hand — mature libraries for the problem the application actually needs to solve, established patterns other teams have already worked out, and a body of production experience to draw on when something goes wrong.
A language with a small but deep ecosystem in the relevant domain — numerical computing, systems programming, data engineering — can be a better fit than a more popular general-purpose language with only shallow, third-party support for that domain. Conversely, a niche language with no meaningful ecosystem for the problem at hand imposes the cost of building foundational tooling in-house before any domain work can start. Weigh this against the Favor proven technology guidance above: a language can be a long-proven, mature technology in general while still being a poor fit for a specific domain it was never designed around.
Runtime and deployment characteristics
A language’s runtime shapes the operational profile of everything built in it — startup time, memory footprint, packaging and distribution, and deployment target compatibility. A language with a slow cold start is a poor fit for a workload that scales via short-lived, frequently-spun-up instances, such as a serverless function invoked per request. A language whose runtime requires bundling a large managed runtime or virtual machine complicates distribution to constrained environments such as CLIs, embedded targets, or edge compute.
These characteristics rarely change the choice of language on their own, but they SHOULD be checked explicitly against the target deployment environment before committing — a language chosen purely on the merits of its syntax or ecosystem can turn out to be a poor operational fit once it reaches production.
Interoperability with the existing stack
Where an application will run alongside, call into, or be called by other systems already built in a particular language, that existing language has a gravitational pull that SHOULD be weighed explicitly, in the same terms as the Favor proven technology guidance above. A new language introduces a second set of tooling, a second set of idioms, and a second thing every engineer on the team eventually needs to be able to read, even if they don’t write it day to day — the same conceptual disunity that TS-2: Software design qualities warns against under cohesiveness.
This does not mean a polyglot stack is never justified. A component with requirements the existing language genuinely cannot meet — a hard performance ceiling, a memory-safety requirement per TS-52: Security and secrets management, or a domain-specific ecosystem with no equivalent in the existing language — can justify crossing that boundary. But the bar is the same one [Triggers for adopting new technology] sets for any new technology: a concrete, evidenced limitation in what’s already in use, not a preference for the new language’s syntax or paradigm.
Hiring and team fit
The available pool of engineers who already know a language, and the learnability of the language for the team that will maintain it, are practical constraints on language choice, not secondary concerns. A language with excellent technical merits but no engineers on the team who know it, and a thin regional or remote hiring pool to draw from, imposes an ongoing training and hiring cost for as long as the system runs. This cost is real even when the language itself is a good technical fit, and it SHOULD be weighed as part of the Technology selection criteria above, under support and maturity.
References
- Wiggins, A (2017). The Twelve-Factor App. — A set of application-architecture principles for software-as-a-service apps, several of which bear directly on this standard’s horizontal-layers, dependencies, and services content: explicit dependency declaration (Factor II), statelessness and backing services as attached resources (Factors IV and VI), port binding (Factor VII), horizontal scaling via the process model (Factor VIII), and fast startup/graceful shutdown (Factor IX).
- McKinley, D (2015). Choose Boring Technology. — The source for the "sharks, not dinosaurs" framing of proven technology in Favor proven technology.
- Allegro Tech (2024). Ten Years of Microservices at Allegro. — The source for the pragmatic-antipattern guidance in Pragmatic acceptance of antipatterns and the service lifecycle content in Decommissioning.
- PostHog (2023). How We Choose Technologies. — The source for the adoption triggers, selection criteria, and build-vs-buy framing in Triggers for adopting new technology, Technology selection criteria, and Adoption process.
- NOCOMPLEXITY (n.d.). Design Principles for Digital Infrastructure. — The source for the pilot-first adoption process in Adoption process and the open-standards preference in Design for change: prefer open standards.
- Perlis, A J (1982). Epigrams on Programming. — The source for the epigraph in Programming languages.