Change one order status field, discover you need to modify 12 files, and 30 test cases blow up. This isn't an exaggeration — it's daily life.

At the beginning of a project, it wasn't like this. Adding a feature meant touching two or three files, running tests that all passed, and shipping that afternoon. Six months later, the same change requires combing the entire codebase. After making changes, you're uncertain. Before deploying, you pull in three people for review — not because the logic is complex, but because nobody knows how many places depend on this field.

What happened?

Essential Complexity vs. Accidental Complexity

Fred Brooks, in his 1986 essay "No Silver Bullet," drew a distinction that remains accurate today: software complexity divides into essential complexity and accidental complexity.

Essential complexity is inherent to the problem domain. If you're building an ad bidding system, real-time bidding is inherently complex — you need to complete user matching, bid calculation, creative selection, and budget deduction within milliseconds, handling concurrency, fault tolerance, and degradation. This complexity shouldn't be eliminated; it can only be managed. You can't make bidding logic simpler through better tools or smarter architecture, because it was never simple to begin with.

Accidental complexity is what we create ourselves. To support "flexibility," we introduce 5 layers of abstraction to handle "requirements that might change." To make code "more generic," we write a configuration system that can handle all scenarios. To "decouple," we split simple logic into 8 interfaces and 12 implementations. These things aren't required by the problem domain — we added them.

Most systems become harder to change not because essential complexity increased, but because accidental complexity ran out of control.

I've seen a payment system whose core logic is three things: create order, call payment channel, update status. But to "support future multi-channel switching," the team designed an abstraction layer: PaymentStrategy, ChannelAdapter, RouteSelector, ConfigLoader... In the end, even creating the simplest Alipay order requires jumping through 7 classes. A new engineer spent a week figuring out how money flows from the user's account to the merchant's — logic that could be explained in under 100 lines of code.

That's the cost of accidental complexity: it doesn't solve the current problem, but creates future comprehension burdens.

How Coupling Quietly Grows

Coupling is like clutter in a room. Add one item a day, you don't notice. A year later, you can barely walk.

Every time we add a feature, we're "grafting" onto existing code. The product manager says add a coupon feature — you add logic in the order module. Next week, add a discount — you add another layer. The week after, add a flash sale — you keep adding. Each layer alone isn't complex, but they all reference the order's core data structures, all modify the same status field, all depend on the same validation logic.

Three months later, you try to change an order status enum value and discover that coupons, discounts, flash sales, points, membership tiers... all these places need to change too. You're not changing a field — you're changing a web.

Coupling grows silently because there's no proactive boundary maintenance. Most teams don't have clear rules about "this module shouldn't know about that module" — only "where's the most convenient place to put this feature" ad-hoc decisions. The cost of convenience is coupling.

I was part of a user system refactoring. Originally, the user module only stored user information and authentication logic; other business modules accessed user data through stable interfaces. Later, to "reduce network calls," someone started directly querying the user table from the order service. To "speed up response," someone cached user profiles in the recommendation service. To "ease debugging," someone printed complete user objects in the logging service.

Two years later, a field type change in the user table caused a site-wide outage. Not because the change itself was dangerous — because nobody knew how many places directly depended on the table structure. Boundaries existed only in documentation, not in code.

Why "Clean Code" Isn't Enough

Even if every piece of code is clean, the system as a whole can still be a mess.

Local order doesn't equal global order. You can write perfect functions — single responsibility, clear naming, no duplication, 100% test coverage. But if the call relationships between these functions form a spider web, the system is still hard to change.

I reviewed a project where every file's code quality was high: standard naming, complete error handling, clear comments, no function over 20 lines. But at the system level, it was a disaster: Module A calls Module B's public API, Module B internally calls Module C's internal implementation, Module C in turn depends on Module A's utility functions. A circular dependency triangle, where any module change drags in the other two.

Code reviews typically focus on local quality: does this function have bugs? Is this variable name clear? Is this exception properly handled? But few people ask: where are this module's boundaries? Are its exposed interfaces stable? Are its internal implementations visible to other modules?

Local optimization can't solve global problems. Like a house where every room is tidy, but the hallways between rooms are piled with clutter — the house is still unlivable.

The Double Edge of Abstraction

Abstraction simultaneously solves problems and creates them. The wrong abstraction is worse than no abstraction.

Abstraction's value is hiding details. A good abstraction lets you use something without knowing how it works underneath. The HTTP client library — you don't need to know TCP handshake, TLS encryption, DNS resolution details; just call get(url) and get a response. Good abstraction — it hides complexity, exposes a simple interface.

But abstraction also creates complexity. When you design a framework supporting multiple strategies for "future extensibility" that never materializes, the abstraction is noise. Code readers need to understand the framework's design intent, where the extension points are, how to configure different strategies — none of which is ever used at runtime.

