Skip to content

work / mvc-to-api-migration · engineering case study · 2026-05

MVC → API Strangler Migration — Retiring a Monolith Without Going Dark

Retiring a five-layer MVC monolith one seam at a time — the old Razor pages keep serving live traffic while a new Clean-Architecture core is grown in underneath them, endpoint by endpoint, with a layer-by-layer parity audit as the gate for calling anything "migrated."

Sole engineer of Partners.com.bd — I design, build, and run the whole platform end to end: the Flutter mobile apps, the .NET Clean-Architecture API, and the migration off the legacy `OnlineShop` MVC monolith. This case study is that migration: how a live marketplace moves from a view-centric monolith to a contract-centric core without a big-bang rewrite and without going dark.

[dotnet][clean-architecture][mediatr][migration][sqlserver]

5 → 3

layers, the dead pass-through service deleted

3–4 → 1

DB round trips per ad-detail page

2 → 1

DB round trips per listing page

15

ranked defects catalogued in the old ad path

string → int

filter keys, index-seekable at last

In one minute

You cannot stop a running marketplace to rewrite it. So you do the opposite of a rewrite: you grow a new system around the old one and let it strangle the old one branch by branch — the strangler-fig pattern.

Concretely: the new JSON API — the one the Flutter apps talk to — is built directly on a fresh Clean-Architecture core, where a single _mediator.Send(...) reaches a MediatR handler that is the use case. The old system points at that same core: each Razor controller calls that same _mediator.Send(...) and maps the result back onto its view — so the page and its SEO-friendly URL stay exactly as they are while the data path underneath them runs on the new core. The end state is one business core reached from two shells — the mobile API and the web's Razor controllers — with a parity audit gating every endpoint's crossover so the new path provably reproduces the old behavior.

01

A monolith where the service layer did nothing and the repository did everything

The legacy OnlineShop MVC app is a five-layer monolith with one layer that earns nothing at all. A request flows Razor view → area controller → service → repository → Dapper/SP → SQL, but the service layer is literal one-line delegation:

public async Task<ServiceResponse<PostViewModel>> AllPost(...)
    => await _homeRepository.AllPost(...);

No validation, no business rules, no transformation — pure indirection. All the real logic is crammed into the repository, and the controller is view-centric: it builds ViewBags, pagers, and category UI state. Filtering is done with strings — the route text is the filter value (Ads/Dhaka/VehicleService), so the repository strips whitespace with regex and the SP has hardcoded sentinel detection (IF @Category = 'all' SET @Category = NULL). It is a system that works, ranks on Google, and takes money — and is also full of the exact defects a monolith accretes when the service layer is decoration and the SQL is where everyone actually edits.

symptom → cause

An ad-detail page cost four database round trips

Opening one vehicle detail page fired: (1) resolve the route slug to IDs, (2) fetch the vehicle by CategoryTablePostId, (3) fetch subcategory field-visibility for the view — and, hidden inside the repository, (4) a synchronous GetValidShopNameForRoute lookup, a textbook N+1. The detail SP also ran with commandTimeout: 0 — an unbounded wait that ties up a pooled connection indefinitely under a slow query.

symptom → cause

The click counter had a race built into the SQL

The old detail SP incremented views with a subquery-read-then-write — SET Count = (SELECT Count ...) + 1 — wrapped in a transaction that also held the read. Two concurrent views read the same value and one increment is lost, and the transaction escalates locks around a pure read the whole time.

symptom → cause

A typo in a category name silently returned zero ads

Filters were NVARCHAR comparisons against denormalized columns (DistrictName, Category, SubCategoryName). No foreign-key index could be used, whitespace and casing mattered, and a mistyped category didn't error — it just returned an empty page. There was no server-side validation anywhere: pageIndex = -1000 or a 10 KB search string reached the stored procedure untouched.

02

Strangle the data path — don’t rewrite the shell

A rewrite is the tempting move and the wrong one: you cannot take a marketplace offline for six months, and a parallel greenfield app that has to reach feature-parity before any value ships is how migrations die. The strangler thesis is different — migrate the data path, not the UI, and route both the old shell and the new one through a single core.

So the design collapses to one seam. The MediatR handler is the use case — validation runs in the pipeline before it, the repository does only data access after it, and there are no pass-through layers in between. The thin new API controller reaches that handler with _mediator.Send(query) for the mobile apps, and each surviving MVC controller calls the same _mediator.Send(query) and maps the result back onto its Razor ViewModel, one endpoint at a time. The result is one business core, two shells, the old five-layer path retired — with a parity audit confirming each endpoint's crossover before it's declared done.

