Iza monolita: projektiranje otpornosti sustava koji se razvija
Monoliths are often blamed for problems they did not create. A single deployable application can be fast to build, easy to understand, and entirely appropriate for a small team. The trouble begins when its boundaries stop matching the system’s changing needs: a slow reporting query delays checkout, one deployment risks unrelated features, or a third-party outage turns into a full-site incident.
Resilience is not the same as splitting everything into services. It is the ability to keep delivering the most important outcomes while dependencies, traffic patterns, and requirements change. Good architecture makes those changes cheaper, safer, and easier to reason about.
Start with pressure points, not a microservices diagram
Before extracting anything, identify where the monolith is genuinely under pressure. Look for different scaling profiles, independent release needs, conflicting reliability requirements, and ownership confusion. A payment workflow and a PDF export job may live in the same PHP application today, but they do not necessarily deserve the same runtime behavior.
A useful question is: what must continue working if this component is slow, unavailable, or being deployed? If customers can place orders without receiving an immediate confirmation email, email delivery is a strong candidate for asynchronous isolation. If inventory allocation must be correct before payment is captured, separating it prematurely may introduce more risk than it removes.
- Extract by business capability: prefer a bounded concern such as notifications, search indexing, or media processing over a technical layer such as “all database code.”
- Extract by operational need: components with distinct traffic, failure modes, or deployment cadence benefit most from independence.
- Preserve a clear source of truth: each important piece of data needs an unambiguous owner, even while systems are in transition.
Build seams inside the monolith first
The safest route beyond a monolith usually begins within it. Make modules explicit, reduce hidden coupling, and define interfaces before introducing a network boundary. This provides value even if no service is ever extracted.
In PHP, that often means moving framework-heavy controller logic toward application services with narrow dependencies. A checkout action should coordinate a use case, not directly know how email is rendered, stock is reserved, and analytics events are stored. Dependencies can then be swapped, deferred, or moved behind an API without rewriting the business flow.
final class PlaceOrder
{
public function __construct(
private OrderRepository $orders,
private InventoryGateway $inventory,
private EventPublisher $events,
) {}
public function handle(PlaceOrderCommand $command): Order
{
$order = Order::create($command->customerId, $command->items);
$this->inventory->reserve($order->items());
$this->orders->save($order);
$this->events->publish(new OrderPlaced($order->id()));
return $order;
}
}
The interfaces matter more than the syntax. An InventoryGateway can initially call local code. Later, it may call a service, use a retry policy, or expose a controlled fallback. The caller should not need to learn those operational details.
Use asynchronous work to contain failure
Synchronous chains are fragile. If an HTTP request must write an order, charge a card, generate a document, notify a warehouse, send email, and update a CRM before returning, the availability of the endpoint becomes the product of every dependency’s availability. It also produces poor latency and difficult incident diagnosis.
Move non-critical follow-up work to a durable queue. The word durable is important: publishing a message only after a database transaction commits avoids workers observing data that was rolled back. For changes that must reliably create both database state and an event, an outbox table is a practical pattern. Store the event with the business update, then let a worker publish and mark it processed.
Workers must expect duplicates and partial failure. A queue can redeliver a message after a timeout, and a process can fail after a side effect but before recording success. Design consumers to be idempotent: use a stable event identifier, enforce unique processing where appropriate, and make repeat execution harmless.
Retries need boundaries
Retrying every failure is not resilience. Retrying an invalid request wastes capacity; retrying a temporarily unavailable dependency may help. Use bounded attempts, increasing delays, and a dead-letter path for messages that require investigation. Log enough context to trace a failure, but avoid placing credentials or unnecessary personal data in logs.
For a remote API, set connection and response timeouts deliberately. A missing timeout can consume PHP workers until the entire application becomes slow. If a dependency is clearly failing, a circuit breaker or a temporary feature degradation can protect the primary workflow. “Order received; confirmation will follow” is often a better outcome than making checkout unavailable because a notification provider is down.
Let data ownership evolve carefully
Database separation is one of the hardest parts of distributed architecture. A shared database makes extraction look easy, but it keeps services coupled through schemas, migrations, locks, and undocumented queries. It is often a transitional step, not an end state.
When a capability becomes independent, give it ownership of its writes and expose the data it intends others to use through a contract. Other components may keep local read models when they need fast access. This introduces eventual consistency, so the user experience and business rules must acknowledge it. A search index may lag behind a product update; a payment ledger cannot casually do the same.
Avoid distributed transactions as a default escape hatch. Prefer explicit states and compensating actions. For example, an order can become pending_payment, then paid when payment succeeds, or payment_failed when it does not. These states make recovery visible and operationally manageable.
Make deployment boring and observable
Containers help standardize execution, but Docker does not create resilience by itself. A useful image has a predictable runtime, configuration supplied through the environment or a secret mechanism, and no hidden dependency on files created by a previous deployment. Run database migrations with care: expand schemas before code depends on them, deploy compatible code, then remove obsolete fields in a later release.
Observability should cross boundaries from the beginning. Carry a request or correlation identifier into logs, queue messages, and outgoing HTTP calls. Track the signals that help answer practical questions: is the endpoint slow, are jobs backing up, is a dependency failing, and which business operation is affected? Metrics, structured logs, and traces are most valuable when they connect technical symptoms to a specific path through the system.
- Define health checks that distinguish a running process from a component able to serve useful work.
- Set resource limits so one queue consumer or report job cannot exhaust the host.
- Test rollback and degraded behavior, not only the happy-path deployment.
- Document ownership, contracts, and recovery procedures alongside the code.
Resilience is an architectural habit
The goal is not to eliminate the monolith or to maximize the number of deployables. It is to create boundaries that reflect real responsibilities, make failures contained, and allow the system to change without turning every change into a coordinated event.
A well-structured monolith is often the first expression of that discipline. Services, queues, separate databases, and containers become useful when they solve a demonstrated problem. Architect for the next meaningful pressure point, keep the critical path small, and make recovery a designed behavior rather than an improvised one. That is how a system remains dependable while everything around it evolves.