Rich Hickey in "Simple Made Easy" distinguished simple from easy. Simple means "few things woven together" — objective. Easy means "easy to approach" — subjective. A well-designed abstraction should be simple — it reduces the number of concepts, not increases them.

But I've seen too much "abstraction for abstraction's sake." A team, to "unify the data access layer," designed an ORM framework supporting MySQL, PostgreSQL, MongoDB, Redis... while the project only used MySQL. The framework's code was 10× the business logic. New engineers needed two weeks to learn the framework before writing any business code. That's not simple — that's trading complexity for illusory generality.

Abstraction should be requirement-driven, not imagination-driven. When you encounter a similar problem the second time, consider abstracting. The third time, refine the abstraction. The first time, write a concrete solution. Premature abstraction is usually wrong abstraction.

Why Simplicity Is the Highest Priority

John Ousterhout in "A Philosophy of Software Design" proposed the concept of deep modules and shallow modules — a practical criterion for judging module design quality.

Deep modules have a simple interface but implement powerful functionality. Unix's filesystem interface — open, read, write, close. Four functions operate any file, device, or network connection. Simple interface, complex implementation hidden behind it. Good design.

Shallow modules have a complex interface but implement very limited functionality. Like a config management class with 20 methods to read different config types, each with special parameters and return values, but all it does is fetch from a map. Bad design.

The core of simplicity is reducing the number of concepts that need to be understood. The more concepts in a system, the harder it is to change. Every class, interface, configuration option, and callback is a concept that needs to be understood, remembered, and maintained.

The most maintainable system I worked on wasn't the one with the prettiest code — it was the one with the fewest concepts. Three layers: HTTP layer handled requests and responses, business layer executed core logic, storage layer persisted data. No more than 10 methods per inter-layer interface, no more than 3 parameters per method. New engineers understood the entire system structure in one day, started contributing in a week.

In contrast, another system designed 8 abstraction layers, each with its own interface, configuration, lifecycle management. The code was "elegant," but nobody could understand how the entire system worked. Every change spanned multiple layers; pull one thread and the whole web shook.

Simplicity isn't crudeness — it's restraint. Constantly asking during design: is this really needed? Can it be done more simply? Does this abstraction actually reduce concepts, or just move complexity from one place to another?

What We Can Do

There's no silver bullet for fighting system entropy, but there are actionable practices.

Control coupling direction. Dependencies should flow one way. High-level modules can depend on low-level modules, but low-level modules shouldn't know about high-level ones. Business logic can depend on database abstractions, but the database layer shouldn't know business rules. If you find a reverse dependency, refactor immediately.

Maintain module boundaries. Interfaces between modules should be stable, documented, and designed. Internal implementation details shouldn't be accessible externally. In Go, use lowercase names to hide internal functions and types; in Java, use package-private; in TypeScript, use internal directories. Boundaries aren't just convention — enforce them at the code level.

Adopt modular monolith. Microservices aren't the solution to coupling — they turn in-process coupling into inter-process coupling. If your team is small and the business domain isn't complex, a modular monolith is better. Clear module boundaries within a single codebase isolate changes while maintaining development convenience. When a module genuinely needs independent deployment, extract it as a microservice — by then you have clear boundaries, and the extraction cost is much lower.

Write good ADRs (Architecture Decision Records). Every important architectural decision should be recorded: what was the problem, what alternatives existed, why this option was chosen, what the tradeoffs are. Six months later, when you look back at this code, the ADR tells you the thinking process.

Leave clues for your future self. Code isn't just instructions for machines — it's documentation for humans. Good naming, clear comments, reasonable structure — all leave clues. Imagine: six months from now, can you quickly understand what this code does, why, and what the impact of changing it would be?

Regularly pay down technical debt. Technical debt isn't "fix it when we have time" — it accrues interest. Every feature stacked on top of debt increases the interest. Set aside 20% of time each quarter for refactoring, cleanup, simplification. This isn't luxury — it's investment.

Closing Judgment

Software rot isn't an accident — it's the default state.

Without continuous, conscious investment, any system will become progressively harder to change. Not because engineers get dumber or tools get worse, but because every change leaves traces. These traces accumulate into coupling, complexity, and comprehension barriers.

There's no one-time fix. You can't solve it permanently through one refactoring, one new framework, or one new methodology. Fighting rot requires continuous effort: attention to boundary design in every code review, coupling cost assessment with every feature addition, timely refactoring when something smells.

Precisely because rot is the default state, every decision that keeps the system simple is valuable. Every refusal of unnecessary abstraction, every maintenance of module boundaries, every deletion of dead code — these are acts of resistance against entropy. These seemingly small efforts, accumulated, make the difference between a maintainable system and an unmaintainable one.

A software system's lifespan depends not on how perfectly it was initially designed, but on whether it can remain simple throughout its evolution. This isn't a technical problem — it's an engineering discipline problem.