Software Survivor logo

Practical software architecture

Design Principles for Software That Survives Change

These principles were developed over years of building software that needed to survive changing business requirements, changing vendors, changing engineers, and changing technologies. Most of the systems were enterprise commerce platforms maintained by small teams. They could not depend on constant rewrites, specialized operators for every service, or unlimited time to correct an architectural mistake.

The goal of these principles is not to describe an ideal system. Ideal systems do not have legacy data, vendor contracts, quarter-end deadlines, partially documented integrations, or a checkout that must keep taking orders while another service is failing. These principles are for systems that operate under those constraints.

They are also not universal rules. Every principle has a cost. Abstraction can hide useful details. Configuration can become an untyped programming language. A modular monolith can eventually reach its scaling limits. Retries can amplify an outage. The work is to understand those tradeoffs and make them deliberately, not to follow an architecture diagram because it is fashionable.

The standard I use is straightforward: does the design make the next necessary change safer, cheaper, and easier to understand? If it does not, the design needs a clear operational or business reason to justify its complexity.

Principle 1

Build Around Business Capabilities

Business capabilities usually outlive their implementations. A commerce business will still need to authorize payments, calculate prices, redeem loyalty value, manage inventory, and persist orders five years from now. It may not use the same payment provider, commerce platform, database, cloud, or application framework.

That difference should be visible in the architecture. Core code should describe what the business does. Integration code should describe how a particular vendor does it.

For example, checkout code should depend on an interface such as authorizePayment, not on a provider SDK's charge object. The business operation has stable inputs: an amount, currency, payment method reference, order reference, and idempotency key. Its result has stable business meaning: approved, declined, indeterminate, or failed. Stripe, Braintree, or another provider can implement that contract through an adapter.

The adapter pattern is useful because it creates a deliberate translation boundary. Vendor request objects, status codes, error types, and retry rules remain inside the adapter. The rest of the system works with terms the business understands. This is not abstraction for its own sake. It limits the number of files that must change when a vendor changes its API, when a contract is renegotiated, or when the business adds a second provider.

Stable business interfaces should be narrow enough to remain meaningful and rich enough to expose important behavior. A generic execute(data): any interface technically hides a vendor, but it does not create a useful boundary. Neither does copying every field and method from a vendor SDK into an interface with a different name. The interface should model the capability that callers need, including failure semantics.

Examples include:

  • A Shopify adapter can expose catalog publication, order retrieval, fulfillment updates, and refunds without allowing Shopify webhook shapes or GraphQL response types to spread through the orchestration layer.

  • A payment interface can normalize authorization, capture, void, refund, and settlement status while preserving provider transaction IDs for reconciliation and support.

  • A loyalty interface can represent balance lookup, earn, redeem, and reversal operations while keeping provider-specific member identifiers and error codes at the boundary.

  • A repository can express operations such as findOrderByExternalId or savePromotionRedemption while MongoDB documents, PostgreSQL rows, indexes, and transaction APIs remain implementation details.

Vendor abstraction does not mean pretending all vendors are identical. Differences that affect the business must remain explicit. If one payment provider supports separate authorization and capture while another does not, the interface must either represent that distinction or reject unsupported workflows clearly. A lowest-common-denominator interface can be as damaging as direct vendor coupling because it hides capabilities the business actually depends on.

The same caution applies to databases. A repository boundary is valuable around business aggregates and transaction behavior. A universal storage interface that attempts to make SQL, document storage, caches, and search engines interchangeable usually removes the useful features of each. Abstract the dependency at the level where replacement is plausible and valuable. Do not build a private database framework.

The tradeoff is additional code and translation. Adapters require contracts, mapping, tests, and a place to retain provider-specific metadata. For a short-lived experiment, direct SDK usage may be the correct choice. For checkout, payments, loyalty, tax, fulfillment, or other capabilities likely to survive multiple vendor contracts, the translation cost is usually much lower than the eventual migration cost.

Case study: MongoDB to PostgreSQL migration