01

The strangler seam

The new API controller calls `_mediator.Send(...)` into one Application core and returns JSON; each MVC controller calls the same seam and maps the DTO back onto its Razor `ViewModel`. Migrating an endpoint is rewiring a controller, not rewriting a page.

02

Handler *is* the use case

MediatR vertical slices replace the pass-through service layer entirely. `LoggingBehavior → ValidationBehavior → Handler`; the handler orchestrates, the repository interface (declared in Application) does only data access.

03

Per-category vertical slices

`Features/Ads/{Vehicles,Properties,Electronics,Lifestyle,Jobs}`, each with its own repository interface, query, validator, DTO, and SP. A Vehicle handler never sees a Jobs method; Jobs gets `MinSalary/MaxSalary` while the rest use `MinPrice/MaxPrice`.

04

One SP per category

Each replaces a slice of the single fat `Get_AllAdsPage_AllTypeOfAds`. Integer FK filters, `OFFSET/FETCH` instead of temp tables, no transaction around reads, items + promoted sections + count returned in one call.

05

Immutable DTOs

One shared sealed `AdListingItemDto` with `bool` flags replaces four mutable ViewModels with `int` flags — three near-identical promoted-section classes plus the 55-property main listing model. Detail DTOs are flat records with lookups pre-resolved by the SP — no manual image-list building in C#.

06

Validation in the pipeline

FluentValidation runs via `ValidationBehavior` before any handler: `Page ≥ 1`, `PageSize ∈ [1,100]`, `MaxPrice ≥ MinPrice`, `SearchText ≤ 200`. The old controllers validated nothing.

07

Atomic click increment

`UPDATE AllPosts SET AllPostsClickCount = ISNULL(AllPostsClickCount,0)+1 WHERE Id = @AllPostId` — a single statement on the primary key, outside any transaction, replacing the read-then-write race.

08

Shared read model, on purpose

Both old and new query the denormalized `AllPosts` projection. The migration changes the *access path*, not the storage — re-modelling `AllPosts` is a separate, riskier migration deliberately left for later.

03

One MediatR core, reached by a thin API and the old Razor controllers alike

At the center is one Application core and a rule: no controller writes data-access logic anymore. A request — from either shell — becomes a MediatR query or command. The pipeline runs LoggingBehavior, then ValidationBehavior (which fails fast with structured 400s before the handler ever executes), then the handler. The handler is the whole use case: it calls a repository interface that lives in the Application layer, and the Infrastructure implementation behind it does only Dapper + stored-procedure execution — fully async, CancellationToken threaded through, default 30-second timeouts. It returns an ApiResponse<T> with pagination metadata. The new API controller serializes that to JSON; the old MVC controller maps it to a ViewModel and hands it to Razor. The seam is the same _mediator.Send() in both — which is exactly what lets one endpoint at a time cross over without the other endpoints noticing.

The strangler seam: two shells, one MediatR core Two front-end shells sit at the top. On the left, the surviving old MVC Razor controller; on the right, the new JSON API controller that the Flutter apps talk to. Both call a single _mediator.Send() into one Application core — that call is the strangler seam. The core runs a MediatR pipeline: LoggingBehavior, then ValidationBehavior which fails fast with a structured 400 before the handler runs, then the Handler, which is the whole use case. Below the handler a repository interface declared in the Application layer is implemented in Infrastructure as a Dapper stored-procedure call, fully async with a default thirty-second timeout, reaching SQL Server. The MVC controller maps the returned DTO back onto its Razor ViewModel so the web page renders unchanged and its SEO URL is preserved, while the API controller returns it as JSON. One business core, reached from two shells. Hover or focus any node to read it. one core, two shells · the seam is a single _mediator.Send() Old MVC Razor controller: the surviving web shell old MVC · Razor controller calls the seam · keeps its SEO URLs maps DTO → ViewModel for Razor New API controller: the JSON shell for the Flutter apps new API controller thin · returns JSON to mobile no ViewModel — serializes the DTO directly The strangler seam: both shells call the same _mediator.Send() _mediator.Send(query) — the seam one Application core, reached from both shells migrate an endpoint = rewire a controller, not rewrite a page LoggingBehavior: first pipeline step LoggingBehavior ValidationBehavior: fails fast with a 400 before the handler ValidationBehavior fail fast → 400 Handler: the whole use case (no pass-through layer) Handler = use case orchestrates, no logic in the controller Repository interface (Application) → Dapper + SP (Infrastructure) IVehicleAdRepository → Dapper + SP interface in Application · impl in Infrastructure fully async · CancellationToken · 30s default timeout SQL Server: stored procedures over the AllPosts read model SQL Server · stored procedures hover a node ▸
Fig. 1 — the strangler seam: the new JSON API controller already calls one _mediator.Send() into a single Application core, whose pipeline runs LoggingBehavior then ValidationBehavior then the handler, down through a repository interface to a Dapper stored-procedure call; the surviving MVC Razor controller calls the same seam and maps the DTO back onto its Razor ViewModel — one business core, reached from two shells
04

