Razvoj

Beyond Microservices: Architecting for Real-World System Complexity

Iznad mikroservisa: projektiranje za stvarnu složenost sustava

Microservices are often presented as the natural next step after a monolith: split the application, deploy services independently, and let teams move faster. In practice, that change can replace one kind of complexity with several harder ones. The codebase may become smaller per service while the system becomes more difficult to understand, test, operate, and evolve.

The useful question is not “monolith or microservices?” It is: where does complexity belong, who owns it, and can the team reliably manage it? Good architecture makes important work obvious. Bad architecture spreads critical behavior across places that are individually reasonable but collectively opaque.

Complexity does not disappear when services split

A modular PHP application running as one deployable unit has real constraints: releases are shared, resource usage is shared, and a failure in one area can affect another. But it also has valuable properties. A request can be traced in one process. A database transaction can enforce an invariant. Refactoring a domain boundary is usually a code change rather than a distributed migration.

Once a capability becomes a separate service, its interface becomes a long-term contract. Calls can time out. Messages can arrive twice. A deployment can leave two versions running at once. A database write may succeed while the event intended to notify another service fails. None of these are arguments against services; they are the normal cost of distribution.

A service boundary is therefore justified by more than directory structure. It should represent an independently changing capability, an ownership boundary, or a scaling and reliability need that cannot be handled cleanly inside the existing application.

Start with a well-structured monolith

“Monolith” should not mean “one enormous controller layer connected directly to every table.” A maintainable monolith can have strong internal boundaries: modules for billing, identity, catalog, and notifications; explicit application services; stable interfaces; and tests that exercise business rules without booting the whole web stack.

For a PHP backend, this often means keeping framework concerns at the edge. A controller translates HTTP input into an application command. Domain code decides what is valid. Infrastructure code persists data or calls outside systems. The exact pattern matters less than preventing business rules from being copied into controllers, queue handlers, CLI commands, and scheduled jobs.

Internal modularity creates options. If a module later needs independent deployment, its dependencies are already visible. If it never needs extraction, the team still benefits from clear ownership and fewer accidental cross-module queries.

Make dependencies deliberate

A practical rule is that one module should not reach into another module’s persistence details. If the orders module needs customer information, expose a small application-level interface rather than allowing arbitrary reads of customer tables. That can feel slower than joining tables directly, but it reveals coupling before that coupling becomes a production constraint.

Database boundaries deserve the same care. A shared database is not automatically a failure, especially in a modular monolith. The problem begins when every component treats every table as public. Assign ownership of tables and migrations, document the few sanctioned integration paths, and resist turning reporting queries into hidden dependencies for operational workflows.

Use APIs as contracts, not plumbing

An API is an agreement about meaning, not merely JSON shape. Fields need ownership, lifecycle rules, validation, and compatibility expectations. A response called status is ambiguous until consumers know its allowed values, whether new values may appear, and what actions are safe for each value.

For synchronous HTTP APIs, design for partial failure from the beginning. Set connection and request timeouts. Handle non-success responses intentionally. Use retries only when the operation is safe to repeat or protected by an idempotency key. Retrying a read after a transient failure can be sensible; blindly retrying a payment creation can create duplicate work.

$response = $client->request('POST', '/orders', [
    'headers' => [
        'Idempotency-Key' => $requestId,
    ],
    'json' => $payload,
    'timeout' => 5,
]);

if ($response->getStatusCode() === 409) {
    // Retrieve the existing result associated with the idempotency key.
}

The example is intentionally incomplete: the client still needs exception handling, logging that avoids sensitive values, and a clear policy for ambiguous outcomes. If the network fails after the remote system accepts the request, the caller may not know whether the operation happened. Idempotency is how the system turns that ambiguity into a recoverable state.

Choose asynchronous work for the right reasons

Queues are valuable when work can happen later, when load needs smoothing, or when a request should not wait for a slow external dependency. They are not a shortcut around consistency. A queued job can be delayed, run more than once, or fail after a related database transaction commits.

Design workers as if every message may be delivered again. Store enough state to identify completed work, make handlers idempotent, and define what should happen after retries are exhausted. A dead-letter queue is not a solution by itself; it is a place where unresolved work waits for an operational decision.

When a database change must eventually publish an event, an outbox pattern is often more reliable than attempting both actions in one request. The application writes the business change and an outbound event record in the same database transaction. A separate publisher sends unsent records and marks them as published only after a successful handoff. This does not provide magical exactly-once delivery, but it gives the system a durable, inspectable recovery path.

Operational simplicity is an architectural feature

Docker can make local environments reproducible, but containers do not remove operational responsibilities. A production-ready service still needs configuration management, health checks, structured logs, metrics, backups, migration discipline, and a rollback strategy.

Database migrations are especially easy to underestimate. An application deployment may briefly run old and new code simultaneously. A safe migration sequence commonly adds a nullable column or new table first, deploys code that can work with both shapes, backfills in controlled batches, and removes old structures only after they are unused. Renaming a heavily used column in one step is often an availability risk disguised as cleanup.

  • Set explicit resource limits and observe memory growth in long-running PHP workers.
  • Separate readiness from liveness: a process can be alive while unable to serve useful traffic.
  • Correlate logs, request IDs, queue jobs, and outbound calls so failures can be followed across boundaries.
  • Practice restoring backups; a backup that has never been restored is an assumption, not a recovery plan.

Optimize the system people can change safely

Performance work should begin with a measured bottleneck. It may be a missing index, repeated query, unbounded result set, slow serialization path, exhausted connection pool, or an external call on the request path. Caching can help, but it also introduces invalidation rules and stale-data behavior that must be designed, not hoped away.

The same pragmatism applies to decomposition. Extract a service when the current boundary creates repeated operational pain, conflicting release cadence, isolated scaling needs, or an ownership model that the existing application cannot support. Extracting because a diagram looks modern is rarely enough.

Architecture is not the number of containers in production. It is the set of decisions that lets a team reason about change, failure, and recovery. A disciplined monolith can be an excellent system. A carefully chosen service can be transformative. The enduring goal is simpler: build boundaries that match reality, then make the hard paths visible before production has to reveal them for you.

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.