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 MUST be a foundational part of every application’s architecture.
Feature flags decouple deployment from release, which is fundamental to supporting continuous integration and continuous deployment. Deployment is an engineering concern — how and when code moves from your repository to production infrastructure. Release is a business concern — when customers gain access to new functionality. These are distinct activities with different cadences and decision-makers.
Without feature flags, code and features are tightly coupled. Merging code to
main means deploying to production which means releasing to users — all
simultaneously. This forces difficult choices: either hold code until you’re
ready to release (leading to long-lived feature branches and integration hell),
or release incomplete features before they’re ready.
With feature flags, you decouple these steps. Code can be deployed to production
while the feature remains disabled for (some or all) users. This enables teams
to merge frequently into main, deploy continuously, and release strategically
based on business readiness, not engineering constraints.
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 rollouts, 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 stage rollouts 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.
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.
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).