Follow one ad-detail request through the strangler seam

  1. 1

    1. The request arrives at one of two front doors

    two shells

    Mobile hits GET /api/v1/ads/vehicles/{id} with a numeric id. The web hits /ads/vehicle/{routePath} — an SEO slug that must be preserved, because those URLs are indexed and ranking. Same underlying ad, two entry points.

  2. 2

    2. The web resolves its slug — an edge-only concern

    edge-only

    The MVC controller still resolves the route slug to an AllPostId and still fetches subcategory field-visibility for the Razor view. These are genuinely presentation concerns, so they stay at the web edge and are deliberately not pushed into the API core — the mobile app handles field visibility itself.

  3. 3

    3. Both shells call the same handler

    one core

    The API controller issues _mediator.Send(new GetVehicleAdDetailQuery(allPostId)); the MVC controller issues the very same call. The validation behavior runs; the handler takes over. There is no second business implementation to keep in sync — that is the whole point of the seam.

  4. 4

    4. One stored procedure does everything

    one query

    dbo.VehicleAdGetDetail(@AllPostId) atomically increments the click count on the primary key, joins AllPosts → Vehicles → AspNetUsers → MembershipProfile, resolves every lookup name, and returns a flat, display-ready row. One round trip replaces the old three-to-four.

  5. 5

    5. Each shell renders in its own idiom

    shell-specific

    The API returns ApiResponse<VehicleAdDetailDto> as JSON. The MVC controller maps that same DTO onto the old VehicleAdDetailsViewModel so the existing Razor page renders unchanged — the migration is invisible to the visitor and to Google, which is the entire point.

One ad-detail request: old MVC vs new, by database round trips A side-by-side of the same ad-detail request. On the left, the old MVC path: the request passes through a pass-through service layer and then makes three-to-four separate database round trips — first resolve the route slug to IDs, second fetch the vehicle by CategoryTablePostId, third fetch subcategory field-visibility for the Razor view, and a hidden fourth is a synchronous N+1 ShopName lookup inside the repository. The old detail stored procedure also runs with commandTimeout zero, an unbounded wait. On the right, the new path: both shells call one handler, which calls one stored procedure, dbo.VehicleAdGetDetail with the AllPostId, that atomically increments the click count on the primary key, joins everything, resolves the lookups, and returns a flat display-ready DTO in a single round trip with a default thirty-second timeout. Three-to-four round trips collapse to one. Hover or focus any step to read it. OLD MVC · 3–4 round trips NEW · 1 round trip Pass-through service layer (adds nothing) controller → pass-through service → repository Round trip 1: resolve the route slug to IDs RT 1 resolve slug → IDs route path → AllPostId + CategoryTablePostId Round trip 2: fetch detail by CategoryTablePostId RT 2 fetch detail SP by CategoryTablePostId · commandTimeout: 0 Round trip 3: subcategory field-visibility (Razor-only) RT 3 field visibility a genuine presentation concern Round trip 4 (hidden): N+1 ShopName lookup in the repo RT 4 hidden N+1 lookup GetValidShopNameForRoute, sync, inside the repo SQL Server · 3–4 separate hits, some inside a read transaction SQL Server · 3–4 hits Both shells → one handler (validated in the pipeline) one handler GetVehicleAdDetailQuery(allPostId) Round trip 1 (only): dbo.VehicleAdGetDetail(@AllPostId) dbo.VehicleAdGetDetail(@AllPostId) atomic click++ on the primary key joins + resolves every lookup name returns a flat, display-ready DTO no transaction around the read · 30s default timeout SQL Server · one hit SQL Server · 1 hit field visibility stays an edge concern in the new world — not pushed into the API ~67–75% fewer round trips race deleted · no read transaction · no N+1 same visitor, same page — the migration is invisible to the user and to Google hover a step ▸
Fig. 2 — one ad-detail request, old vs new: the old MVC path takes three-to-four DB round trips (resolve slug, fetch by CategoryTablePostId, field visibility, plus a hidden N+1 ShopName lookup) through a pass-through service layer, while the new path routes both shells through one handler and one stored procedure for a single round trip
05