How Adapter Boundaries Enabled a Zero-Downtime MongoDB-to-PostgreSQL Migration shows where repository boundaries contained the change, where MongoDB assumptions still leaked, and why data movement, reconciliation, synchronization, cutover, and rollback remained separate operational concerns.

Principle 2

Optimize for Change

Architecture is a set of decisions about which changes will be cheap and which will be expensive. Optimizing only for today's implementation usually makes today's first release slightly faster and every later release slower. Optimizing for every imaginable future creates a framework nobody can finish. The useful middle ground is to design for changes that the business has evidence it will make.

In enterprise commerce, some changes are predictable even when their exact form is not. Pricing rules will change. A new fulfillment source will appear. Marketing will request a campaign the current promotion model cannot express. A provider will deprecate an API. An acquisition will add a second customer or order model. These are not speculative possibilities. They are recurring classes of change.

Flexibility starts with identifying the decisions most likely to vary and keeping them out of the stable workflow. Checkout may have a durable sequence—validate the cart, calculate totals, reserve inventory, authorize payment, create the order—but tax calculation, discount eligibility, payment routing, and fulfillment selection can vary behind explicit policies or capability interfaces.

Configuration is appropriate when the possible variations are understood, bounded, and safe to change without a deployment. Composition is appropriate when behavior can be assembled from small, testable operations. Replaceable adapters are appropriate at external boundaries. Ordinary code is appropriate when the rule is still changing shape and an attempted abstraction would only preserve a guess.

Long-term thinking means preserving options without paying for all of them immediately. A team may start with one payment adapter while using a provider-independent interface. It does not need a dynamic multi-provider routing service on day one. A promotion pipeline may compose a few explicit rule types without becoming a general-purpose expression language. The architecture should provide a clean seam for the next likely change, not implement the next ten hypothetical changes.

Replaceability also requires operational completeness. Swapping a vendor is not only changing an API client. It includes historical identifiers, reporting, reconciliation, webhooks, retries, support tools, data backfills, parallel operation, and rollback. A clean interface helps, but a credible migration plan must account for the lifecycle around that interface.

The tradeoff is that change-friendly designs require judgment up front. Teams can create too many interfaces, configuration points, and extension mechanisms. Each one becomes another concept the next engineer must learn. When there is no concrete reason a component will vary, keep it simple. Refactor toward a seam when the second implementation or second business rule makes the variation real.

Principle 3

Optimize for the Next Engineer

Reducing cognitive load is one of the highest-leverage architectural goals. A system that only its original author can safely change is already failing, even if it is fast and technically correct. The cost appears as slow onboarding, narrow ownership, fragile releases, repeated questions, and a small number of engineers becoming permanent bottlenecks.

The next engineer should be able to predict where code belongs. Consistency matters more than local cleverness. If integrations use adapters, use the same adapter structure. If use cases own business orchestration, do not hide orchestration in controllers for one feature and database hooks for another. Repetition of a clear pattern is often cheaper than a more abstract design with several valid ways to accomplish the same task.

Dependency injection makes dependencies visible and replaceable. A constructor that accepts an order repository, payment capability, clock, and logger tells an engineer what the component needs. It also makes isolated tests possible without patching global modules. Dependency injection does not require a large container or decorators. Explicit factory functions and constructor parameters are often sufficient. The goal is visible wiring, not a framework.

Clear boundaries should answer common questions: Where is validation performed? Which layer owns a transaction? Can this operation be retried? Is this object a vendor response or a business model? Where are side effects initiated? If those answers depend on tribal knowledge, the boundary is not clear enough.

Linting and formatting remove low-value decisions from code review. They should enforce rules the team actually cares about and produce predictable code without becoming a substitute for design review. Naming carries even more weight. Names should describe business intent and side effects. applyEligiblePromotions is more useful than processRules; reserveInventory is more honest than updateStock. Generic names force every reader to inspect the implementation.

