Development

System Architecture: Plan for Evolving Needs, Not Just Today's Demands

System Architecture: Plan for Evolving Needs, Not Just Today's Demands

Most systems do not fail because the first version was too simple. They fail because the first version quietly became a permanent commitment.

A feature arrives with a deadline, a table is added, an endpoint is exposed, and a Docker container starts successfully. The work may be entirely reasonable for today. The trouble begins when the original assumptions are never made visible: one customer type, one payment flow, one deployment target, one reporting need. Months later, every new requirement has to squeeze through choices that were meant to be temporary.

Good system architecture is not an attempt to predict every future. It is the discipline of making likely change affordable while keeping today’s solution understandable.

Design around change, not imaginary scale

“Future-proof” is an unhelpful goal. No design is proof against unknown requirements, and building for every possibility produces abstractions that nobody can safely change. A better question is: which parts of this system are most likely to change, and what would changing them cost?

For a PHP backend, likely change points often include business rules, external integrations, authorization, data ownership, and reporting. A controller that mixes all of these concerns may work perfectly at first, but it makes each later adjustment risky. The aim is not to split every class into a dozen layers. It is to give volatile decisions a clear home.

For example, keep HTTP concerns in controllers, application workflow in dedicated services or actions, and persistence behind models or repositories only when the repository genuinely adds a useful boundary. A thin wrapper around an ORM query is not architecture. A boundary that prevents payment-provider details from leaking across the codebase can be.

Start with a modular monolith

For most products, a modular monolith is the strongest default. It keeps deployment, debugging, transactions, and local development straightforward while allowing the codebase to develop meaningful internal boundaries.

Modules should reflect business capabilities rather than technical categories alone. A directory structure organized only as Controllers, Services, and Models can turn into a scavenger hunt as the application grows. Grouping code around areas such as orders, billing, inventory, and identity makes ownership and dependencies easier to see.

The important test is dependency direction. Billing may ask an identity component whether an account is allowed to pay, but identity should not need to know billing’s invoice rules. When modules depend on each other freely, every feature becomes a system-wide edit.

Use interfaces where the dependency is real

Interfaces are useful when the application depends on a capability that may have multiple implementations or needs isolation from an external system. A notification workflow can depend on a sender without knowing whether delivery happens through email, a queue, or a third-party API.

interface InvoiceSender
{
    public function send(Invoice $invoice): void;
}

final class SendInvoice
{
    public function __construct(private InvoiceSender $sender)
    {
    }

    public function handle(Invoice $invoice): void
    {
        $this->sender->send($invoice);
        $invoice->markAsSent();
    }
}

This boundary is valuable because delivery policy can change independently of the invoicing workflow. It would be less valuable if the interface existed only to hide a single stable internal helper. Abstraction should reduce coupling, not merely increase the number of files.

Let the database enforce the truths it owns

Application validation improves user feedback, but it is not a substitute for database integrity. Requests can be retried, jobs can run concurrently, scripts can bypass a form, and a second code path may eventually be introduced. The database is the final shared authority over stored data.

Use foreign keys where relationships are required, unique constraints for values that must be unique, and appropriate indexes for the queries the application actually performs. If an order number must not repeat, enforce that rule in the schema as well as in PHP. If a child record cannot exist without its parent, model that relationship explicitly.

Schema evolution deserves the same care as application code. A safe migration often follows an expand-and-contract pattern:

  1. Add a new nullable column, table, or index without breaking the running application.
  2. Deploy code that can read the old form and write the new form.
  3. Backfill existing data in controlled batches if necessary.
  4. Switch reads to the new representation after verifying the data.
  5. Remove the old path only after it is no longer in use.

This is less dramatic than a single destructive migration, but it gives deployments a recovery path. It also matters when multiple application instances briefly run different versions during a rollout.

Make APIs explicit about contracts and failure

An API is an agreement, not just a route returning JSON. Clients need stable field meanings, predictable validation errors, authorization behavior, pagination rules, and a clear response when work cannot be completed.

Versioning is one option, but compatibility often starts with simpler habits: add fields rather than rename or remove them, avoid changing a field’s type, and treat error payloads as part of the contract. If a public endpoint accepts a request that creates a resource, retries deserve special thought. A network timeout may leave the client unsure whether the server completed the work.

For operations that must not create duplicates, an idempotency key can let the server associate repeated requests with the original result. That key must be stored and handled consistently; merely accepting a header without recording its outcome does not make the operation idempotent.

External APIs need similarly defensive treatment. Set timeouts, distinguish retryable failures from permanent ones, and avoid blindly retrying non-idempotent requests. Queue asynchronous work when a user does not need an immediate result, but design jobs to tolerate duplicate delivery. At-least-once processing is common; the business action must be safe if it runs again.

Containers should make operations boring

Docker helps when it makes environments repeatable, not when it hides uncertainty. Build one application image, provide configuration through environment variables or a managed secret mechanism, and keep stateful services such as databases separate from the application container.

A production image should install only what it needs to run. In a typical PHP application, dependencies can be installed during the build, while writable runtime paths are deliberately identified and mounted or provisioned as required. Avoid assuming that a container’s local filesystem is durable between deployments.

Health checks should reflect useful readiness. A process that has started is not necessarily ready to serve traffic; it may still lack database connectivity or required configuration. At the same time, a health endpoint should not perform expensive work on every probe.

Performance is a feedback loop

Architecture cannot be separated from performance, but premature optimization is still costly. Measure the slow path before redesigning it. Inspect database queries, look for repeated queries in loops, verify indexes with the database’s query-planning tools, and observe queue latency and error rates.

Caching is most effective when its ownership and invalidation rules are clear. Cache a derived value because it is expensive and safely reusable, not because caching feels like a general cure. A stale price, permission, or inventory count can be worse than a slow response.

When a bottleneck is confirmed, choose the smallest intervention that addresses it: a better query, an index, pagination, a background job, or a bounded cache. Each solution introduces its own operational responsibility.

Architecture is a sequence of reversible decisions

The healthiest systems are not the ones with the most diagrams or the most services. They are the ones where developers can explain where a rule belongs, how data changes safely, what happens when a dependency fails, and how a deployment can be rolled back or completed.

Build the smallest coherent system for today, then protect its seams. Name the boundaries, enforce the important invariants, measure before optimizing, and leave room for the next credible change. That is how architecture remains an advantage instead of becoming the most expensive part of the codebase.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.