The Maintenance Work Behind Reliable Software

Notes on maintenance, careful decisions, and the unglamorous practices that keep software useful over time.

AI 摘要

Reliable software depends on disciplined maintenance: clear boundaries, small reversible changes, decision-focused documentation, purposeful observability, explicit performance budgets, evidence-based code removal, and errors designed for recovery. Treating maintenance as product development keeps systems understandable, adaptable, and safe as requirements, operations, and teams change.

Software rarely fails because its authors lack intelligence. It often fails after it becomes more expensive for an organization to understand. Shortcuts become conventions, and exceptions introduced as temporary fixes harden into permanent interfaces. Abstractions also accumulate responsibilities until their boundaries are no longer clear.

Building software includes invention, but it also requires keeping a system understandable while its requirements, operating conditions, and personnel change. This work has little to show in a screenshot. Its absence becomes clear when a routine change takes a week because no one knows what else it could break.

Clarity is an operational feature #

Clarity is often treated as a preference for elegant code. It has much more practical consequences. During an incident, the team needs an accurate model of the system before it can act safely. An ambiguous name or a hidden dependency slows that work; a surprising side effect can send it in the wrong direction entirely.

A clear system does not need to be simple in every dimension. Some domains really are complicated. The useful distinction is between complexity the domain requires and complexity the implementation happened to acquire. A payment system may need careful state transitions. Its operators should not also have to remember that a function named refresh silently writes to three databases.

That distinction changes code review. Once a patch works, I want to know whether a reader can identify its inputs, outputs, and side effects, and whether the intended behavior will still be clear to the next person. Review should also catch concepts that the existing model cannot express and failures that remain hidden from the boundary where they could be handled. These checks will not prevent every defect, but they leave less to guess when one occurs.

Small changes are easier to trust #

A large rewrite may appear to offer a clean start, but the old system contains years of discovered behavior, including undocumented behavior that still matters to someone. Much of that complexity is absent at the beginning of a rewrite only because it has not yet been rediscovered.

Smaller changes expose mistakes sooner. A narrow patch can be reviewed against a specific claim, deployed independently, and reverted without disturbing unrelated work. The team can then use what it learns before making further decisions.

Consider a function that has begun to mix validation, persistence, and notification:

func Publish(ctx context.Context, post Post) error {
	if err := post.Validate(); err != nil {
		return fmt.Errorf("validate post: %w", err)
	}

	if err := repository.Save(ctx, post); err != nil {
		return fmt.Errorf("save post: %w", err)
	}

	return notifier.Send(ctx, post)
}

This function may eventually need a queue and retries. It may also need idempotency or an audit log, but there is little benefit in adding all of that before the failure modes are understood. A reasonable first step is to make the notification boundary explicit and record failures. The next decision can then use production evidence rather than guesses about every future requirement.

The value of reversible decisions #

Not every decision deserves the same ceremony. A local variable name is easy to change. A persisted schema is not, and neither is a public URL or external protocol. The harder a decision is to reverse, the more attention it deserves up front.

Decision Cost to reverse Useful response
Internal helper name Low Choose clearly and continue
Page layout detail Low to medium Test with real content
Database representation High Model migration and rollback
Public API contract Very high Document invariants and versioning

Without this distinction, teams can spend an hour debating a helper name and then approve a public interface after a quick glance. One can be corrected in minutes; the other may constrain the project for years.

Maintenance is product development #

Maintenance is often treated as work that follows product development, but users do not separate the two. They experience the system through its performance, recovery behavior, compatibility, and ability to preserve their data. Work on any of those properties remains product work even when the release notes contain no new feature names.

Sometimes that means replacing an unreliable background job with an observable queue or removing a configuration option that no longer changes behavior. A clearer error message may let a user recover without contacting support, while a shorter build can put fixes into production sooner. Documenting a data-retention rule may prevent it from becoming the cause of an incident.

Preventive work is harder to defend in planning meetings because a visible feature can be demonstrated immediately, while the benefit of a safer migration may be an incident that never occurs. Naming the operational result makes the work easier to evaluate. “Allow schema changes without stopping writes” says more than “clean up the database layer.”

Documentation should preserve decisions #