Documentation should explain decisions, contracts, and operational behavior that the code cannot make obvious. Useful documentation includes why a boundary exists, what an idempotency key protects, which system is authoritative, how to recover a failed workflow, and which invariants must remain true. Documentation that restates function names or reproduces a directory tree will become stale without helping anyone.

Small, understandable files are valuable when they align with coherent responsibilities. Splitting every function into its own file creates navigation overhead. Keeping unrelated workflows in a 2,000-line service creates change risk. The right size lets an engineer understand the purpose, dependencies, and failure modes of a module without loading a large part of the system into memory.

Architecture should shorten onboarding. A new engineer should be able to trace one request from its entry point through business decisions, integrations, persistence, and observability. Local development should expose the same important behavior as production without requiring access to every vendor. Tests and fixtures should demonstrate contracts, not merely increase a coverage percentage.

The tradeoff is that consistency sometimes preserves a pattern that is not locally optimal, and documentation takes time. Small teams cannot document everything. Document the decisions with a high cost of rediscovery and standardize the paths engineers use frequently. Allow exceptions, but make the exception and its reason explicit.

Principle 4

Prefer Practical Simplicity

Complexity must earn its operating cost. This is why a modular monolith often outperforms premature microservices for a small engineering team. It provides strong internal boundaries without making every boundary a network, deployment, authentication, observability, and data-consistency problem.

Inside a modular monolith, pricing, promotions, orders, loyalty, and fulfillment can have separate interfaces and ownership while sharing one deployable application and, when appropriate, one database. Calls are ordinary function calls. Refactoring across boundaries is possible. A local environment can run the system without recreating a distributed platform.

Microservices have legitimate uses. Independent scaling, isolation of dangerous workloads, different availability requirements, regulatory boundaries, and genuinely independent teams can justify a service boundary. But splitting a system because modules might scale someday exchanges a possible future problem for immediate distributed-systems work: timeouts, partial failure, duplicate delivery, schema compatibility, tracing, secrets, deployments, and cross-service data ownership.

Operational simplicity is a production feature. Fewer moving parts mean fewer dashboards, fewer failure combinations, fewer credentials, and fewer deployment pipelines. Debugging is faster when an engineer can follow a request through one process and correlate it with one set of logs. Recovery is safer when the team understands the complete runtime instead of relying on one specialist per service.

Lower operational cost matters even when infrastructure spending is not large. The more important cost is engineering attention. Every queue, database, service, cache, and orchestration system needs upgrades, monitoring, capacity decisions, security patches, runbooks, and incident experience. Small teams should spend that attention on business capabilities and known reliability risks.

Practical simplicity does not mean one unstructured application. A monolith without module boundaries becomes difficult to change. The design should make dependencies directional, keep business capabilities distinct, and prevent database access or vendor calls from appearing everywhere. The deployment unit can remain simple while the code remains disciplined.

The tradeoff is that a modular monolith can create deployment coupling, shared-resource contention, and broad failure impact. A single build may take longer as the system grows. Teams should measure those costs. Extract a service when a demonstrated scaling, isolation, or ownership need outweighs the distributed-systems cost—not when a diagram looks cleaner with more boxes.

Principle 5

Configuration Beats Code

When a business rule changes frequently, requiring an engineer and a production deployment for every change is an architectural tax. A configurable system turns repeated engineering work into a controlled business capability. That can shorten campaign lead time, reduce interruptions, and let the people closest to pricing or marketing own routine decisions.

Pricing engines are an obvious example. Base prices, effective dates, customer segments, channels, and precedence rules should be represented as validated data when the variation is known. Rules engines can express eligibility and outcomes for a bounded set of conditions. Marketing campaigns can select approved audiences, benefits, limits, and time windows. Feature flags can separate code deployment from feature release and provide a controlled kill switch.

The important word is controlled. Business-owned configuration needs schemas, validation, permissions, preview, audit history, effective dates, and rollback. A malformed promotion should be rejected before it affects a cart. Conflicting rules should have deterministic precedence. A price change should record who approved it. A feature flag should have an owner and an expected removal date. Configuration without governance moves production risk into an interface with fewer safety checks.

