Decomposing Monoliths: A Pragmatic Path to Microservices
A monolith is not a failure. It is often the fastest way to turn a useful idea into a working product: one deployment, one codebase, one database, and fewer moving parts to understand. Trouble starts when the application grows beyond the boundaries its original structure can comfortably support. Releases become risky, a small change requires knowledge of unrelated areas, and teams spend more time coordinating than delivering.
Microservices can help, but they are not a cure for every painful codebase. They replace in-process complexity with distributed-systems complexity: network failures, versioned contracts, asynchronous workflows, observability, and operational overhead. The pragmatic goal is not to “break up the monolith.” It is to improve the system’s ability to change safely.
Start by finding the pressure points
Do not begin with a technology diagram. Begin with evidence. Which parts of the application change most often? Which deployments are dangerous? Where do separate teams block one another? Which workloads need independent scaling? Those answers reveal whether a service boundary would solve a real problem or merely create a new one.
In a PHP application, a typical pressure point might be a checkout flow that touches catalog pricing, inventory, payments, notifications, and reporting. Extracting all of those concerns at once is an invitation to create a distributed monolith. A better first candidate is usually a capability with a clear purpose, limited dependencies, and a team that can own it end to end.
Good early candidates often include:
- Generating and delivering notifications.
- Processing media or document conversions.
- Search indexing and query serving.
- Export generation and other long-running background work.
- A distinct integration boundary, such as a payment-provider adapter.
These areas tend to have naturally asynchronous work, measurable load, and fewer reasons to share transactional state with the rest of the application.
Make the monolith modular before extracting anything
A tangled monolith does not become clean simply because its folders are moved into separate repositories. First create boundaries inside the existing application. Group code by business capability rather than by technical layer alone. Keep controllers thin, place application workflows behind explicit interfaces, and prevent modules from reaching into each other’s persistence models.
For example, an order module should not directly modify inventory tables or invoke a notification mailer. It can publish an intent such as OrderPlaced. Within the monolith, another module may handle that event in the same process. Later, that handler can move behind a queue without forcing the ordering code to learn about broker details.
final class PlaceOrder
{
public function __construct(
private OrderRepository $orders,
private EventPublisher $events,
) {
}
public function handle(PlaceOrderCommand $command): Order
{
$order = Order::place($command->customerId, $command->items);
$this->orders->save($order);
$this->events->publish(new OrderPlaced($order->id()));
return $order;
}
}
The exact framework is less important than the dependency direction. Business code should depend on stable abstractions, while HTTP clients, queues, ORM models, and provider SDKs stay at the edges. This makes extraction a deliberate infrastructure change rather than a rewrite of the business rules.
Extract by capability, not by database table
“One service per entity” sounds tidy but usually produces chatty APIs and fragile workflows. Customers, orders, products, and payments are data concepts; they are not automatically service boundaries. A useful boundary owns a business capability, its rules, and the data needed to enforce those rules.
Once a service owns data, other services should not write directly to its database. Direct shared-database access creates hidden coupling: a schema migration in one team can silently break another team’s production workload. Expose a contract instead, whether that is an HTTP API, a message stream, or both.
That does not mean every request must become remote. A practical transition often leaves the main application and the newly extracted service sharing a database temporarily, with a clearly stated deadline and a migration plan. Treat that arrangement as scaffolding. If it becomes permanent, the service cannot truly evolve independently.
Design contracts for change
A service API should describe business actions, not leak its storage layout. Prefer an endpoint or command such as POST /payment-authorizations over an API that asks callers to manipulate payment rows. Return stable identifiers and explicit states. Add fields compatibly, tolerate unknown fields where appropriate, and version only when a compatible evolution is no longer possible.
Network calls can fail after the remote service has completed the work but before the caller receives a response. For operations that must not happen twice, support idempotency. A client can attach a unique key to a payment request; the service records the result associated with that key and returns it on a retry instead of charging again.
Accept eventual consistency where it belongs
A single database transaction is convenient because all related changes either succeed together or fail together. Across services, that convenience disappears. Trying to recreate it with distributed transactions usually increases fragility and reduces availability.
Instead, identify the invariants that truly require immediate consistency. Reserving stock before confirming an order may be one. Updating a dashboard count usually is not. For the latter, publish an event and let the reporting service update its own read model asynchronously.
Reliable event publication needs more than writing to a queue after a database commit. If the process fails between those two steps, the database change exists but the event is lost. The outbox pattern addresses this: write the business change and an event record in the same local transaction, then have a worker deliver pending records. Consumers must still be idempotent, because delivery can occur more than once.
Build operational discipline alongside the services
Each new service adds deployment, configuration, logging, health checks, metrics, and alerting responsibilities. Docker can make local development repeatable, but a container is not an operating model. Teams need a clear way to answer: what request failed, which dependency caused it, and whether a retry is safe?
Use structured logs with request or correlation identifiers. Measure latency, error rates, queue depth, and dependency failures. Set timeouts on outbound calls; an absent timeout can exhaust PHP workers while they wait for an unhealthy dependency. Retry only transient failures, use bounded backoff, and avoid retrying non-idempotent actions unless the receiving service explicitly supports safe deduplication.
Deploy incrementally. Route a small, reversible slice of traffic through the new path, compare outcomes, and retain a rollback path while confidence grows. Feature flags and contract tests are valuable here: the first controls exposure, while the second verifies that consumers and providers still agree on behavior.
Keep the architecture earned
The most successful microservice journey is usually unglamorous. It begins with a modular monolith, extracts one boundary that has earned independence, and learns from the resulting operational cost. Some domains will remain together because their rules and transactions belong together. That is sound engineering, not incomplete transformation.
Architecture should make the next important change easier than the last one. If a smaller, better-structured monolith achieves that, keep it. If an independently owned service genuinely reduces coordination, deployment risk, or scaling pressure, extract it carefully. The destination is not a diagram filled with boxes; it is a system that remains understandable, dependable, and ready to evolve.