Contents
Most software systems do not die because of technology. They die because of architectural decisions made in the first months of development.
When a project starts, it feels like the main goal is to ship the first version faster. Deadlines press, investors wait for a demo, competitors are already in production. The architect draws a "simple" diagram. The team writes code they will "refactor later." Three years later, "later" becomes "never" — and every change costs as much as a mini-project.
But the true cost of a system is not MVP delivery speed. It is the product's ability to adapt to new requirements without constant rewrites, data loss, and months of business downtime.
Why do some systems evolve successfully for decades — bank accounting, retail warehouse management, B2B CRM — while others turn into expensive technical debt within a few years? The answer is almost never "an outdated framework." The answer is how cheap each next change remains.
This article is for architects, CTOs, team leads, and senior developers who are designing a system today and do not want to explain to the business in five years why "adding one button" takes a quarter.
Key takeaways
Systems die from coupling, not from Java vs Go. The more modules depend on each other, the more expensive every change becomes — and the faster the team stops keeping up with the business.
Design for change, not for features. "How do we implement feature X?" matters for a sprint. "What happens if X changes in three years?" matters for a decade.
The domain outlives technology. Customer, order, invoice, shipment — they survive framework, database, and UI changes. Architecture should reflect the problem domain, not "controller / service / repository" layers.
Universality is the most expensive trap. Builders-of-everything, EAV tables, and "configurable fields for everything" win in demos and lose in production.
Data matters more than code. Code can be rewritten in six months. Migrating a decade of order history from a broken schema takes years and puts the business at risk.
Architecture must outlive its creators. People leave faster than technologies change. Clear structure and ADRs are cheaper than "clever" abstractions only one person understands.
A system lives long not because it was built perfectly. It lives long because each next change stays cheap enough.
Introduction
Architecture is not choosing microservices vs monolith on a Miro board. It is the accumulated effect of decisions about boundaries, data, dependencies, and trade-offs — decisions that seem small when you make them.
"Let's put order status in the shared entities table for now." "We'll make one CommonService for everything." "We'll add configurable fields — the client will set it up." Each such decision saves a day at the start and costs a week three years later.
Long-lived systems share one property: the cost of change grows slowly. Bad systems do the opposite: past a certain threshold, every new feature requires touching half the codebase, coordinating five teams, and hoping nothing breaks in prod.
You cannot predict what the system will look like in 10 years. But you can build it so change does not become a catastrophe. That is what the rest of this article is about.
Chapter 1. Why Most Systems Do Not Reach 10 Years
The typical project lifecycle
The lifecycle of most enterprise systems is predictable. Treat it not as theory but as an early warning checklist: which stage you are in now, and what comes next if nothing changes.
Stage 1. Startup euphoria
The first months are golden:
- Fast development. A team of five knows all the code. Decisions happen over lunch.
- Simple architecture. One repo, one database, one deploy. The diagram fits on a napkin.
- Few constraints. "We'll do it properly later," "refactor after MVP," "this is temporary."
Velocity is high, bugs are few, the business is happy. It feels like it will always be this way. That is an illusion: simplicity is a result of small scale, not good architecture.
Stage 2. Growing requirements
The business grows — and so does the system:
- New features. Reports, dashboards, a mobile app, partner APIs.
- New user roles. Manager, accountant, warehouse worker, auditor — each with their own permissions and flows.
- Integrations. CRM, accounting, marketplaces, banks, logistics.
- Reports. "We need a pivot by region for the last three years grouped by SKU."
Every new capability is added into the existing structure. If module boundaries were never set, new features are simply "glued" onto what already exists. Coupling grows linearly with every release.
Stage 3. The first workarounds appear
Temporary solutions become permanent:
- An
is_legacy_flowflag on the orders table because "the new process is only for one client for now." - Copy-pasted discount logic in three modules — "we'll extract it into a shared service later."
- A seven-table JOIN for one report — "we'll optimize when we have time."
Workarounds spread faster than refactors. A new developer does not know the history and repeats the pattern. A year later, the "temporary" solution is the only way the system works at all.
Stage 4. Architectural crisis
The symptoms are familiar:
- Any change breaks neighboring parts. You fix VAT calculation — warehouse reports fail.
- Development speed drops. What used to take a sprint now takes a quarter.
- Bug count rises. Regressions after every release. QA cannot keep up.
- The team fears touching code. "We don't know what will break."
At this stage the business is already losing money: competitors ship features faster, and your "simple tweaks" cost as much as projects.
Stage 5. The big rewrite
The classic ending:
- A decision to rewrite the system from scratch — "on a modern stack, properly."
- Lost time and money: two or three years of parallel development, a doubled team, data migration.
- Repeating old mistakes — because the same people under the same pressure again cut corners on boundaries "until the first release."
A rewrite rarely solves the problem forever. It resets the timer — and the cycle starts again if the approach to design does not change.
Euphoria → Growth → Workarounds → Crisis → Rewrite → Euphoria (v2) → ...
↑______________________________________________|
(if principles don't change)
Chapter 2. The Main Principle of Long-Lived Systems
Design for change, not for features
Most developers design a system for current requirements. The product owner brings a user story — the team implements it. Architecture "accumulates" from answers to specific sprint tasks.
But requirements always change. A law that does not affect your product today may become mandatory in two years. A client will ask for an integration nobody thought about. The market shifts — and the "obvious" business model ages out.
Long-lived systems are designed differently: change must stay cheap. Not "perfect." Not "beautiful." Cheap — in time, risk, and number of modules touched.
The wrong question
How do we implement this feature?
That question is correct for a two-day task. It is dangerous as the only architectural question. It leads to decisions like "fastest to add a field to the existing table" — without understanding that the table already serves four unrelated domains.
The right question
What happens if requirements change in three years?
Concrete questions worth asking in design review:
| Question | Why |
|---|---|
| Which parts of the system must change if rule X changes? | Coupling assessment |
| Can we replace module A without touching module B? | Independence |
| Where is the source of truth for this entity? | Single source of data |
| What if a second sales channel / currency / warehouse appears tomorrow? | Extensibility without rewrite |
| How do we roll back this change? | Risk management |
Good architecture is architecture where a typical business change is localized. You add a new document type — one module changes. You change a discount rule — one service changes. Not half the monolith.
Chapter 3. Fight Coupling
What coupling is
Coupling is the degree to which a change in one part of the system forces changes in other parts.
Low coupling: the Billing module changes invoice format — the Warehouse module is unaffected.
High coupling: changing order status requires edits in five services, three database triggers, two cron jobs, and the frontend — because logic is "smeared" across the entire system.
Coupling is the main enemy of long-lived systems. Not legacy code. Not an "old" framework. Coupling is what makes legacy incurable without a rewrite.
Examples of high coupling
Shared tables for the whole system
One documents table for orders, invoices, acts, requests, and internal memos. The difference is a type field. Three years later it has 200 columns, half are nullable, indexes do not work, and the business analyst cannot explain which fields belong to what.
Global state
A singleton AppContext service through which half the modules read "current user," "current organization," "current settings." Untestable. Concurrent requests break each other.
Universal entities
Entity + Attribute + Value — classic EAV. "We'll describe everything through metadata." Flexible in a demo. In prod — JOIN upon JOIN, no typing, runtime validation, reports that take weeks to write.
Shared services for every case
CommonService, UtilsService, HelperService — a dump of methods used by 40 modules. Any change is a risk for everyone. Nobody knows who calls what.
Consequences
| Consequence | How it shows up |
|---|---|
| No local changes | "Can't touch module X — half the system depends on it" |
| Growing technical debt | Workarounds multiply because the "proper" refactor is too expensive |
| Slower team | Onboarding takes months. Code review is a lottery |
| Fragility | One bug — cascade of failures |
| Fear of change | The team adds another bypass layer instead of fixing the root |
How to reduce coupling
- Explicit module boundaries with contracts (APIs, events, interfaces).
- Ban "convenient" access to other modules' tables and internal classes.
- One source of truth for each business entity.
- Events instead of direct calls where modules should react, not control each other.
Chapter 4. Design Around the Business Domain
Why technical layers do not last
Over 10 years, almost everything technical changes in a typical project:
| Layer | What changes |
|---|---|
| Programming language | PHP → Java → Go → something else |
| Database | MySQL → PostgreSQL → distributed storage |
| Framework | Symfony → Laravel → NestJS → "the next hype" |
| UI | jQuery → React → "server components" → unknown |
| Infrastructure | bare metal → Docker → Kubernetes → serverless |
A structure of "controllers / services / repositories / models" does not survive team and technology turnover because it says nothing about the business. A new developer sees folders — and cannot tell where "order" ends and "warehouse" begins.
Business entities live longer
The problem domain changes more slowly:
- Customer — was, is, and will be (perhaps with different fields).
- Order — same essence: who, what, when, for how much.
- Invoice — money, taxes, payment terms.
- Shipment — from where, to where, when, status.
- Employee — roles, permissions, org structure.
Ten years ago orders were placed by phone. Today — through an app and a marketplace. An order is still an order. Channels and rules changed — not the essence.
The domain outlives technology
The practical principle: architecture should be built around the problem domain, not technical layers.
Bad (technical layers): Good (domain modules):
src/ src/
controllers/ orders/
services/ OrderService
repositories/ OrderRepository
models/ OrderStatus
utils/ billing/
InvoiceService
inventory/
StockMovement
The orders module can be rewritten from PHP to Go without touching billing. The inventory module can be extracted into a separate service when load grows. Boundaries match business language — understood not only by developers but by product owners and analysts.
Domain-Driven Design here is not a "fashionable methodology" but a longevity tool: bounded context, ubiquitous language, explicit aggregates — all about code and product conversations speaking the same language for a decade.
Chapter 5. Never Make the System Fully Universal
The most expensive trap
The desire to anticipate every variant upfront is one of the most expensive mistakes in enterprise development. It arrives under respectable names: "flexible architecture," "platform," "process builder," "configuration without programmers."
On a slide deck it looks like competitive advantage. In operation — like a monster nobody can maintain.
How monsters appear
Typical symptoms of universality:
| Pattern | Promise | Reality after 5 years |
|---|---|---|
| Builders of everything | "Any process without code" | 400 workflow steps; nobody knows why the invoice was not issued |
| Configurable fields for everything | "The client adds a field" | EAV, reports through VIEWs, DBA under constant stress |
| Universal tables | "One model for all documents" | 180 nullable columns; migrations are a nightmare |
| Universal forms | "Form builder in admin" | Runtime validation; bugs only in prod |
| Universal processes | "BPM for any scenario" | Two parallel processes on one document; race conditions |
Why it looks good at the start
Universality really saves time in the first months:
- No need to write a separate model for each document type — "add a type to the catalog."
- Demo impresses: "watch, we created a new entity in five minutes."
- Sales promise "fits any business."
- The architect gets approval for a "scalable solution."
This is a loan at high interest. Interest accrues in database queries, abstraction layers, developers' heads — and in business downtime when a "simple change" takes a month.
Why it becomes a nightmare after a few years
- Performance. Universal queries do not index as well as typed ones.
- Loss of typing. Errors reach prod — "the field was a string but should be a number."
- Refactoring becomes impossible. Logic is spread across metadata, configs, and a "universal engine."
- Dependence on "wizards." Only two people understand how the builder works — both have left.
- Business loses control. "We can't change the process — it will break three clients."
Rule: 80% of the system is a typed domain with explicit rules. 20% is controlled extension points (plugins, webhooks, custom fields at the periphery). Not the other way around.
For more on the cost of universality in ERP, see The Most Expensive Mistake in ERP Architecture.
Chapter 6. Data Matters More Than Code
Code can be rewritten
Code is an interpretation of business rules in a specific language and framework. It can be:
- rewritten in another language;
- split into microservices;
- replaced by AI generation (a joke, but the trend is clear).
Data is business history. Ten years of orders. Payments. Audit trail. Agreements with clients recorded in the system.
Data is almost impossible to rewrite
Data migration is the most expensive and risky operation in a system's life:
- Data loss = lawsuits, fines, lost trust.
- Incorrect migration = reports do not reconcile; accounting panics.
- Long migration = months of running two systems in parallel.
A bad data model survives any amount of code refactoring — because "touching the database is scary."
Data design mistakes
Poorly thought-out relationships
Foreign keys to a "universal" table without understanding lifecycle. Cascade delete that wipes half the history. Many-to-many without an intermediate entity with metadata.
No change history
"We'll just update the status field." A year later nobody knows who moved an order to "cancelled" and when — yet that is needed for a client dispute or audit.
Business logic stored in data
A skip_validation = true flag. Magic number in type = 7. JSON rules interpreted by a "universal engine." Logic in data is not versioned and not tested like code.
Universal EAV structures
-- Pretty on a diagram. Nightmare in SELECT.
entity_id | attribute_code | value_text | value_number | value_date
----------+----------------+------------+--------------+-----------
1001 | customer_name | Acme Corp | NULL | NULL
1001 | amount | NULL | 15000.00 | NULL
1001 | due_date | NULL | NULL | 2026-07-01
Three rows instead of one typed record. Report "all orders with amount > 10,000" — pivot and prayer.
How to design data for a decade ahead
- The model reflects the business, not the UI. Table
orders, notscreen_form_data. - Append-only history for critical entities: audit log, event sourcing for statuses,
valid_from/valid_tofor reference data. - Explicit types and constraints in the database — not null, check constraints, enums where the domain is stable.
- Migration strategy from day one — tool (Flyway, Liquibase, Alembic), naming rules, rollback plan.
- Separate operational and analytical data — do not run OLAP queries on production order tables.
- Document invariants — "line items sum = total," "cannot delete a paid invoice" — in ADRs and constraints where possible.
Chapter 7. Build the System from Independent Modules
What a real module is
A module is not a folder in the project.
A module is not a namespace.
A module is not a set of files with a common prefix.
A module is a part of the system you can change independently — with minimal risk to the rest and a clear outward contract.
Module test: "Can we replace the Billing module implementation without recompiling Orders?" If not — these are not modules but components of one monolith with an illusion of structure.
Signs of a good module
| Sign | What it means |
|---|---|
| Own data | The module owns its tables / collections. Others read through an API, not direct SQL |
| Own rules | Business logic is not spread across Utils |
| Minimal dependencies | Depends on contracts, not neighbors' implementations |
| Explicit public API | Clear what can be called from outside vs internal |
| Independent testing | The module is tested in isolation with mocked dependencies |
Signs of a bad module
- Shared logic base —
BaseEntity,AbstractDocumentthat everything inherits from. - Shared tables — the Warehouse module writes to the Orders module's tables.
- Constant cross-calls — A calls B, B calls C, C calls A.
- Shared database as integration — the "integration via database" anti-pattern.
Modular monolith — a reasonable compromise
You do not have to jump straight to microservices. A modular monolith is one deploy with strict module boundaries inside:
┌─────────────────────────────────────────────┐
│ Modular Monolith │
│ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │
│ │ Orders │ │ Billing │ │ Inventory │ │
│ │ (API) │ │ (API) │ │ (API) │ │
│ └────┬────┘ └────┬────┘ └──────┬──────┘ │
│ │ events │ events │ │
│ └────────────┴──────────────┘ │
└─────────────────────────────────────────────┘
When a module matures, it can be extracted into a separate service without rewriting the domain. The boundaries already exist.
Chapter 8. Make Architecture Understandable to New Developers
People change faster than technology
Over 10 years on a typical team:
- The architect leaves — the one who "designed everything."
- The team lead leaves — the one who knew all the workarounds.
- The team turns over two or three times completely.
- Juniors arrive who become mid-levels in a year — and will make architectural decisions.
The system must outlive its creators. If only one person understands how the payment module works — that is not architecture; that is bus factor = 1.
Signs of understandable architecture
- Obvious rules. "Orders do not import Billing directly — only through
BillingPort." One rule, one place in documentation. - Consistent decisions. All modules structured the same:
domain / application / infrastructure. Not "here this way, there that way because Pete wrote it." - Simple code navigation. A new person finds in a day where orders are created, where status changes, where invoices are generated.
- Minimal magic. No framework that "injects everything" without understanding. No code generation nobody reads.
The cost of "clever" solutions
Complex patterns are not a sign of maturity. Often they signal an architect showing off:
| "Clever" solution | Hidden cost |
|---|---|
| Generic repository for everything | Lost typing, opaque queries |
| Event sourcing everywhere | Complexity without CRUD payoff |
| CQRS for a three-field form | Two stores, eventual consistency for a checkbox |
| Plugin system on day one | Nobody writes plugins; everyone hacks the core |
Simplicity is not primitiveness. Simple architecture understood by a team of eight will outlive "brilliant" architecture understood by one.
Rule: if you cannot explain the decision to the product owner in five minutes — it is too complex for current scale.
Chapter 9. Document Decisions, Not Code
Documentation that actually helps
Code does not explain itself — it explains what the system does. Not why.
Three years later nobody remembers why PostgreSQL was chosen over MongoDB. Why order status is an enum, not a string. Why bank integration is synchronous, not through a queue.
Useless
- Comments on every method —
// returns user by idabovegetUserById. - Describing the obvious — "this class represents an order."
- Wiki nobody updates — outdated documentation is worse than none.
Useful
- Why the decision was made — context, constraints, deadline pressure.
- What alternatives were considered — and why rejected.
- What constraints exist — "cannot change ID format — external integrations depend on it."
- What happens if you break the rule — "direct access to the orders table from the reports module will break locking."
Architecture Decision Records (ADR)
An ADR is a short document for each significant architectural decision:
# ADR-007: Order status as enum in the database
## Status
Accepted
## Context
Need to capture order lifecycle. Considered string,
JSON in metadata, separate transitions table.
## Decision
PostgreSQL enum order_status + order_status_history table
for audit.
## Consequences
+ Typing, simple queries
+ Explicit history
- New status = migration (acceptable: statuses change rarely)
ADRs live in the repo next to code (docs/adr/). They are versioned; every developer sees them during onboarding. This is the project's architectural memory — what survives when people leave.
Chapter 10. Design for the Team of the Future
In 10 years the developers will be different
They will not know:
- Why it was done this way — "that's how it evolved" is not acceptable.
- What constraints existed — "we needed a release for Black Friday 2023."
- What trade-offs were made — "we knew EAV was bad but the client paid for a demo in two weeks."
If this is not recorded — the future team either fears changing code or breaks the system trying to "do it right" without context.
Architecture should explain itself
Practical principles:
| Principle | Example |
|---|---|
| Predictable structure | Every module: domain/, application/, infrastructure/ |
| Minimal magic | Explicit dependency registration, not "auto-scan everything" |
| Maximum transparency | Logging at module boundaries, trace id through the request |
| Convention over configuration | One way to do things — not five |
| Fail fast | Contract violation — error at startup, not in prod on Friday |
Onboarding test: a new developer in two weeks (not two months) can:
- find where a typical task belongs;
- understand which modules a change will touch;
- write code that passes review without "we don't do it that way here."
If onboarding takes months — architecture is already aging, even if the code "works."
Chapter 11. How to Tell Architecture Is Aging
Early symptoms
Architectural aging is not an event but a process. You can spot it through metrics and team behavior.
Every new module is harder than the last
The first module — 500 lines. The fifth — 5,000 because "we need to cover all cases" and "reuse" a half-dead CommonCore.
Every change requires edits in several places
User story "add a field to the customer card" becomes a task touching 13 files in 4 modules.
Development time grows with the same team size
Velocity drops year over year. Story points rise. "We used to do it in a sprint" — "now it takes a quarter."
Bug count increases
Regressions. Hotfixes after every release. "We didn't touch that module" — "but it broke."
The team fears refactoring
"Don't touch it — it works." "We'll work around it." "One more if." Technical debt grows because paying it down seems more expensive than another workaround.
What to do
- Measure cost of change — how many files / modules a typical feature touches.
- Architecture fitness functions — automated checks: "module A must not import module B directly."
- Regular architecture reviews — quarterly, not "when it's already on fire."
- Local refactoring — not a "big rewrite" but constant boundary improvement.
Chapter 12. Practical Checklist for a 10-Year System
Use as a design review checklist or when auditing an existing system.
Architecture
- Clear module boundaries — drawn, documented, checked in CI
- No global dependencies — no
GlobalContext,AppState, "convenient" access to everything - No universal entities for universality's sake — every entity justified by the domain
- Integration through contracts — APIs, events, ports — not shared DB
- Modular monolith or deliberate services — not a "distributed monolith"
Data
- Model reflects the business — tables and entities speak domain language
- Migration strategy exists — tool, process, rollback
- Change history exists — audit log / event log for critical entities
- Constraints in the database — not validation in code only
- OLTP vs analytics separation — or a plan for it
Team
- New developer can get oriented in weeks, not months
- Architectural decisions documented — ADRs, module READMEs
- Shared conventions — structure, naming, code review checklist
- Bus factor > 1 — at least two people know critical modules
Evolution
- Changes are localized — typical feature touches 1–2 modules
- New features do not require rewriting old ones
- Metrics for cost of change exist — and do not grow unchecked
- Refactoring is part of the process, not "someday later"
Chapter 13. What Actually Makes a System Long-Lived
Not technology
A system does not live 10 years because you chose:
- the "right" programming language;
- the "best" database;
- the "trendy" framework.
All of these change. COBOL and mainframe systems still run — not because of the technology but because of resilience to change (or because changing is scarier than enduring).
The ability to change
A system lives long because each next change stays cheap enough:
- the business can adapt to the market;
- the team can ship features without quarter-long "one button" projects;
- data is preserved and accumulates value;
- new people can join without half a year of training.
That is the main criterion of good architecture. Not diagram beauty. Not pattern count. Not matching the latest ThoughtWorks blog post.
The cost of a typical change after 5 years should be comparable to after 1 year — higher perhaps, but not an order of magnitude.
If after three years a "simple" feature costs ten times more than at the start — architecture has already lost. Technology is not the reason.
Conclusion
Architecture is not the art of predicting the future. Nobody knows whether your product will become a marketplace, a SaaS platform, or a niche tool for one industry.
Architecture is the art of reducing the cost of future mistakes:
- design for change, not only features;
- fight coupling — the main killer of systems;
- build around the domain, not technical layers;
- do not make the system universal — that is the most expensive trap;
- protect data — it outlives any code;
- split into independent modules with explicit contracts;
- make architecture understandable to people you have not hired yet;
- document decisions — ADRs, not comments on every line;
- watch for aging symptoms — fix boundaries, do not wait for the "big rewrite."
You cannot build a perfect system the first time. But you can build one where mistakes do not compound exponentially — where each sprint does not make the next one more expensive.
That is what separates long-lived systems from projects rewritten every few years — again and again, each time promising "this time we'll do it right."
Ask yourself not "how do we ship the MVP faster" but "what will it cost to change this in three years." The answer to that question is architecture built to last a decade.