Configuration also needs limits. If users request arbitrary branching, loops, remote calls, and custom scripts, the configuration system has become a programming language. It will need versioning, testing, debugging, security controls, and developers to maintain it. At that point, ordinary code may be safer and easier to reason about.

A useful approach is to define a small vocabulary of conditions and actions around a specific business capability. For promotions, conditions might include channel, date range, customer group, product attributes, quantity, and subtotal. Actions might include a fixed discount, percentage discount, free item, or shipping benefit. The engine owns evaluation order, stacking rules, rounding, and an explanation of why a rule did or did not apply.

Feature flags deserve similar discipline. They are good for progressive delivery, operational control, and short-lived experiments. They are poor substitutes for authorization, permanent product configuration, or unresolved architecture. Every long-lived flag doubles a path someone must understand and test.

The tradeoff is substantial upfront work. A configuration interface, validation model, audit log, and safe evaluator may cost more than the first several hard-coded rules. Build it when change frequency, business value, or operational risk justifies it. Do not build a platform for a rule that changes once a year.

Principle 6

Design for Small Teams

Architecture for a small team must maximize engineering leverage. The system cannot assume a dedicated platform group, database team, site-reliability rotation, and integration specialist are available for every decision. It must be operable by the engineers who build the business features.

Reusable components create leverage when they encode a decision the team should make consistently. An HTTP client with standard timeouts, retry policy, correlation IDs, metrics, and redaction is more valuable than five vendor clients that each solve those concerns differently. A shared job runner can standardize idempotency, leases, retry limits, and dead-letter handling. A deployment template can provide health checks, secrets, alarms, and rollback behavior by default.

Standards reduce discussion and incident variance. They should cover the recurring production questions: structured logging, error classification, API validation, database migrations, idempotency, dependency timeouts, health checks, and ownership. A standard is useful when the safe path is also the easy path. A document nobody follows is not a platform capability.

Automation should target repeated work with a high cost of human error. CI should verify formatting, types, tests, builds, migrations, and dependency policy appropriate to the repository. CD should produce repeatable artifacts, separate deployment from release where necessary, and make rollback boring. Local setup should be scripted. Routine operational actions should be captured in safe tools or runbooks instead of remembered commands.

Developer productivity is not measured by the number of internal tools. It is measured by how quickly an engineer can understand a problem, make a safe change, verify it, deploy it, and diagnose the result. Every platform abstraction should be evaluated against that workflow. If it saves the platform author time but makes application debugging opaque, it has shifted work rather than removed it.

Small teams also need deliberate limits. They should prefer managed infrastructure where it removes undifferentiated operations, but not when it creates severe vendor lock-in around a core business capability. They should avoid supporting multiple ways to deploy, log, queue work, or structure an integration without a concrete need. Optionality has a maintenance cost.

The tradeoff is centralization. Shared components can become bottlenecks or accumulate incompatible requirements. Standards can resist useful exceptions. Keep shared foundations narrow, version them carefully, and allow an escape hatch with an explicit reason. Consolidate operational concerns; do not force unrelated business domains into one generic model.

Principle 7

Reliability Is a Feature

Reliability is part of product behavior. A customer does not care whether checkout failed because of application code, a loyalty provider, a database connection pool, or a network timeout. The business result is the same: the order was not placed, confidence was lost, and support work was created.

Graceful degradation begins by deciding which dependencies are required for the primary business operation. Payment authorization may be mandatory for checkout. A loyalty balance, recommendation, analytics event, or marketing profile update may not be. Optional dependencies should not take down the critical path. The fallback must be explicit, observable, and honest to the user; silently returning incorrect data is not graceful degradation.

Caching can isolate a system from slow or unavailable vendors and reduce cost, but its correctness depends on the data. Product descriptions can tolerate staleness differently from inventory, prices, loyalty balances, or payment status. Every cache needs an ownership model, freshness policy, invalidation strategy, and behavior for a miss during an outage. A cache should not quietly become an untracked system of record.

