System Architecture: Planning for Failure, Not Just Success
Most architecture diagrams tell a comforting story. A request arrives, the application processes it, the database responds, and a useful result returns to the user. That happy path matters, but it is rarely where systems earn their reputation.
Production systems live in the less elegant moments: a payment provider answers too slowly, a database connection pool is exhausted, a Docker container restarts halfway through a job, or a client retries a request after the server has already completed it. Good architecture does not assume these events are exceptional. It makes them understandable, contained, and recoverable.
Start with the ways a request can fail
For every important endpoint, ask a deliberately pessimistic question: what happens if this step fails after the previous step succeeded?
Consider an API that creates an order. It may validate input, reserve inventory, write an order row, charge a payment provider, and publish an event for email or fulfillment. Treating that as one uninterrupted operation invites inconsistent state. A database transaction can protect the database work, but it cannot safely roll back an external payment call or an email already sent.
The practical response is to make state explicit. Store an order with a clear status such as pending, confirmed, or payment_failed. Record enough information to resume or reconcile the workflow. Then make each transition deliberate rather than hoping that a long request completes perfectly.
Retries need boundaries, not optimism
Retries are useful when a failure is temporary: a network timeout, a brief overload, or a service restart. They are harmful when they turn one slow dependency into a flood of duplicate work.
A retry policy should answer four questions:
- Which failures are plausibly transient?
- How many attempts are acceptable before the work is surfaced for review?
- How long should each attempt wait?
- Can repeating the operation create a duplicate effect?
Use bounded retries with increasing delays, and keep the request deadline in view. Retrying a dependency three times is not helpful if each attempt can consume the entire web request timeout. Background workers are often a better home for slow, retryable work than a synchronous HTTP request.
Idempotency is the companion to retries. If a client submits the same order request twice because it did not receive the first response, the system should create one order, not two. A client-generated idempotency key can be stored with the operation and protected by a unique database constraint. The application can then return the original result when the same key is seen again.
$order = DB::transaction(function () use ($request) {
return Order::firstOrCreate(
['idempotency_key' => $request->header('Idempotency-Key')],
['status' => 'pending', 'total_cents' => $request->integer('total_cents')]
);
});
The exact framework API will vary, but the architectural point does not: correctness must survive repeated delivery.
Keep database truth and external effects aligned
One common failure pattern is writing a database record and then publishing a message. If the process crashes between those steps, the database says the change happened but downstream systems never hear about it.
An outbox pattern addresses this without pretending distributed transactions are simple. In the same database transaction that changes the business record, write an outbox row describing the event. A separate worker reads unpublished outbox rows, sends them to the broker or external service, and marks them delivered only after a successful handoff.
This changes the problem from “can two systems commit at exactly the same instant?” to “can we reliably retry a durable record?” The second problem is still real, but it is tractable. Consumers must also tolerate duplicate events, because a worker may successfully publish and crash before recording that success.
Design overload behavior before traffic forces the issue
Performance is not only about making successful requests fast. It is also about avoiding collapse when a dependency slows down. An overloaded PHP application can run out of worker capacity while waiting on downstream calls; then even lightweight health checks and recovery actions become difficult.
Set timeouts intentionally for database connections, HTTP clients, queues, and cache calls. A missing timeout is often an unbounded wait disguised as a default. Keep connection pools and PHP worker counts aligned with the capacity of the database and dependent services. More application concurrency is not automatically more throughput.
Queues are valuable pressure valves, but only when they have limits and operational visibility. Define what should happen when a queue grows: reject nonessential work, delay it, prioritize it, or alert an operator. A queue that accepts work forever merely moves the outage from the API to an invisible backlog.
Make degradation a product decision
Not every dependency deserves equal treatment. If recommendations are unavailable, a product page may still be useful without them. If authorization data cannot be checked, continuing may be unsafe. Write down these distinctions.
Useful degraded modes are narrow and honest: show cached data with an appropriate freshness policy, postpone a report, or accept a request and process it asynchronously. Avoid silently returning incomplete data as though it were complete. The user experience should reflect the system’s actual confidence.
Containers improve packaging, not reliability by themselves
Docker makes deployment environments more consistent, but a container restart is not a recovery strategy for unfinished work. Containers can be terminated, rescheduled, or started alongside a temporarily unavailable database. Applications should therefore start predictably, expose a meaningful health endpoint, and handle dependency failures without corrupting state.
Separate liveness from readiness in your thinking. A process may be alive while unable to serve traffic because migrations are incomplete, configuration is invalid, or a required dependency is unavailable. Deployment automation should only route traffic to instances that are ready for their intended role.
Database migrations deserve the same caution. Prefer compatible, staged changes: add a nullable column, deploy code that can handle both shapes, backfill safely, then enforce stricter constraints later. A migration that locks a large table or removes a column still used by older containers can turn a routine release into an outage.
Observability is part of the architecture
When a failure occurs, the first question is rarely “did something fail?” It is “which request, dependency, deploy, or state transition caused this?” Structured logs, request IDs, error reporting, and a small set of service-level metrics make that question answerable.
Log events with context that helps diagnosis without exposing secrets or personal data. Track error rate, latency, queue age, and dependency failures. Alert on symptoms users feel, not every noisy internal event. The aim is not a dashboard full of charts; it is a system that can explain itself under pressure.
Build for recovery, then practice it
Failure-aware architecture is not pessimism. It is respect for reality and for the people who will operate the software. Make important operations idempotent, persist state transitions, bound retries, isolate slow dependencies, deploy compatible changes, and ensure that failures leave useful evidence behind.
The memorable test is simple: when the happy path breaks halfway through, can the system recover without guessing? If the answer is yes, the architecture is doing more than processing requests. It is protecting the business decisions hidden inside them.