Let me tell you something that every senior engineer eventually learns: nobody wakes up one morning and decides to migrate away from a monolith for fun. You do it because the monolith is actively hurting your team, your deployments, and your customers. That’s exactly where we were about two years ago, and I want to walk you through how we actually pulled it off — not the sanitized conference talk version, but the real one.
The Monolith Was Screaming for Help
Our monolith was a Node.js Express application that had been growing for three years. It started clean. It didn’t stay that way. Here’s what the symptoms looked like:
- Deploy fear: A change to the payment module required deploying the entire application. One Friday afternoon, a CSS fix in the admin panel brought down the checkout flow. That was a fun weekend.
- Tangled dependencies: The order service directly imported the user repository. The notification module knew about inventory levels. Everything knew about everything.
- Team bottlenecks: Three squads working on the same codebase meant constant merge conflicts and a deploy queue that sometimes backed up for days.
- Performance ceiling: We couldn’t scale the reporting module independently from the real-time checkout flow. Both lived in the same process, competing for the same resources.
The monolith wasn’t just technical debt — it was organizational debt. And that’s when you know it’s time.
Why BFF (And Not Just “Microservices”)
The knee-jerk reaction is “let’s do microservices!” But microservices without a clear frontend strategy just moves the spaghetti from the backend to the API layer. We had three clients: a web SPA (Angular), a mobile app (React Native), and an internal admin dashboard. Each one needed different data shapes, different auth flows, and different performance characteristics.
That’s where Backend for Frontend comes in. The idea is simple: each client gets its own lightweight backend that orchestrates calls to domain services and shapes the response for that specific client’s needs.
The key insight was this — the BFF is not a domain service. It has zero business logic. It’s an orchestration and transformation layer. The moment you put business rules in a BFF, you’ve just built another monolith with extra network hops.
Hexagonal Architecture in the Domain Services
For the actual domain services (orders, inventory, users, payments), we went with hexagonal architecture. Here’s why: we wanted the domain logic to be completely decoupled from infrastructure concerns. Database? That’s an adapter. Message queue? Adapter. External payment gateway? Adapter.
Here’s the structure we used in NestJS:
src/
├── domain/
│ ├── entities/
│ │ └── order.entity.ts
│ ├── ports/
│ │ ├── order.repository.port.ts
│ │ └── payment-gateway.port.ts
│ └── use-cases/
│ └── create-order.use-case.ts
├── infrastructure/
│ ├── adapters/
│ │ ├── postgres-order.repository.ts
│ │ └── stripe-payment.gateway.ts
│ └── modules/
│ └── order.module.ts
└── application/
└── bff/
└── web-bff.controller.ts
The port definition is clean and infrastructure-agnostic:
// domain/ports/order.repository.port.ts
export interface OrderRepositoryPort {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<Order>;
findByCustomer(customerId: string, pagination: PaginationParams): Promise<PaginatedResult<Order>>;
}
And the BFF controller orchestrates without owning logic:
// application/bff/web-bff.controller.ts
@Controller('web/orders')
export class WebBffController {
constructor(
private readonly createOrderUseCase: CreateOrderUseCase,
private readonly getCustomerUseCase: GetCustomerUseCase,
) {}
@Post()
async createOrder(@Body() dto: CreateOrderDto, @Req() req: AuthRequest) {
const order = await this.createOrderUseCase.execute({
customerId: req.user.id,
items: dto.items,
shippingAddress: dto.shippingAddress,
});
// BFF responsibility: shape response for the web client
return {
orderId: order.id,
status: order.status,
estimatedDelivery: order.estimatedDelivery?.toISOString(),
items: order.items.map(item => ({
name: item.productName,
qty: item.quantity,
total: formatCurrency(item.totalPrice),
})),
};
}
}
Notice: the BFF calls the use case, then transforms the response for the web client. The mobile BFF would return a different shape — maybe with less data to save bandwidth, or with image URLs optimized for mobile screens.
The Migration Strategy: Strangler Fig
We didn’t do a big bang migration. That’s how you burn everything down. We used the strangler fig pattern, and here’s how it worked in practice:
- API Gateway as the router: We put an API Gateway (Kong) in front of everything. Initially, 100% of traffic went to the monolith.
- Extract one domain at a time: We started with the notification service because it was the most isolated and lowest risk. We built it as a standalone NestJS service with hexagonal architecture.
- Dual-write period: For about two weeks, both the monolith and the new service processed notifications. We compared outputs. Found three bugs in the new service this way.
- Route switch: Once confident, we updated the gateway to route notification traffic to the new service. The monolith code stayed in place but dormant.
- Repeat: Orders was next, then inventory, then payments (the scariest one, saved for last).
The whole migration took about eight months. Not eight days, not eight weeks. Eight months.
The Mistakes We Made
I’d be lying if I said it went smoothly. Here are the mistakes that cost us real time:
Mistake 1: Shared database too long. We initially let the new order service read from the monolith’s database. “We’ll separate it later,” we said. Later took four months, and during that time we had schema coupling that defeated half the purpose of extracting the service.
Mistake 2: BFF doing too much. In the first iteration, our web BFF started accumulating business logic. “It’s just a small validation,” someone said. Then another. Within a month, the BFF had 40 lines of business rules that should have lived in the domain service. We had to refactor it back out.
Mistake 3: Not investing in observability early. With a monolith, you can follow a request in a single log stream. With distributed services, you need distributed tracing from day one. We added OpenTelemetry in month three. Should have been day one.
Mistake 4: Underestimating data consistency. Going from a single database transaction to eventual consistency across services was a mental shift for the whole team. We had to implement the saga pattern for the order-payment-inventory flow, and it took three iterations to get right.
The Trade-offs Nobody Tells You About
Let me be real about what you’re signing up for:
- Operational complexity goes up significantly. You now have 6 services instead of 1. That’s 6 CI/CD pipelines, 6 sets of logs, 6 health checks, 6 things that can fail independently.
- Local development gets harder. Docker Compose became mandatory. Onboarding a new developer went from “clone and run” to a 45-minute setup process (which we eventually automated).
- Network is now a failure mode. Service-to-service calls can timeout, fail, or return stale data. You need retry logic, circuit breakers, and fallback strategies everywhere.
- Testing changes fundamentally. Unit tests are easy. Integration tests across services? That requires contract testing (we used Pact) and a proper staging environment.
But here’s the other side:
- Teams move independently. The payments squad deploys three times a day without coordinating with anyone.
- Scaling is surgical. Black Friday? Scale the order service and payment service. Leave the admin BFF alone.
- Failure is isolated. The notification service went down for 20 minutes last month. Orders kept flowing. In the monolith era, that would have been a full outage.
The Honest Takeaway
If your monolith is working and your team is small, don’t migrate. Seriously. A well-structured monolith beats a poorly implemented distributed system every single day. We migrated because we had real, measurable pain — not because microservices were trendy.
But if you’re feeling that pain — deploy fear, team bottlenecks, scaling ceilings — then a thoughtful migration using BFF + hexagonal architecture + strangler fig is a battle-tested approach. Just go in with your eyes open about the trade-offs, invest in observability from day one, and for the love of everything good in engineering, keep business logic out of the BFF.
The best architecture is the one that lets your team ship with confidence. Everything else is details.