When I first started working with microservices at Vivasoft, the promise was simple: break a monolith into smaller, independently deployable services. The reality? It's far more nuanced than that.
The Monolith Problem
Our legacy system was a classic monolith — a single Go binary handling everything from authentication to payment processing. It worked fine at 1,000 requests per second. At 10,000? Things started breaking.
// The monolith handler — doing everything in one place
func HandleRequest(w http.ResponseWriter, r *http.Request) {
user := authenticateUser(r) // Auth logic
order := processOrder(r, user) // Business logic
payment := chargePayment(order) // Payment processing
sendNotification(user, payment) // Notification
renderResponse(w, order) // Response
}
The problem wasn't just performance — it was deployment velocity. Every change to the notification system required redeploying the entire application.
Breaking It Apart
We identified four distinct bounded contexts:
| Service | Responsibility | Scale Requirements |
|---|---|---|
| Auth Gateway | Authentication & authorization | High throughput, low latency |
| Order Service | Order processing & management | Medium throughput, consistency critical |
| Payment Service | Payment processing | Low throughput, high reliability |
| Notification Service | Email, SMS, push notifications | Async, eventually consistent |
The Circuit Breaker Pattern
One of the most valuable patterns we implemented was the circuit breaker. When the payment service went down, it shouldn't cascade and take down the entire system.
type CircuitBreaker struct {
maxFailures int
timeout time.Duration
failures int
lastFailure time.Time
state State
mu sync.RWMutex
}
func (cb *CircuitBreaker) Execute(fn func() error) error {
cb.mu.RLock()
if cb.state == Open {
if time.Since(cb.lastFailure) > cb.timeout {
cb.mu.RUnlock()
cb.mu.Lock()
cb.state = HalfOpen
cb.mu.Unlock()
} else {
cb.mu.RUnlock()
return ErrCircuitOpen
}
} else {
cb.mu.RUnlock()
}
err := fn()
if err != nil {
cb.recordFailure()
return err
}
cb.reset()
return nil
}
Key Takeaways
After 18 months of running microservices in production, here are the lessons that stuck:
- Start with a modular monolith — Don't jump to microservices on day one. Build clear module boundaries first.
- Invest in observability early — Distributed tracing (we use OpenTelemetry) is not optional. Without it, debugging is a nightmare.
- Embrace eventual consistency — Not everything needs to be strongly consistent. The notification service doesn't need to be in the same transaction as the order.
- Automate everything — CI/CD pipelines, infrastructure as code, automated testing. Manual processes don't scale.
"A distributed system is one in which the failure of a computer you didn't even know existed can render your own computer unusable." — Leslie Lamport
What's Next
We're currently exploring event sourcing for our order service. Instead of storing the current state, we store every event that led to that state. It's a powerful pattern for audit trails and temporal queries — but it comes with its own complexity.
The journey from monolith to microservices isn't a one-time migration. It's an ongoing evolution of your architecture as your understanding of the domain deepens.
Have questions about microservice architecture? Feel free to reach out through the contact page.
