I’ve been part of three “microservices migrations” over the past decade. Two failed spectacularly. The third succeeded — but only because we stopped trying to build microservices and started trying to find real boundaries.
If you’re thinking the path is Monolith → Microservices and expecting it to be short and exciting, stop. The pragmatic path that works is:
Monolith → Modular Monolith → Selective Microservice Extraction
This article is a coherent, practical guide — written as a senior engineer would actually explain it — for teams who want the benefits of distribution without the debt. No buzzwords. No ideology. Just what to do, when to do it, how to test it, and when to stop.
TL;DR — The single sentence strategy
Refactor the codebase into honest modules, prove the boundaries at runtime inside one deployable (modular monolith), and only extract services where there is a clear operational, scaling, or organizational benefit. Use interfaces, contract tests, feature flags, shadow traffic and a strangler approach to reduce risk.
Why most migrations fail (and how to avoid each failure)
1. Over-decomposition too early.
Teams split because a class looks big, not because there’s a true bounded context. Result: a distributed monolith that is harder to change.
Avoid it by: draw boundaries with domain experts first, then refactor the monolith around those boundaries before extracting.
2. Distributed monolith hell.
Many small services calling each other synchronously create cascades and debugging nightmares.
Avoid it by: designing asynchronous communication patterns where possible, explicit APIs, and avoiding chatty sync RPC between services.
3. Team readiness gaps.
Microservices demand operational maturity (CI/CD, observability, SLOs, runbooks, service ownership). If you don’t have that, stay modular.
Avoid it by: improving platform and operational practices while still on a single deployable.
4. Shared database addiction.
“We’ll share the DB for now” becomes permanent coupling.
Avoid it by: extracting data ownership and contracts; require a migration plan and a dual-write strategy if you must split data.
Step 1 — Modularize the monolith first (the low-risk win)
The goal here is not perfection: it’s clarity. Make the boundaries explicit in your code and in runtime behavior.
What to do (practically):
- Create bounded contexts inside the repo. Use namespaces and clear directory layout (example below).
- Introduce repository interfaces (ports) and only allow other modules to depend on the interface, not concrete types.
- Move infra code to the edges (persistence, HTTP, queues). Domain code should be pure.
- Add module integration tests — prove the module API in process before you consider network extraction.
Example layout (Symfony):
src/
Catalog/
Domain/
Application/
Infrastructure/
UI/
Orders/
Domain/
Application/
Infrastructure/
UI/
Users/
Shared/
Why this matters: You learn the coupling cost up front. Fixes are cheap when everything’s in one repo and one process.
Interfaces and domain code — real examples (Symfony)
Keep domain logic independent and testable. Depend on interfaces.
// src/Catalog/Domain/ProductRepository.php
namespace App\Catalog\Domain;
interface ProductRepository
{
public function findById(ProductId $id): ?Product;
public function findByCategory(CategoryId $categoryId): array;
public function save(Product $product): void;
}
// src/Catalog/Domain/Product.php
namespace App\Catalog\Domain;
class Product
{
public function __construct(
private ProductId $id,
private string $name,
private Money $price,
private CategoryId $categoryId,
private int $stock
) {}
public function reserve(int $quantity): void
{
if ($this->stock < $quantity) {
throw new \DomainException('Insufficient stock');
}
$this->stock -= $quantity;
}
}
Practical rules:
- One module’s
Domainmust neveruseanother module’sDomainobjects directly. - Use small, well-documented interfaces for cross-module calls.
- Keep infrastructure implementations in
Infrastructureand register them in the DI container (services.yaml) under interface types.
Step 2 — Strangler Fig pattern: extract incrementally and safely
Once the monolith is modular, you can start pulling functionality out behind a façade.
Key practices:
- Put an API gateway / router in front (Traefik, Envoy, etc.) and route traffic per path.
- Start by routing new or versioned paths to the new service.
- Use feature flags to control who hits the new service.
- Shadow traffic heavily to compare behavior before cutover.
- Use an anti-corruption layer (ACL) to translate between old and new models.
Anti-corruption layer (Symfony example):
// src/Catalog/Infrastructure/LegacyProductAdapter.php
namespace App\Catalog\Infrastructure;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use App\Catalog\Domain\Product;
use App\Catalog\Domain\ProductId;
use App\Catalog\Domain\CategoryId;
use App\Catalog\Domain\Money;
class LegacyProductAdapter
{
public function __construct(
private HttpClientInterface $client,
private string $baseUrl
) {}
public function getProduct(string $id): Product
{
$response = $this->client->request('GET', $this->baseUrl . '/legacy/products/' . $id);
$data = $response->toArray();
return new Product(
new ProductId((string)$data['prod_id']),
trim($data['prod_name']),
new Money($data['price_cents'], 'USD'),
new CategoryId((string)$data['cat_id']),
$data['qty_on_hand']
);
}
}
Why ACLs matter: They prevent legacy shape from leaking into your new domain.
Feature flags and shadow traffic — the safety net
Use feature flags for progressive rollout and shadow traffic to validate responses without impacting users.
Feature flag example (LaunchDarkly client use is illustrative):
// src/Shared/FeatureFlags.php
namespace App\Shared;
class FeatureFlags
{
public function __construct(private \LaunchDarkly\LDClient $client) {}
public function useNewCatalogService(string $userId): bool
{
return $this->client->variation(
'use-new-catalog-service',
['key' => $userId],
false
);
}
}
Routing in controller (inject dependencies via DI):
// src/UI/Controller/ProductController.php
namespace App\UI\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
class ProductController extends AbstractController
{
public function __construct(
private \App\Shared\FeatureFlags $flags,
private \App\Catalog\Infrastructure\LegacyProductAdapter $legacyService,
private \App\Catalog\Client\CatalogClient $newCatalogClient
) {}
public function getProduct(string $id): JsonResponse
{
$user = $this->getUser();
$userId = $user ? $user->getId() : 'anon';
if ($this->flags->useNewCatalogService($userId)) {
$product = $this->newCatalogClient->getProduct($id);
} else {
$product = $this->legacyService->getProduct($id);
}
return $this->json($product);
}
}
Shadow traffic: dispatch an async task to call the new service and diff results. Use Messenger to send the shadow request off the critical path.
$this->messageBus->dispatch(new ShadowProductCheckMessage($id, $product));
Data migrations: dual-write and the cutover plan
Principles:
- Treat data as owned by a service. If you extract a service, its owned data should move with it (or be behind a stable API).
- Dual-write during migration — write both old and new stores, but make legacy writes secondary (log and continue).
- Consumers must be migrated one by one; avoid long periods of shared writes without a plan to remove them.
Dual-write example:
public function createOrder(CreateOrderRequest $request): Order
{
$order = $this->orderRepository->save(Order::fromRequest($request));
try {
$this->legacySync->syncOrder($order); // best effort
} catch (\Throwable $e) {
$this->logger->warning('Failed to sync to legacy', ['orderId' => $order->getId(), 'err' => $e->getMessage()]);
}
return $order;
}
Cutover checklist:
- Consumers migrated and passing contract tests
- Shadow traffic shows parity
- Feature flag at 100% for a sustained period
- Legacy sync removed and monitored
- Observability shows no regressions
Eventual consistency — design for it, don’t pretend otherwise
If you split data, you will get eventual consistency. Design for idempotency, retries, and clear user messaging (e.g., “price confirmed at checkout”).
Event-driven cache invalidation example:
class ProductPriceUpdatedEvent
{
public function __construct(public string $productId, public int $newPrice) {}
}
class PriceUpdateHandler
{
public function __invoke(ProductPriceUpdatedEvent $event)
{
$this->cache->delete($event->productId);
}
}
Use durable event delivery (e.g., Kafka, RabbitMQ) between services; avoid fragile synchronous cross-service reads in hot paths.
Contract testing: catch integration errors early
Contract tests verify the consumer/provider contract without deploying both together. Pact PHP or a similar tool should be in CI for any boundary you extract.
Pact PHP (conceptual) test outline:
public function testCatalogContract()
{
$pact = new PactBuilder();
$pact->uponReceiving('a request for product 123')
->withRequest('GET', '/api/products/123')
->willRespondWith(200, ['id' => '123', 'name' => 'Widget', 'price' => 1999]);
// verify consumer against mock provider
$client = new CatalogClient($pact->getMockServerUrl());
$product = $client->getProduct('123');
$this->assertEquals('123', $product['id']);
}
Run consumer contract tests in the consumer repo CI; run provider verification in provider CI. This prevents many integration surprises.
Observability, SLOs and operational readiness
You cannot extract services you cannot observe.
Minimum production observability checklist:
- Request traces (distributed tracing)
- Per-service metrics: success rate, latency (p50/p95/p99), error rate
- Alerting tied to SLOs, not arbitrary CPU thresholds
- Dashboards for owners
- Runbooks for common failures
SLO guidance: pick SLOs tied to user journeys (checkout end-to-end), not just per-service. That aligns incentives.
When to stop: the modular monolith is a valid destination
Not everything should be a microservice. Stay modular if:
- Team < ~30 engineers and coordination is cheap
- You don’t need different scaling profiles
- You can deploy frequently and reliably
- Compliance does not force isolation
Extraction is expensive: only do it where there is clear business value (scaling, compliance, independent ownership).
Migration timeline (practical, realistic)
A realistic, low-risk timeline for a medium codebase:
- Months 0–2 — Discovery: map domains, interview product and engineers, identify high-velocity modules.
- Months 2–6 — Modularization: reorganize code, add interfaces, write module tests, integrate CI for module boundaries.
- Months 6–8 — First extraction: pick a small, well-bounded module (auth is common). Strangler Fig route, shadow traffic, canary, cutover.
- Months 9–12 — More extractions: tackle modules where data and scale justify the work.
- Month 13+ — Operate & evaluate: most modules often stay in the monolith; extract only when justified.
This is a marathon, not a sprint. The work you do while still in a monolith saves enormous refactor cost later.
Practical pitfalls and checks (operational checklist)
Before extracting a module, verify:
- [ ] Clear domain boundaries agreed with product/SEs
- [ ] Public interface documented and covered with tests
- [ ] Metric coverage for consumer and provider
- [ ] Contract tests in CI for both sides
- [ ] Feature flags and shadow traffic in place
- [ ] Runbooks and escalation paths for the new service
- [ ] Data ownership plan (migration/dual-write/cutover)
- [ ] Rollback and retry strategies defined
If any item is missing, postpone extraction and invest in the monolith refactor.
Final words — pragmatic, not dogmatic
Microservices are a tool, not a religion. The path that succeeds is slow, boring, and disciplined:
- Stop rushing to network boundaries.
- Modularize the monolith and learn the domain at code speed.
- Extract only when the evidence (scaling, team ownership, compliance) is clear.
- Use feature flags, shadow traffic, ACLs, and contract testing to reduce blast radius.
- Invest heavily in observability and runbooks before you extract.
If you follow that, distribution becomes a tactical advantage rather than a long-term liability.
Top comments (0)