Architecture / System Architecture / Software Engineering
System Architecture Is the Set of Decisions That Are Expensive to Change
Architecture is less about diagrams than about which decisions are hard to reverse, which qualities the system must protect, and how to keep both visible. Quality attributes, C4 views, dependency direction, ADRs, and fitness functions.
On this page
System design and system architecture overlap, but they answer different questions. Design asks how to solve a particular problem: how webhooks are retried, how a feed is ranked. Architecture asks what structure all of those solutions live inside, and which constraints they all have to respect.
A useful working definition: architecture is the set of decisions that are expensive to change later. The data model, the boundaries between components, how identity and tenancy work, which parts can be deployed independently, and where the system's trust boundaries are. A library choice inside one module is usually not architecture. Whether that module may talk to the database directly usually is.
Start from quality attributes
Functional requirements say what the system does. Quality attributes say how well, and they are what architecture is actually optimizing:
- Availability: what must keep working when components fail.
- Performance: latency and throughput targets, and where.
- Modifiability: which kinds of change must be cheap.
- Security: what must be protected from whom, and where trust changes.
- Operability: how the system is deployed, observed, and debugged.
- Cost: what it is allowed to spend to run.
They conflict. More availability usually costs money and complexity. More flexibility usually costs performance. An architecture is a set of positions on those conflicts, so the most useful thing to write down early is a priority order. "For this product, data integrity matters more than availability, and availability more than latency" is a sentence that resolves a dozen later arguments in advance.
Describe the system at the right zoom levels
One diagram cannot serve every audience. Simon Brown's C4 model separates four zoom levels:
- Context. The system as one box, the people who use it, and the external systems it depends on.
- Containers. The separately deployable or data-storing parts: web app, API, workers, databases, queues.
- Components. The major structural parts inside one container.
- Code. Classes and functions, rarely worth drawing by hand.
The container level is where most architectural decisions are visible:
A good container diagram names each box's responsibility and technology, labels each arrow with what flows over it, and makes the system boundary explicit. Anything that crosses that boundary is an external dependency with its own availability, latency, cost, and data-handling implications. The model API in the diagram is one of those.
Control the direction of dependencies
Within a codebase, the most consequential architectural property is often the direction of dependencies. Code that encodes business rules should not depend on code that talks to specific infrastructure. Infrastructure should depend on the business code, through interfaces the business code defines.
// domain/summarize.ts: business logic, owns the interface it needs
export interface TextModel {
complete(input: { system: string; prompt: string; maxTokens: number }): Promise<string>;
}
export async function summarizeTicket(ticket: Ticket, model: TextModel) {
const text = await model.complete({
system: SUMMARY_INSTRUCTIONS,
prompt: renderTicket(ticket),
maxTokens: 300,
});
return parseSummary(text);
}
// infrastructure/provider-model.ts: depends on the domain interface
export class ProviderTextModel implements TextModel {
constructor(private client: ProviderClient, private model: string) {}
async complete(input: { system: string; prompt: string; maxTokens: number }) {
const response = await this.client.messages.create({ model: this.model, ...input });
return response.text;
}
}The payoff is practical. Business logic can be tested with a fake model, the provider can be changed or wrapped with caching and fallbacks in one place, and the dependency on an external vendor is a deliberate seam rather than an import scattered across the codebase.
Separate reversible decisions from irreversible ones
Not every decision deserves the same care. A useful exercise is to sort decisions by how expensive they are to undo:
| Usually hard to reverse | Usually easy to reverse | | --- | --- | | Core data model and identifiers | Libraries inside a module | | Public APIs and event schemas | Internal service implementations | | Tenancy and isolation model | Caching strategies | | Identity and authorization model | UI frameworks for one surface | | Where data is stored, and in which jurisdiction | Choice of model for one feature, behind an interface |
Spend analysis, prototypes, and review on the left column. Move fast on the right column, and preserve its reversibility by keeping those choices behind interfaces.
Write decisions down
Architectural knowledge decays quickly. Six months after a decision, nobody remembers why the system uses polling instead of webhooks, and someone reverses it, rediscovering the original reason in production.
An Architecture Decision Record (ADR), a format popularized by Michael Nygard, is a short document per significant decision, stored with the code:
# ADR 014: Use Postgres as the delivery queue for webhooks
## Status
Accepted (2026-10-04)
## Context
Webhook deliveries must never be lost and must be auditable per attempt.
Expected volume fits comfortably in a single primary with batching.
The team has no existing broker in production.
## Decision
Store deliveries in Postgres and claim due work with FOR UPDATE SKIP LOCKED.
The table is the source of truth; no separate broker for now.
## Consequences
+ One system to operate; delivery history and dispatch share one source of truth.
- Claim contention and table bloat need monitoring as volume grows.
Revisit if p99 claim latency or autovacuum lag becomes a sustained problem.The format matters less than three properties: it records the context that made the decision reasonable, it states the consequences honestly, including the negative ones, and it says when to revisit. Superseded ADRs stay in the repository, marked as superseded, because the history is the point.
Make the architecture checkable
Diagrams and ADRs describe intent. Over time the code drifts from them unless something checks. The idea of fitness functions, from Building Evolutionary Architectures by Neal Ford, Rebecca Parsons, and Patrick Kua, is to express architectural properties as automated tests:
- Dependency rules: domain code does not import infrastructure; modules only use each other's public interfaces.
- Performance budgets: a key endpoint's latency in a load test, or a page's JavaScript size, stays under a threshold.
- Security properties: no service has public network exposure unless it is on an allowlist.
- Data rules: every table with personal data has an owner and a retention policy.
// architecture.test.ts: a minimal dependency-rule check
import { getImports } from './test-utils/imports';
test('domain code does not depend on infrastructure', async () => {
const violations = (await getImports('src/domain/**/*.ts')).filter(imp =>
imp.target.startsWith('src/infrastructure/')
);
expect(violations).toEqual([]);
});A failing fitness function turns an architectural erosion that would have been noticed in a year into a build failure today.
Architecture for AI features
Products that use language models add a few architectural concerns that are easy to treat as implementation details:
- The model is an external dependency with variable latency, rate limits, cost per call, and behavior that can change between versions. It belongs on the container diagram, behind an interface, with timeouts and a defined degraded mode.
- Prompts, tool definitions, and model versions are configuration that changes behavior. Version them, review changes to them, and record which versions produced each output.
- Evaluation is part of the delivery pipeline. A change to a prompt or model should run against a fixed evaluation set before it ships, the same way a code change runs tests.
- Data boundaries are trust boundaries. Decide explicitly which data may be sent to which provider, and enforce it in code rather than by convention.
Signs the architecture is drifting
- Changes that should be local keep requiring edits in several components.
- The diagrams no longer match what is deployed, and nobody is sure which is right.
- Decisions are being made by whoever touches the code first, with no record.
- Quality attributes are discussed only during incidents.
None of these require a rewrite. They require making the expensive decisions visible again, writing down what is being protected and why, and adding a check where a rule keeps getting broken.
The step-by-step process for designing a single system inside these constraints is in A Working Method for System Design.
Further reading
- Simon Brown, the C4 model for visualizing software architecture (c4model.com).
- Michael Nygard, "Documenting Architecture Decisions" (2011).
- Neal Ford, Rebecca Parsons, and Patrick Kua, Building Evolutionary Architectures.
- Len Bass, Paul Clements, and Rick Kazman, Software Architecture in Practice, on quality attributes and tradeoff analysis.