Building Resilient Microservices: Lessons from a Production Migration
The monolith was working fine — until it was not
For three years, our payment processing platform ran as a single Laravel application. It handled 50,000 transactions per day, had an uptime of 99.97%, and the team of five engineers knew every corner of the codebase. By most measures, it was a success.
Then the business grew. We added multi-currency support, recurring billing, merchant onboarding, and a reconciliation engine. Each feature added complexity to an already dense codebase. Deployments that once took 15 minutes started taking an hour. A bug in the invoicing module could bring down the entire payment gateway. The team had grown to twelve engineers, and merge conflicts became a daily ritual.
The breaking point came when a memory leak in the reporting service crashed the payment processor during peak hours. That incident made the cost of coupling visible to everyone.
Choosing the right boundaries
The biggest mistake teams make with microservices is splitting by technical layer — a service for the database, a service for the API, a service for the queue. This creates distributed monoliths: all the overhead of microservices with none of the benefits.
We used Domain-Driven Design to identify bounded contexts. The payment engine, merchant management, invoicing, reconciliation, and notification systems each became a service with its own data store and deployment pipeline.
The key insight was that bounded contexts are discovered, not invented. We spent three weeks mapping actual business processes before writing a single line of infrastructure code.
The strangler fig pattern saved us
We did not rewrite the monolith. Instead, we used the strangler fig pattern: new features went into new services, and we gradually extracted existing functionality as the team gained confidence.
The first extraction was the notification service. It was relatively isolated, had clear input/output boundaries, and the team could validate the approach without risking the core payment flow.
# Docker Compose for local development
services:
payment-service:
build: ./services/payment
depends_on:
- payment-db
- redis
notification-service:
build: ./services/notification
depends_on:
- redis
- mailhog
What we would do differently
The migration took nine months. Here is what we learned:
- Start with observability, not architecture. We added distributed tracing before splitting services. Without it, debugging cross-service failures would have been impossible.
- Invest in contract testing early. We lost two weeks to integration bugs that contract tests would have caught immediately.
- Keep the monolith running. We maintained the ability to fall back to monolith deployments for the first six months.
- Avoid premature optimisation. Our first service communication was synchronous HTTP. We moved to event-driven messaging only when we had evidence that synchronous calls were a bottleneck.
The result
Today, the platform handles 200,000 transactions per day across five services. Deployments take under five minutes. The team can work on independent services without stepping on each other. Most importantly, a failure in one service no longer cascades to the others.
Microservices are not a silver bullet. But for teams facing genuine scaling challenges — in complexity, not just traffic — they offer a structured way to manage that complexity.