Documentation that repeats the code decays quickly. A list of function parameters can usually be generated, so there is little reason to maintain a second copy by hand. Written documentation earns its keep when it records context that cannot be reconstructed from the current implementation.

In practice, I look for answers to questions like these:

  • What problem made this component necessary?
  • Which alternatives were considered and rejected?
  • What must remain true during a migration?
  • Which failure modes are acceptable?
  • Who depends on this behavior outside the repository?

A decision record does not need to be long:

Decision: store publication timestamps in UTC.

Reason: authors and readers may be in different time zones, while ordering
must remain stable across deployments.

Consequence: convert to local time only at presentation boundaries.

The format matters less than the reason. Without it, a future developer may “simplify” the code by storing local timestamps and unknowingly restore the original bug.

Observability begins with questions #

More telemetry does not automatically make a system observable. A dashboard can contain hundreds of charts without showing whether users are completing the action they came to perform.

I start by checking whether the service accepts work and whether accepted work completes successfully. Operators also need to see where time is being spent, which users or inputs a failure affects, and whether the latest deployment changed any of those conditions. Answering those questions usually requires a small set of service-level metrics, together with structured events and traces around important boundaries. It also makes decorative logging easier to spot. A message such as operation failed consumes storage without helping diagnosis unless it identifies the operation, its underlying cause, and any relevant state.

Design errors for recovery #

Errors are part of an interface. Inside the system, an error should preserve enough context for diagnosis. At an external boundary, it should tell the caller what can happen next. Those are related needs, but they are not identical.

A storage timeout, for example, may need a request identifier and dependency name in the logs. The user may only need to know that the draft remains safe and retrying is appropriate. Dumping internal detail into the UI does not help them. It hands diagnostic work to someone without the tools to do it.

Performance work needs a budget #

Performance work is easier to evaluate against an explicit budget. Without one, “fast” keeps changing meaning and optimization turns into a collection of local opinions.

A small content site might choose budgets like these:

Measure Budget
Initial HTML Under 30 KB compressed
Critical CSS Under 20 KB compressed
JavaScript 0 KB unless interaction requires it
Largest image Under 250 KB
Server build Under 2 seconds for 500 posts

These numbers are not universal laws; they are constraints for one product. They turn a vague preference into something testable. When a feature exceeds a budget, the team can decide whether its value justifies the cost before that cost arrives in production.

Before deleting code, look for evidence #

Removing code can keep paying off for a long time. It reduces the number of states that tests and operators must consider, and leaves future developers with fewer paths to read. Old behavior may still have consumers that cannot be found in the repository, so deletion needs evidence.

Start by searching for internal references and, where possible, checking production usage. Find any persisted data that still uses the old representation and notify external consumers about the change. The behavior and its tests should be removed together, followed by observation after deployment.

Feature flags need particular attention because even a temporary flag creates at least two versions of the product. Leaving it after rollout keeps both versions in the code although only one still runs. Removing the flag is part of completing the feature rather than a task to postpone until a quiet quarter.

Urgency leaves marks in the code #

Software quality depends on attention, which is finite. Under constant urgency, people notice less and fall back on familiar solutions. Verification gets postponed. The effects eventually show up in the code as duplicated logic or a half-finished migration, and in operations as alerts everyone has learned to ignore.

A sustainable pace does not mean avoiding deadlines or difficult periods. It means leaving room to recover afterward and protecting some uninterrupted time for careful work. Code review and incident response both suffer when every task arrives as an emergency. Architectural decisions do too, though the damage often takes longer to become obvious.

This is an engineering concern, not a separate conversation about morale. A team cannot compensate indefinitely for an unstable process by asking each person to be more disciplined.

Months after the launch #

Software that lasts continues to change. A new contributor should be able to locate important behavior without tracing the whole repository, and an operator should understand a failure before trying remedies at random. Upgrades should preserve user data. Once evidence shows that a replacement works, the old feature should be removable without requiring a month of archaeology.

This does not require perfect foresight, and none of it makes for an exciting launch demonstration. Explicit boundaries and small changes that produce useful evidence make later corrections cheaper. Decisions that are expensive to reverse benefit from a short record of why they were made, and obsolete paths should be removed before everyone forgets why both versions exist. The result becomes visible months after launch, when someone can find the relevant code, make a contained change, and verify it without first untangling the rest of the system.