Where the migration earned its keep

Every one of these had a tempting shortcut. Choosing the harder-but-correct path — and being able to say why — is the difference between code that works in a demo and code that survives production.

Rewire the controllers, don't rewrite the views

chose: MVC controllers delegate to `_mediator.Send()`over: a big-bang Razor-to-SPA rewrite

A rewrite has to reach full parity before it ships anything; the strangler ships value per endpoint and keeps the live site — and its SEO — intact the whole way. The old Razor page stays; only its data path moves. Each seam is a small, reversible change instead of one enormous switch-over.

Integer FK filters over string matching

chose: `DistrictId` / `SubCategoryId` / `ThanaId`over: `NVARCHAR` `DistrictName` / `Category`

Integer equality is index-seekable, deterministic, and typo-proof; the regex whitespace-stripping and the 'bangladesh'/'all' sentinel detection simply disappear. A wrong id is a validation error, not a silently empty page.

One SP per category over one fat cross-category SP

chose: per-category vertical slicesover: `Get_AllAdsPage_AllTypeOfAds`

A single SP handling all five categories couples everything to everything. Slicing per category means each can be owned, tested, and even extracted independently, and category-specific shape (Jobs' salary range) stops leaking into the others.

Keep AllPosts as the read model — for now

chose: query the denormalized projection from both pathsover: normalizing storage during the migration

The migration's job is the access path; re-modelling the read-optimized AllPosts table is a distinct, higher-risk migration. So Price stays NVARCHAR (the new SPs TRY_CAST it) — a deliberate scoping line that keeps storage changes in their own migration rather than smuggling them into this one.

The war story · the parity audit

The audit that caught two paid boosts quietly going missing

The dangerous part of a migration isn't the code you write — it's the moment you believe the new path matches the old one. So before calling the ad system migrated, I audited it layer by layer: old MVC vs new API, UI down to SQL, writing down what was correct, what was intentionally simplified, and what was actually missing. The audit confirmed the architecture direction — and then it caught the thing a "looks done" migration ships by accident.

The old system supports five paid promotion tiers: Show Up, Top Post, Between, Hurry Up, and Jump Up. The new listing procedures accept a @PromotionTypeId filter for all five — but the response only materializes three result sets (ShowUpAds, TopPostAds, BetweenAds), and there was no query path for Hurry Up or Jump Up at all. The filter contract had been generalized; the response shape had only been built for the three visible sections. A migration that felt finished would have silently dropped two revenue-generating promotion tiers — customers paying for a boost that never rendered.

The audit produced two artifacts. One is a ranked catalog of fifteen defects in the old ad path — string-based filtering at the top, down through the click-count race, the read-wrapping transactions, and the N+1 lookups — each tagged with where the new design fixes it. The other, and the one that mattered most, is a parity finding on the new system that the audit caught before it shipped: two boost tiers with a filter param but no result set — a revenue feature flagged at the gate, before a single customer could pay for a boost that wouldn't render. That is the discipline the pattern demands — no endpoint crosses the strangler seam until parity is proven, not assumed. The vine hasn't strangled a branch until you've checked the branch is actually dead.

The promotion parity gap: five paid boost tiers, only three materialized The old system supports five paid promotion tiers: Show Up, Top Post, Between, Hurry Up, and Jump Up. The new listing stored procedures accept a single PromotionTypeId filter parameter that covers all five. But the response only materializes three result sets — Show Up, Top Post, and Between — served by the one list query. Hurry Up and Jump Up have the filter parameter but no result set and no query path, so a migration that felt complete would silently drop two revenue-generating promotion tiers: customers paying for a boost that never renders. This was a distinct parity finding, separate from the ranked catalog of old-system defects. Each tier is shown as a card with two checks: whether it has a result set and whether it has a query path. Three are complete; two are owed. Hover or focus any tier to read it. @PromotionTypeId accepts all five tiers — only three are built @PromotionTypeId: one filter parameter for all five tiers @PromotionTypeId — generalized for 5, so the input contract looks done Show Up: complete (result set + query path) Show Up result set query path ships Top Post: complete (result set + query path) Top Post result set query path ships Between: complete (result set + query path) Between result set query path ships Hurry Up: owed — filter param but no result set, no query path Hurry Up result set query path paid, never renders Jump Up: owed — filter param but no result set, no query path Jump Up result set query path paid, never renders The parity finding — the gate before an endpoint is called migrated the parity audit's headline finding a “looks done” migration ships two paid tiers that render nothing — a revenue feature, gone a distinct finding, not a row in the old-defect catalog — parity proven, never assumed generalize the input contract, but verify every output the old behavior produced hover a tier ▸
Fig. 3 — the promotion parity gap the audit caught: five paid boost tiers exist (Show Up, Top Post, Between, Hurry Up, Jump Up); the new listing SPs accept a PromotionTypeId filter for all five but only materialize three result sets, so Hurry Up and Jump Up had no query handler and would have silently disappeared in a migration that felt complete
06

Round trips halved, a class of races deleted — one core, two shells

The measured wins are round trips and correctness, with a corresponding estimated latency drop. Detail pages went from three-to-four round trips to one (a ~67–75% reduction); listing pages from two to one (~50%). The click-count race is gone (a single atomic increment on the PK), lock contention from read-wrapping transactions and temp tables is gone, the commandTimeout: 0 connection-pool hazard is gone (default 30 s), and the .Result blocking and Task.Run(() => View()) deadlock/allocation anti-patterns are replaced by real async throughout. The decorative service layer is deleted, and server-side validation exists for the first time.

And the scope is drawn as deliberately as the wins. The display-ad boundary is a design line, not an omission: Google AdSense stays UI-only and the DB-driven display-ad subsystem keeps to its own feature group — neither is dragged into the marketplace ad API, because conflating four different "ad" concepts is exactly the coupling this migration exists to undo. Every endpoint that crosses the seam does so behind a parity audit, so what runs on the new core provably matches what the old path served.

Layer collapse: five pass-through layers become three that each do work A side-by-side of the two architectures. On the left, the old five-layer MVC monolith stacked top to bottom: the Razor view, the view-centric area controller that builds ViewBags and pagers, the service layer which is pure one-line delegation and adds nothing, the repository where all the logic is crammed, and Dapper with stored procedures. On the right, the new three-layer slice: a thin shell that is either the API controller or the surviving MVC controller, the MediatR handler which is the whole use case with validation moved into the pipeline in front of it, and Dapper with stored procedures in Infrastructure. The pass-through service layer is deleted, and validation, which did not exist at all in the old system, now runs in the pipeline before the handler. Hover or focus any layer to read it. OLD · 5 layers, one pure pass-through NEW · 3 real layers Razor view Razor view Area controller — view-centric, builds ViewBags area controller ViewBags, pagers, UI state Service layer — pass-through, adds nothing (deleted) service — pass-through one-line delegation, zero logic deleted in the new design Repository — all the logic crammed here repository — all logic business rules + data access mixed Dapper + stored procedures Dapper + SP collapse Thin shell — API controller or MVC controller thin shell API controller · or MVC controller MediatR handler = the use case (validation in the pipeline) MediatR handler = use case + ValidationBehavior in front calls a repository interface (Application) validation existed nowhere in the old system Dapper + SP (Infrastructure) — fully async, default timeouts Dapper + SP (Infrastructure) fully async · 30s default timeout the handler IS the use case — no pass-through indirection, logic out of the repository pass-through service deleted, logic out of the repository; validation and real async now present same shared AllPosts read model underneath — the access path changed, the storage did not hover a layer ▸
Fig. 4 — layer collapse: the old five-layer monolith (Razor, controller, pass-through service, all-logic repository, Dapper/SP) with the service layer marked as dead indirection, against the new three-layer slice (thin shell, MediatR handler as the use case, Dapper/SP) — validation moves into the pipeline and the pass-through layer is deleted
07

The migration is documented, not just performed: a deep-dive of the old MVC ad system (marketplace vs boost vs display vs AdSense — four "ad" concepts the monolith conflated), an old-vs-new architecture comparison that ranks fifteen defects by severity with the fix location for each, and a layer-by-layer parity audit that gates every endpoint's crossover. The design is deliberately unglamorous where it counts — one core, two shells, one SP per category, one seam per endpoint — and the methodology is the point: documented end to end, audited layer by layer, and gated per endpoint, so no crossover is declared done until the parity audit proves the new path reproduces the old one. That is how a live, revenue-taking marketplace moves off its monolith without a big-bang rewrite and without going dark.

Stack: .NET, ASP.NET Core, C#, MediatR, FluentValidation, Dapper + stored procedures, SQL Server, ASP.NET MVC (legacy `OnlineShop`), Razor, Clean Architecture

(sole-engineer migration of a production marketplace — walk-through on request)

@misc{ammar2026mvctoapimigration,
  author = {Ammar, Md. Abu},
  title  = {MVC → API Strangler Migration — Retiring a Monolith Without Going Dark},
  year   = {2026},
  note   = {Engineering case study}
}