Razvoj

Beyond Microservices: Architecting for Distributed System Sanity

Izvan mikroservisa: projektiranje za stabilnost distribuiranih sustava

Microservices are often presented as the inevitable next step after a monolith: split the application, deploy services independently, and let teams move faster. The appeal is real. So is the hidden cost.

A distributed system does not become simpler because its code lives in smaller repositories or containers. It becomes a network of partial failures, delayed messages, incompatible contracts, duplicate data, and operational decisions that must remain sensible under pressure. Good architecture is less about choosing “microservices” than choosing the smallest amount of distribution that solves a real problem.

Start with boundaries, not deployment units

The strongest reason to separate a service is not that a module has grown large. It is that it has a distinct business boundary, lifecycle, scaling profile, security posture, or ownership model.

An order workflow, for example, may contain catalog lookup, pricing, payment authorization, inventory reservation, fulfillment, and notifications. Those capabilities do not automatically deserve separate services. First ask whether they need independent releases, independent storage, or independent failure handling. If the answer is no, keeping them together may be the more resilient choice.

A modular monolith is often an excellent intermediate architecture. It provides clear internal boundaries without immediately requiring network calls for ordinary collaboration. In a PHP application, that can mean organizing code around domain modules rather than technical layers alone:

src/
  Orders/
    Application/
    Domain/
    Infrastructure/
  Inventory/
    Application/
    Domain/
    Infrastructure/
  Payments/
    Application/
    Domain/
    Infrastructure/

Each module can expose a narrow application-level interface. Other modules should not reach into its persistence models or tables as a shortcut. That discipline makes later extraction possible, but it also improves the monolith today.

Every remote call is a reliability decision

Inside one process, a function call is fast and usually dependable. Across a network, it can time out after the remote system has completed the work. It can fail before receiving a response. It can be retried and create a duplicate operation. These are normal outcomes, not edge cases.

Consider a checkout API calling a payment service. A naive implementation treats a timeout as a failure and retries immediately. But the first request may have reached the payment provider and succeeded. The retry could authorize the customer twice unless the operation is idempotent.

For commands that change state, accept an idempotency key and store the result associated with it. A repeated request with the same key should return the original outcome rather than perform the action again.

$key = $request->getHeaderLine('Idempotency-Key');

if ($existing = $idempotencyStore->find($key)) {
    return $existing->toResponse();
}

$result = $paymentService->authorize($command);
$idempotencyStore->save($key, $result);

return $result->toResponse();

This example still needs careful transaction design: the stored response and the business action must not drift apart if the process crashes. The point is not a universal snippet; it is recognizing that retries require a defined semantic contract.

Use timeouts, retries, and queues deliberately

Retries are useful only when failure is likely to be temporary and the operation can tolerate repetition. Set short, explicit timeouts. Retry a limited number of times with backoff. Do not retry validation failures, authorization failures, or requests that are not idempotent.

For non-immediate work, a queue often creates a healthier boundary than synchronous HTTP. Sending an order-confirmation email should not hold up a successful checkout. Publishing an event such as OrderPlaced lets downstream workers process notifications and analytics independently.

But queues do not eliminate complexity. Consumers can receive a message more than once, messages can arrive late, and processing can fail repeatedly. Build consumers to be idempotent, send poison messages to a reviewable failure path, and monitor queue age as well as queue length.

Data ownership is where architecture becomes real

The most damaging distributed-system shortcut is the shared database. It feels efficient because every service can query the data it needs. In practice, it ties deployments together, leaks internal assumptions, and makes schema changes risky.

A service should own the data it writes. Other services should use its API, consume its events, or maintain their own read model. That may introduce eventual consistency, so the user experience must acknowledge it honestly. “Your order is being confirmed” is better than showing an irreversible status before inventory and payment have completed.

Not every query needs a real-time cross-service request. Reporting, search, and dashboards often work better from read models built from events. The trade-off is freshness and operational overhead, which should be explicit rather than accidental.

  • Keep transactional data close to the service that owns the business decision.
  • Publish stable events about facts that happened, not internal database-shaped records.
  • Version API and event contracts when changes are not backward compatible.
  • Plan reconciliation for workflows where temporary inconsistency has financial or customer impact.

Containers standardize delivery, not system design

Docker is valuable because it makes runtime dependencies repeatable. It does not make an application independently deployable, observable, or safe to operate. A containerized distributed system still needs configuration management, health checks, logs, metrics, tracing, and a reliable path for schema migrations.

Keep images small and predictable, run one primary process per container, and provide a meaningful health endpoint. A health endpoint should distinguish “the process is running” from “the application can serve required traffic.” Avoid making it depend on every optional downstream integration, or a temporary notification outage may make healthy checkout instances disappear.

Database migrations deserve particular care. Running migrations automatically from every web container risks races during a rolling deployment. Treat migrations as an explicit deployment step, make them backward compatible where possible, and use an expand-and-contract sequence: add new structures, deploy code that supports both forms, migrate data, then remove obsolete structures later.

Make failure visible before it becomes mysterious

When a request crosses several services, logs from one process are rarely enough. Carry a correlation identifier through inbound requests, outbound calls, asynchronous messages, and structured logs. Record the operation, outcome, latency, and relevant identifiers without leaking secrets or personal data.

Measure the behavior users experience: error rate, latency, queue delay, saturation, and the success of critical business flows. An API returning HTTP 200 is not necessarily healthy if orders are accumulating unprocessed in a queue.

Operational simplicity is a feature. Fewer moving parts mean fewer dashboards, alerts, credentials, deployment paths, and failure modes. A team that cannot confidently diagnose a service at 2 a.m. has not gained much from splitting it out.

The calm architecture is usually the better one

Microservices can be the right answer when independent teams and genuinely independent domains need to evolve at different speeds. They are not a badge of maturity. Mature engineering is knowing which complexity is buying real capability and which is merely relocating a function call onto the network.

Begin with clear domain boundaries, enforce ownership, design every remote operation for partial failure, and extract services only when the benefit outweighs the operational burden. The goal is not a diagram with more boxes. It is a system that remains understandable, changeable, and calm when the real world behaves imperfectly.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.