Retries are appropriate for transient failures when the operation is safe to repeat. They need bounded attempts, exponential backoff, jitter, time budgets, and error classification. Retrying a validation failure wastes capacity. Retrying an indeterminate payment request without an idempotency key can charge a customer twice. Retrying at several nested layers can multiply traffic and turn a partial outage into a complete one.

Vendor failures should be assumed. Every external call needs a timeout. Critical workflows need idempotency and a record of their state. Asynchronous work needs durable delivery, duplicate handling, retry limits, and a way to inspect and replay failures. Circuit breakers, queues, or bulkheads can help, but only when they address a measured failure mode and the team can operate them.

Resiliency includes recovery, not only prevention. If an order is created but fulfillment submission fails, the system needs to identify and resume that work. If a payment response is indeterminate, reconciliation must determine the provider's result before another charge is attempted. Logs should contain correlation and business identifiers without exposing sensitive data. Metrics should describe customer and business outcomes, not only CPU and memory.

The tradeoff is complexity and sometimes reduced freshness. Fallbacks create alternate paths to test. Caches can serve old data. Queues delay work. Idempotency records and workflow state require storage and cleanup. Reliability mechanisms should be proportional to business impact, and failure paths must be tested. An untested fallback is an assumption, not a capability.

Principle 8

Architecture Should Compound

Great architecture becomes more valuable over time because each sound boundary, standard, and operational capability reduces the cost of later work. The first adapter may appear to add effort. The second vendor migration proves its value. The first deployment template takes time. Every later service receives safer defaults. The first clear module boundary improves one feature. Years later, it allows that capability to move without rewriting the rest of the platform.

Architecture should survive migrations. Data stores, hosting models, and application frameworks will change. Stable business models and explicit translation boundaries keep those migrations from becoming simultaneous rewrites of every business rule.

It should survive new engineers. Consistent structure, visible dependencies, and documented decisions let engineers build on prior work instead of reverse-engineering it. Their improvements should fit the system without requiring permission from the original authors.

It should survive new vendors. External systems should enter through contracts that preserve business intent and failure semantics. Historical records must retain enough provider context for support and reconciliation without making the provider's model the platform's model.

It should survive acquisitions. Acquisitions introduce overlapping customer records, catalogs, fulfillment processes, financial rules, and technology stacks. Architecture cannot make that easy, but capability boundaries and authoritative data ownership make it possible to integrate one decision at a time.

It should survive product changes. A platform built around checkout, pricing, payment, order, and fulfillment capabilities can support new channels and experiences more safely than one organized around the screens and vendors of its first release.

This is why architecture is an investment rather than an expense. The return is not the number of patterns or diagrams produced. The return is lower marginal cost for future changes, fewer defects at established boundaries, faster onboarding, safer operations, and the ability to replace a major dependency without stopping product development.

Not every architectural investment compounds. A generic framework that only one person understands creates interest payments. A service boundary without operational need multiplies overhead. An abstraction with no second use case preserves speculation. Investment language must not be used to avoid delivering value today.

The practical test is whether the system's accumulated decisions make the next real change easier. If each feature requires more coordination, more exceptions, and more knowledge of unrelated code, the architecture is depreciating. The response should be targeted: repair the boundary causing the cost, improve the shared path, and remove accidental complexity. A rewrite is rarely the first answer.

The practical standard

My Definition of Great Architecture

Great architecture makes change inexpensive.

It allows businesses to evolve without expensive rewrites.

It enables engineers to become productive quickly.

It reduces cognitive load.

It makes failure behavior explicit and recovery possible.

It preserves business capabilities while implementations change.

It outlasts vendors, frameworks, and technologies.

It does not eliminate tradeoffs. It makes them visible, deliberate, and appropriate to the team that must operate the system.

Technology changes.

Good architecture should not have to change at the same rate.

See the principles under real constraints

The useful test of a principle is how it shapes a migration, integration, reliability decision, or platform boundary—not how convincing it sounds in isolation.