The Business Problem
FreshMarket, a growing grocery delivery service, came to MediaFront with a platform that had become a business liability. Built ten years earlier as a PHP/jQuery monolith, the codebase had accumulated so much technical debt that even simple features took weeks to ship—and those features frequently broke in production.
The impact was measurable:
- 5.2-second page load times driving mobile users to competitors
- System crashes during promotional events — the platform would buckle at 3× normal traffic
- 62% cart abandonment rate — far above the 50% industry average
- Monthly release cycles with a 40% regression rate
The brief was clear: rebuild for performance, scalability, and velocity—without taking the platform offline for months.
The Strategy: Strangle, Don't Rewrite
The fastest way to deliver value without risk is the strangler fig pattern: build a new system incrementally alongside the old one, routing traffic to the new components as they mature, until nothing is left to strangle.
We rejected a full rewrite for two reasons. First, big-bang rewrites routinely fail or deliver years late. Second, FreshMarket couldn't stop selling groceries while we worked.
Our phased approach over eight months:
- Month 1–2: Build the API layer and CI/CD infrastructure
- Month 3–4: Rebuild the product catalogue, search, and checkout in Nuxt 3
- Month 5–6: Migrate order processing and inventory to .NET 8 microservices
- Month 7–8: Decommission legacy PHP, performance optimisation, launch
An Azure API Management gateway sat in front of both systems throughout, routing based on URL path. Users never saw the migration happening.
The Technical Architecture
Backend: .NET 8 with Clean Architecture
We built the backend on .NET 8 using a clean architecture approach—domain logic isolated from infrastructure concerns, with CQRS (Command Query Responsibility Segregation) separating reads from writes at the service boundary.
// Command: write path — validated, transactional, produces domain events
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Result<int>>
{
private readonly IOrderRepository _orders;
private readonly IEventBus _events;
public async Task<Result<int>> Handle(
CreateOrderCommand cmd,
CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
if (order.IsFailure)
return Result.Failure<int>(order.Error);
await _orders.SaveAsync(order.Value, ct);
await _events.PublishAsync(new OrderCreatedEvent(order.Value.Id), ct);
return Result.Success(order.Value.Id);
}
}
// Query: read path — optimised for speed, no domain model overhead
public class GetOrderSummaryQueryHandler : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto>
{
private readonly IDbConnection _db;
public async Task<OrderSummaryDto> Handle(GetOrderSummaryQuery query, CancellationToken ct)
{
// Direct Dapper query against denormalised read model — sub-10ms
return await _db.QuerySingleAsync<OrderSummaryDto>(
"SELECT * FROM v_order_summaries WHERE id = @Id",
new { query.Id });
}
}
Key decisions that paid off: MediatR for decoupled command/query dispatch, Azure Service Bus for asynchronous cross-service communication, and Redis for hot product catalogue data with a 94% cache hit rate in production.
Frontend: Nuxt 3 with Server-Side Rendering
The customer-facing frontend was rebuilt in Nuxt 3 with SSR enabled for all product and checkout pages — critical for both SEO and perceived performance on slow mobile connections.
// Nuxt 3 composable: type-safe product fetching with caching
const useProduct = (id: string) => {
return useAsyncData(
`product-${id}`,
() => $fetch<Product>(`/api/products/${id}`),
{
// Cache in Nuxt's built-in data layer — no duplicate requests
dedupe: 'defer',
transform: (data) => ({
...data,
// Pre-format price on server to avoid hydration mismatch
formattedPrice: new Intl.NumberFormat('nl-NL', {
style: 'currency',
currency: 'EUR',
}).format(data.price),
}),
}
);
};
We implemented Tailwind CSS for consistent utility-first styling, Pinia for client-side state (cart, wishlist, user session), and lazy-loaded non-critical components to keep initial JS bundles under 120kB.
Measuring What Matters
We tracked three categories of metrics from day one:
Performance (Lighthouse, Real User Monitoring)
| Metric | Before | After | Change |
|---|---|---|---|
| Page load time (median) | 5.2s | 1.8s | −65% |
| Server response time | 850ms | 120ms | −86% |
| Lighthouse Performance | 42 | 89 | +112% |
| Core Web Vitals (LCP) | 6.1s | 1.9s | ✅ Good |
Business Metrics (3 months post-launch)
| Metric | Before | After | Change |
|---|---|---|---|
| Conversion rate | baseline | +28% | 📈 |
| Mobile orders | baseline | +47% | 📈 |
| Cart abandonment | 62% | 41% | −34% |
| Average order value | baseline | +12% | 📈 |
Engineering Velocity
| Metric | Before | After |
|---|---|---|
| Deploy frequency | monthly | 3–4× per week |
| Deploy-related incidents | baseline | −92% |
| Mean time to recovery | hours | minutes |
| Onboarding time (new dev) | ~3 weeks | ~4 days |
The Lessons That Carried Forward
Start where the customer feels the pain. We rebuilt the checkout flow in month three, before the product catalogue was even finished on the new stack. A 28% conversion lift started accumulating six months before the migration completed.
Invest in observability from week one. We instrumented every API endpoint with Azure Application Insights on day one. Without real latency data, we would have optimised the wrong things. With it, we identified that 70% of total response time came from two database queries — both fixable.
Strangler is slower to start, faster to finish. The first two months felt slow — no visible user-facing changes. But by month four, we were shipping multiple features per week to a progressively growing slice of real production traffic, validating changes with real users rather than staging assumptions.
The modernised FreshMarket platform now handles 5× the peak traffic of the old system, on 30% less infrastructure spend. More importantly, the team ships with confidence — because the architecture was built to evolve.