Migrating Away From the ABP Framework: A Real-World Case Study
Field report from an enterprise LOB migration off Volo.Abp to plain ASP.NET Core 10 — bridge-pattern refactoring, incremental forks and honest judgment calls, without a rewrite, without a freeze window, and while shipping features.
Migrating Away From the ABP Framework: A Real-World Case Study
ABP Framework promises a faster start. DDD base classes, permission management, identity, multi-tenancy, an opinionated module system, a Lepton theme — everything pre-wired. For a handful of years that promise pays off. Then, sooner or later, some teams reach the point where the framework starts costing more than it saves: license fees, transitive dependencies, parallel conventions on top of ASP.NET Core, and a .NET upgrade cadence bound to someone else's release schedule.
This article is a field report. It describes what actually happened when an enterprise LOB application was pulled off Volo.Abp (ABP Commercial) and brought back to plain ASP.NET Core 10 — without a rewrite, without a freeze window, and while shipping features to production.
This is not a recommendation to copy. Most teams on ABP should stay on ABP. But if you are in the narrow intersection where exiting the framework is a real business decision and a green-field rebuild is off the table, the patterns below may save you from a few of the traps we hit.
Who this article is for: CTOs, tech leads and senior .NET engineers responsible for an ABP-based enterprise application who are weighing the cost of staying against the cost of leaving — and want a concrete, test-driven migration path instead of a slide deck.
The starting point
The application under migration was the authoring and publication backbone of a mid-sized German publisher. It had been built in 2022 on ABP Commercial and looked like a lot of enterprise .NET codebases of that era:
- ~60 application services, 80+ Razor Pages, ~40 Kendo UI grids
- Multi-tenant-capable architecture (single-tenant in practice)
- Identity, OpenIddict and Permission Management via ABP modules
- Lepton Theme as embedded Razor view package
- Full DDD layer stack (Domain.Shared / Domain / Application / Application.Contracts / EntityFrameworkCore / Web)
- Hangfire for background jobs, Serilog for logging, Azure Blob for documents
- ~500 integration and E2E tests driving a real Kestrel host through Playwright
ABP delivered what it promises: a consistent module system, property-injected services, automatic API controller generation from application service interfaces (AutoAPI), authorization and validation interceptors, a virtual file system for embedded views, and a bootstrapped theme.
It also delivered what it rarely advertises:
- A commercial license with per-seat pricing
- A large transitive dependency chain — dropping a single direct ABP package reference pulls in 10–50 siblings
- A parallel universe of conventions (DDD base classes,
[RemoteService]attributes,AbpModulelifecycle,AbpPageModelbase) that has to be learned on top of ASP.NET Core itself - Occasional surprises where ABP-internal infrastructure disagreed with plain .NET patterns (strict DI resolution, interception-based validation order, convention-based controller discovery)
Three converging pressures forced the decision: licensing cost, willingness to own maintenance of the handful of ABP features actually in use, and a preference for a codebase that looks and behaves like "plain modern ASP.NET Core 10" to anyone new joining the team.
The goal was never purity. The goal was strategic reduction of surface area so the team could onboard faster and upgrade .NET without waiting for an ABP release.
Why we did not rewrite
The tempting answer to "we want to leave ABP" is "start a clean solution and port everything over." We rejected this early, for three reasons.
1. Feature parity is underestimated. The application had five years of accumulated domain logic. A rewrite would freeze feature work for six to twelve months — a timeline no customer would tolerate.
2. Test coverage is portable, code is not. The 500-test suite exercises end-to-end flows against the same Kestrel host a user hits. That suite is the crown jewel — it keeps regressions out regardless of what framework is underneath. A rewrite loses that coverage for the duration of the rebuild.
3. Bridges are cheaper than parallel worlds. Running two codebases side-by-side doubles the maintenance surface. Incremental in-place refactor keeps the team on a single branch.
The chosen approach rested on three principles.
Principle 1 — Bridge before drop. Every ABP type that application code touches gets a native equivalent first. Both live side by side for some commits. Only once consumers compile and run against the native type does the ABP type get removed.
Principle 2 — Tests are the contract. No ABP refactor ships without a green end-to-end suite. Not "should pass" — green. If the suite is flaky, fix the flakiness before any ABP work, because ABP churn will otherwise get blamed for every unrelated flake.
Principle 3 — "Option C" is a valid endpoint. Not every ABP type has to leave. If a package is not vulnerable, not blocking upgrades, and the cost to remove it exceeds the benefit, it stays. This decision was made explicitly three times during the migration, and each call was correct.
Phase walk-through
The migration ran over roughly 14 calendar weeks. The phases below are not chronological — they are how the reductions are structured, from domain inward to infrastructure outward.
Phase 6 — DbContext decoupling
Starting point: AppDbContext : AbpDbContext<AppDbContext>. This base pulls in IIdentityDbContext, soft-delete filters, creation audit, concurrency stamps, multi-tenant data filters and extension-property persistence.
Target: AppDbContext : IdentityDbContext<AppUser, AppRole, Guid> — the Microsoft Identity base, with our own soft-delete and audit interceptors wired in.
What worked:
- Replacing ABP's
FullAuditedAggregateRoot<Guid>with a localAppFullAuditedEntity<Guid>that implements the same audit interfaces (ICreationAuditedObject,IHasModificationTime, …) fromVolo.Abp.Auditing.Abstractions. Consumers compile unchanged because the interfaces remain ABP-typed. - Moving soft-delete from
AbpDbContextquery filters to an explicit EF CoreHasQueryFilter(e => !e.IsDeleted)per entity. One afternoon for 40 entities. Removed a whole category of "why is this row invisible?" support tickets.
What bit us:
ABP's unit-of-work system (IUnitOfWorkManager) is not a plain DbContext wrapper. It owns the connection, the transaction and the lifetime. Identity's UserManager<AppUser>.CreateAsync bypasses this — it wants its own scoped DbContext. The connection had to be shared between both contexts explicitly:
public AppIdentityDbContext CreateFromAbpUow(
IUnitOfWorkManager abpUow,
DbContextOptions<AppIdentityDbContext> options)
{
var activeAbpDbContext = abpUow.Current!
.ServiceProvider
.GetRequiredService<IAppDbContextProvider<AppDbContext>>()
.GetDbContextAsync().Result;
var sqlConnection = activeAbpDbContext.Database.GetDbConnection();
var transaction = activeAbpDbContext.Database.CurrentTransaction;
var newBuilder = new DbContextOptionsBuilder<AppIdentityDbContext>()
.UseSqlServer(sqlConnection);
var ctx = new AppIdentityDbContext(newBuilder.Options);
ctx.Database.UseTransaction(transaction!.GetDbTransaction());
return ctx;
}
Lesson 1 — UoW ≠ DbContext. Bridging frameworks via DbContext alone produces mysterious "transaction already committed" or "cannot enlist in distributed transaction" errors the first time a concurrent test writes users.
Phase 7 — Identity, Account, OpenIddict, Permission Management
Starting point: [DependsOn] graph pulls 18 ABP modules for auth: Volo.Abp.Identity.*, Volo.Abp.Account.*, Volo.Abp.OpenIddict.*, Volo.Abp.PermissionManagement.*. Login pages come embedded from Volo.Abp.Account.Web.OpenIddict.
Target: Plain Microsoft.AspNetCore.Identity plus OpenIddict 7.x directly, plus a custom authorization policy provider and Razor pages for login, logout and forgot-password.
This phase was the largest single chunk of work (~3 weeks). Every ABP-shipped feature the application relied on had to be replaced or deliberately deleted:
IAuthorizationPolicyProvider— ABP's version dynamically synthesises policies from permission names at resolution time. The replacement does the same:
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (_cache.TryGetValue(policyName, out var cached)) return cached;
if (AppPermissions.AllKnownPermissions.Contains(policyName))
{
var built = new AuthorizationPolicyBuilder()
.RequireAssertion(ctx =>
_permissionChecker.IsGrantedAsync(ctx.User, policyName).Result)
.Build();
_cache[policyName] = built;
return Task.FromResult<AuthorizationPolicy?>(built);
}
return _default.GetPolicyAsync(policyName);
}
IAppPermissionChecker— reads from the sameAbpPermissionGrantstable the old system wrote into. Schema kept, provider replaced. Data-migration cost: zero.Login page HTML identifiers —
#LoginInput_UserNameOrEmailAddress,#LoginInput_Passwordandbutton[type='submit']kept exactly the way ABP rendered them. Reason: 30+ Playwright tests use those selectors. Changing IDs would have forced a test rewrite. Holding them stable made the swap invisible to the test layer.
Lesson 2 — preserve your test's contract. If the selectors and URLs stay the same, the test suite is a green-light button during a swap that otherwise touches 150 files.
Lesson 3 — sequence matters. Do not drop Volo.Abp.Identity.* before the OpenIddict replacement is done. The two packages are tied via IdentityDataSeedContributor, which OpenIddict.EF's seeder calls — transitive loading failures at test startup are the result if the drop is sequenced incorrectly.
Phase 8 — Domain and application base-class forks
Starting point: AppServiceBase : ApplicationService from Volo.Abp.Application.Services. This base exposes LazyServiceProvider, CurrentUser, CurrentTenant, UnitOfWorkManager, Logger, L, ObjectMapper, GuidGenerator, feature checker, Clock and a dozen more — 20+ properties, all injected via a property-setting interceptor.
Target: A small, explicit base with only the services the code actually touches. Six.
[AppTransactional]
public abstract class AppServiceBase : IApplicationService
{
public IObjectMapper ObjectMapper { get; set; } = null!;
public IStringLocalizer<AppResource> L { get; set; } = null!;
public ILogger<AppServiceBase> Logger { get; set; }
= NullLogger<AppServiceBase>.Instance;
public IGuidGenerator GuidGenerator { get; set; } = AppGuidGenerator.Instance;
public IAppUnitOfWorkManager UnitOfWorkManager { get; set; } = null!;
public IAppCurrentUser CurrentUser { get; set; } = null!;
}
Lesson 4 — property injection is a hidden contract. When code inherits from ApplicationService, it also inherits the promise that somebody (in ABP's case, a Castle DynamicProxy interceptor) will fill in Logger, ObjectMapper, CurrentUser at resolve time. The moment the base class is dropped, the interceptor goes with it, and every property access becomes a NullReferenceException at runtime rather than a compile error.
The fix was a custom property-injection wrapper that runs at PostConfigureServices:
public static void WrapForPropertyInjection(
IServiceCollection services,
Type markerInterface)
{
var descriptors = services
.Where(d => markerInterface.IsAssignableFrom(d.ServiceType))
.ToList();
foreach (var d in descriptors)
{
services.Remove(d);
services.Add(new ServiceDescriptor(d.ServiceType, sp =>
{
var instance = d.ImplementationFactory?.Invoke(sp)
?? ActivatorUtilities.CreateInstance(sp, d.ImplementationType!);
foreach (var prop in instance.GetType().GetProperties()
.Where(p => p.CanWrite && p.GetSetMethod(true)?.IsPublic == true
&& sp.GetService(p.PropertyType) != null))
{
prop.SetValue(instance, sp.GetService(p.PropertyType));
}
return instance;
}, d.Lifetime));
}
}
Thirty lines of reflection. One afternoon. It is the bridge that let Volo.Abp.Ddd.Domain and half of Volo.Abp.Ddd.Application be dropped without touching a single consumer.
Phase 10 — Theme fork
Starting point: The Lepton Theme from ABP Commercial — embedded Razor layouts, CSS bundles, view components for menu, branding, breadcrumb and alerts. The web module depended on AbpAspNetCoreMvcUiLeptonThemeModule, which pulls ~15 sibling packages.
Target: A single modules/App.Theme/ project, with the Lepton layouts copied in, branded for the client, and no ABP-commercial dependency.
The work was mechanical but broad:
- Copy the Lepton layouts (
Application/Default.cshtml,Account/Default.cshtml,Empty/Default.cshtml) into the project and mark them asEmbeddedResourcein the csproj. - Fork the small interfaces ABP's menu/branding/breadcrumb/toolbar system relied on (
IPageLayout,IBrandingProvider,IMenuContributor,IToolbarContributor), keeping the API shapes compatible so views did not change. - Register the copies in DI and use
services.Replace(...)to override ABP's wherever they overlapped.
The surprise: ABP's IPageLayout holds a ContentLayout object that both ABP-internal view components and custom layouts write to (title, breadcrumb, menu-item-name). A straight fork of IPageLayout does not work, because the same runtime instance has to be visible to both the ABP Razor compiler and the local code.
The solution was a dual-interface implementation with shared state:
public class AppPageLayout
: Volo.Abp.AspNetCore.Mvc.UI.Layout.IPageLayout,
App.Theme.Layout.IPageLayout
{
public Volo.Abp.AspNetCore.Mvc.UI.Layout.ContentLayout Content { get; } = new();
}
Both interfaces have a Content property. Because the property types were identical (ABP's ContentLayout), a single backing field satisfied both contracts. Same trick you would use to implement ICollection<T> and IReadOnlyCollection<T> on the same class — applied to a framework boundary.
Later, when ContentLayout itself was forked, the backing state was split but the dual interface stayed, using explicit-interface implementation on the ABP side. ABP-internal consumers got the old instance, the new Razor got the new one, and the two never had to sync because the internal ABP writers had been replaced by then.
Lesson 5 — shared state across framework boundaries is the hardest part. When code you don't control reads state that code you do control writes, three options exist: dual-implement an interface (clean, works when types match), bridge-map at the boundary (clean, works when types diverge), or abandon the shared-state requirement entirely (usually the right long-term move, but expensive).
Phase 11.3 — Inner pipeline forks (the sweet spot)
This is where the migration paid the most per unit of effort. Three ABP types were consumed by local code, but their mutation points sat inside ABP middleware:
AbpErrorViewModel— populated by ABP's exception middleware, read by five Razor error pages.AlertList— populated byAbpPageModel.AlertManager.Alerts.Success(...), read byContentAlertsViewComponent.ContentLayout— populated by pages (PageLayout.Content.Title = ...), read by layout view components.
The trick: the read side can be replaced without replacing the write side.
public class AlertList : List<Alert> { }
public class Alert
{
public AlertType Type { get; set; }
public string Text { get; set; } = string.Empty;
public string? Title { get; set; }
public bool Dismissible { get; set; }
}
public class ContentAlertsViewComponent : LeptonViewComponentBase
{
private readonly AbpIAlertManager _abpAlerts;
public ContentAlertsViewComponent(AbpIAlertManager alertManager)
{ _abpAlerts = alertManager; }
public IViewComponentResult Invoke(string name)
{
var mapped = new AlertList();
foreach (var a in _abpAlerts.Alerts)
mapped.Add(new Alert { Type = Map(a.Type), Text = a.Text,
Title = a.Title, Dismissible = a.Dismissible });
return View("Default.cshtml", mapped);
}
}
The view Default.cshtml now has @model App.Theme.Alerts.AlertList. Zero ABP types on the read path. ABP is still the writer, but that was fine: after session 5b, the local OnPageHandlerExecuting also called our own alert manager. Two phases, one type shape, full decoupling.
The same pattern applied to ContentLayout and ErrorViewModel. Total work for all three: about 90 minutes of code plus one hour of test verification — and each removed several hundred ABP-type references from the surface area.
Lesson 6 — fork reads before writes. Views read state. Middleware writes state. Forking the read side is always easier, always faster, and almost always enough for the consumer refactor you actually care about. Write-side replacement can wait for a dedicated middleware session.
Phase 11.3 Session 5 — middleware replace
ABP's exception-handling middleware was replaced with UseExceptionHandler plus UseStatusCodePagesWithReExecute:
if (!env.IsDevelopment())
{
// Was: app.UseErrorPage(); (ABP)
app.UseExceptionHandler("/Error");
app.UseStatusCodePagesWithReExecute("/Error/{0}");
app.UseHsts();
}
A plain ASP.NET Core controller covers the Error endpoints:
[AllowAnonymous]
public class ErrorController : Controller
{
[HttpGet("/Error")]
[HttpGet("/Error/{statusCode:int}")]
public IActionResult Index(int? statusCode = null)
{
var effective = statusCode ?? 500;
Response.StatusCode = effective;
var exFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
var model = new ErrorViewModel
{
HttpStatusCode = effective,
ErrorInfo = new ErrorInfo { Message = exFeature?.Error.Message }
};
var viewName = effective switch
{
401 => "/Views/Error/401.cshtml",
403 => "/Views/Error/403.cshtml",
404 => "/Views/Error/404.cshtml",
500 => "/Views/Error/500.cshtml",
_ => "/Views/Error/Default.cshtml",
};
return View(viewName, model);
}
}
And a plain scoped alert manager:
public interface IAlertManager { AlertList Alerts { get; } }
public class AlertManager : IAlertManager { public AlertList Alerts { get; } = new(); }
services.AddScoped<IAlertManager, AlertManager>();
The key insight: ABP's exception middleware and alert manager are not special. They are standard middleware and standard scoped services in ABP-shaped clothes. Microsoft's own UseExceptionHandler does the same job in fewer lines and with fewer surprises.
Phase 11.3 Session 8a — PageModel base replace
Starting point: AppPageModel : AbpPageModel. Every Razor page derives from this, and pages assume CurrentUser, Logger, L, ObjectMapper, AppAlertManager, UnitOfWorkManager and shortcut helpers like Alerts.Success(...) are available.
Target: AppPageModel : PageModel — the Microsoft base — with those properties exposed as shims:
public abstract class AppPageModel : PageModel
{
private IConfiguration? _configuration;
protected IConfiguration Configuration
=> _configuration ??= HttpContext.RequestServices
.GetRequiredService<IConfiguration>();
private IAppCurrentUser? _currentUser;
public IAppCurrentUser CurrentUser
=> _currentUser ??= HttpContext.RequestServices
.GetRequiredService<IAppCurrentUser>();
private ILogger? _logger;
protected ILogger Logger
=> _logger ??= HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(GetType());
protected IStringLocalizer<AppResource> L
=> _l ??= HttpContext.RequestServices
.GetRequiredService<IStringLocalizer<AppResource>>();
// ... UnitOfWorkManager, ObjectMapper, AppAlertManager, Alerts shortcut
}
Lazy property injection without an interceptor. Cheaper than property-set-at-resolve-time, and it works on the plain Microsoft PageModel base.
Total rework: 56 pages inherited. None of them needed modification — the shims preserved the interface. Five .cshtml files had @inject ICurrentUser CurrentUser at the top and injected ABP's variant; those were updated to @inject IAppCurrentUser CurrentUser in a single batch.
Lesson 7 — the cheapest refactor is the one your consumers don't see. If page.CurrentUser.Id still compiles and works after the base-class swap, something was done right.
Phase 11.3 Session 8b — AutoAPI controllers (the final blocker)
The plan called for dropping await builder.AddApplicationAsync<AppWebModule>() and replacing it with direct builder.Services.AddAppWeb(...) calls — exiting the AbpModule system entirely.
The attempt failed four times across two days. Each attempt got slightly further. The third actually got the web host to bind on port 44332 and serve /Account/Login with HTTP 200. The fourth revealed the real blocker.
ABP's AutoAPI — ConfigureAutoApiControllers.Create(assembly) — does not generate controllers at configuration time. It adds the assembly to a static options list. The materialisation of controllers happens at MVC startup via an IApplicationModelConvention that ABP's module system registers internally when AbpAspNetCoreMvcModule.ConfigureServices runs.
Drop AddApplicationAsync, and that convention never registers. The assembly is listed but nothing reads the list. Result: zero /api/app/* routes. Result of that: the generated JavaScript proxies (window.app.<module>.<service>.<method>) are empty. Result of that: every Kendo UI grid in the app fails silently — tables render but their AJAX data sources cannot call anything, rows stay hidden.
E2E smoke tests caught it immediately (17 of 18 grid tests red). Revert.
Lesson 8 — framework-level convention discovery is the hard boundary. If a framework injects itself into ASP.NET Core's MVC startup via IApplicationModelConvention / IControllerModelConvention / IActionModelConvention, and consumer code relies on the artefacts those conventions generate (such as auto-API controllers), exiting is only possible in two ways:
- Manually register an equivalent convention (requires reflecting on framework internals — hacky and fragile), or
- Write the generated artefacts by hand.
Option 2 won. But before committing to 60 hand-written controllers, an audit asked the right question: which application services are actually consumed from JavaScript? The answer: 9, not 60. Most AppServices are called only from C# (other AppServices, Razor page handlers) via plain DI — they do not need an HTTP controller at all.
That reduced scope to a few hours of mechanical work:
[ApiController]
[Route("api/app/addresses")]
[Authorize(AppPermissions.Addresses.Default)]
public class AddressesController : ControllerBase
{
private readonly IAddressesAppService _service;
public AddressesController(IAddressesAppService service) { _service = service; }
[HttpGet]
public Task<PagedResultDto<AddressDto>> GetListAsync([FromQuery] GetAddressesInput input)
=> _service.GetListAsync(input);
[HttpGet("{id:guid}")]
public Task<AddressDto> GetAsync(Guid id) => _service.GetAsync(id);
[HttpPost]
[Authorize(AppPermissions.Addresses.Create)]
public Task<AddressDto> CreateAsync([FromBody] AddressCreateDto input)
=> _service.CreateAsync(input);
[HttpPut("{id:guid}")]
[Authorize(AppPermissions.Addresses.Edit)]
public Task<AddressDto> UpdateAsync(Guid id, [FromBody] AddressUpdateDto input)
=> _service.UpdateAsync(id, input);
[HttpDelete("{id:guid}")]
[Authorize(AppPermissions.Addresses.Delete)]
public Task DeleteAsync(Guid id) => _service.DeleteAsync(id);
}
On the application service itself, [RemoteService(false)] tells ABP to skip AutoAPI generation for that service — avoiding a route conflict while both systems are live.
Once all nine controllers were in place, ConfigureAutoApiControllers was removed and the Volo.Abp.Ddd.Application package reference was dropped from the Application csproj. The final build reported zero warnings; the full test suite ran green.
Lesson 9 — audit before you scale. "We have 60 services, so this will be 60 units of work" is usually wrong. Count the actual consumers (JavaScript, C#, HTTP) — the number is typically a fraction of the interface count, and the pattern of work changes completely once the real target is visible.
Phase 11.3 Option C — the deliberate non-removal
Three times, an ABP package was formally documented as staying — Option C, the honest non-removal:
Volo.Abp.Ddd.Application— originally kept because AutoAPI conventions were too deep to replicate cheaply. Later removed once the nine explicit controllers were in place.Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared— still kept as of writing. Anchors the Lepton theme and theAbpModulebootstrap. Will be removed only when theAbpModulesystem itself is replaced.Volo.Abp.TestBase/Volo.Abp.Autofac— test infrastructure. Replacement would mean rewriting the test bootstrap for ~500 tests. Not worth it.
Each "Option C" decision was a commit with a comment in the csproj explaining why the package stays. Those comments are the single best communication tool for the next engineer: when they open the file and read "last attempt to drop this failed at <link to commit> because <reason>", they do not repeat the mistake.
Cross-cutting lessons
Beyond the phase-specific lessons, a handful of patterns repeated across almost every sub-session.
Bridge-pattern is the default tool
The six-line pattern used over and over:
public class AppX { ... }
public class AppXBridge
{
private readonly FrameworkX _source;
public AppXBridge(FrameworkX source) { _source = source; }
public AppX Map() => new AppX { /* copied from _source */ };
}
The bridge is temporary scaffolding. Once both read and write sides are local, the bridge gets deleted. Until then, the bridge is the commit boundary where tests go green.
Atomic commits, green tests, push
Every sub-session ended in one of three states:
- Committed and pushed, tests green. Continue.
- Uncommitted, tests red, cannot fix in this session. Revert, commit nothing, document the attempt with the concrete error cause.
- Committed locally but cannot push, integration untested. Never. This state does not exist.
State 2 happened three times — all on session 8b attempts. Each revert commit carried a detailed note of exactly what broke and why, so the next attempt could start where the last one left off. These "attempt + revert" commits are not waste; they are the research log.
The stuck background-job incident
Two weeks in, the E2E test suite started mysteriously hanging at test #113 of ~500. Sometimes it passed in 17 minutes; sometimes it timed out at 40. The initial assumption was that recent ABP changes had broken it.
The real cause: a remote Hangfire worker on another machine had locked a job in the shared test database via DisableConcurrentExecution. The local test process tried to start its own Hangfire host, saw the lock, and waited. Forever.
Root-cause discovery took three hours. An auto-cleanup was added to the test fixture:
using var cmd = new SqlCommand(@"
DELETE FROM HangFire.Job
WHERE StateName = 'Processing'
AND CreatedAt < DATEADD(hour, -1, GETUTCDATE())",
connection);
await cmd.ExecuteNonQueryAsync();
One SQL statement. Saved an estimated ten hours of unrelated debugging over the remainder of the migration.
Lesson 10 — shared infrastructure is a distributed-systems problem in disguise. When two processes can race on a shared resource (database, Redis cache, message queue), defensive cleanup belongs in test startup before it is needed. The alternative is spending hours assuming your own changes are the bug, when actually a long-dead worker is holding a lock you cannot see.
Tests as the continuous contract
The migration touched ~800 files across 14 weeks. Not once did a regression hit production that the E2E suite had not caught first. Every time a smoke test went red, the fix was either "revert the last commit" (safe default) or "I understand what broke, here is the targeted fix" (only when the evidence was clear).
The test-suite discipline that enabled this:
- Full E2E on every non-trivial commit. The suite runs in ~15 minutes; it was run after every milestone commit (60+ times during the migration).
- Targeted smoke subsets for quick iterations. Within a commit sequence, 20-test subsets (AuthFlow, DataManagementGrid, TagHelper) gave a two-minute signal before the full 15-minute run.
- Screenshots on failure. Playwright auto-captured. When a grid went hidden, the hidden grid was visible in the artefact — no stepping through code needed to know that proxy generation was broken.
- Zero-warning build as a gate. Every Phase 11.3 commit merged only if
dotnet buildreported 0/0. This caught dozens of unused-import regressions and three actual bugs.
Documentation of non-obvious decisions
A persistent, dated markdown log captured every decision that required judgement (each Option C, the stuck-job pattern, the session 8b blocker). Code gets refactored away. The reasons behind the refactors — and the reasons behind the non-refactors — are what the next engineer needs six months later.
The numbers
For anyone planning a similar migration and wanting benchmarks:
| Metric | Value |
|---|---|
| Starting lines of code | ~185,000 C# |
| Starting test count | ~480 integration + E2E |
| Direct ABP package references at start | 14 |
| Direct ABP package references after migration | 3 (Theme.Shared + 2 test-infra packages) |
| Transitive ABP packages pulled at start | ~70 |
| Transitive ABP packages after migration | ~30 |
| Sub-sessions committed | ~50 |
| "Attempt + revert" commits | 7 |
| Full test-suite runs during migration | 62 |
| Full test pass rate, final | 481 / 498 (17 skipped by design) |
| Build warnings, final | 0 |
Dead using statements removed in the final sweep |
2,804 |
| Engineering-days (ballpark) | 65 |
| Wall-clock days (ran alongside feature work) | ~100 |
The single biggest commit: a dotnet format --diagnostics IDE0005 sweep that removed dead using directives from 796 files in one transaction, once the IDE0005 warning was turned on project-wide. That single commit closed the visible-surface reduction from "some lingering ABP namespace imports" to "every line that has an ABP reference is an ABP reference we still need."
Would we recommend it?
Honestly? In most cases, no.
If your team is happy on ABP, stay on ABP. The framework works, the vendor is responsive, and the feature surface is enormous. The effort described above translates to roughly six developer-months for a team of this size — significant opportunity cost for a codebase that is shipping fine.
The narrow cases where the migration actually pays off:
- Licensing cost is a meaningful budget line and the saving compounds over years.
- The team is small, and the cognitive overhead of two framework conventions (ABP + ASP.NET Core) slows onboarding.
- There is a strong preference for "boring technology" and the framework blocks your .NET upgrade cadence.
- Open-sourcing parts of the codebase is on the roadmap, and the commercial dependency is incompatible.
In every other case, the right answer is "strategically ignore ABP's more exotic features, use only what you would write yourself, and let the framework fade into the background." Most of the onboarding simplicity, none of the migration cost.
Fazit
The migration that worked was not a single big-bang event. It was ~50 small, test-verified commits, each of which left the branch in a state where a production build could ship. Most took 30 minutes of engineering time; a handful took two days; none took a week.
The five takeaways, in order of impact:
- Respect the limits of what can be replaced without framework-internal knowledge. Four times, a dependency was dropped that the framework had woven into its own bootstrap. Four reverts. The fifth attempt, once the real blocker (AutoAPI's convention-based controller discovery) was understood, succeeded by bypassing the convention system entirely with hand-written controllers.
- Never confuse bridge code with target code. A bridge is temporary — it exists so tests stay green through a transition. Target code is what the team intends to keep. At the end of each phase, ask: "which files exist only because they bridge to ABP?" and tag them for deletion in the next phase. Over 14 weeks, the bridge surface shrank from ~30 files to ~5.
- Tests are the continuous contract. A green E2E suite is the only credible signal that a framework swap did not break production behaviour. Invest in it before the migration, not during.
- Audit before you scale. "N services means N units of work" is usually wrong. Count actual consumers; the real target is typically a fraction of the interface count.
- Admit when to stop. "Option C" — the deliberate non-removal — is not a failure. Three packages stayed. Each was a conscious decision with a written rationale, and each of those rationales is still valid.
If there is one sentence to take from this article, it is this: the goal of a framework exit is not to exit the framework. The goal is to reduce the surface area to what the team can own, understand and evolve without external approval. The distance between those two goals is where engineering judgement lives.
Nächste Schritte
- Passende Landing Page: .NET & Legacy Migration Services
- Für CTOs im Mittelstand: CTO-Sparring: Zweitmeinung für strategische Tech-Entscheidungen
- Verwandter Artikel: .NET Framework zu .NET Core migrieren
- Verwandter Artikel: Legacy-Modernisierung mit dem Strangler-Fig-Pattern
- Verwandter Artikel: Technical Debt: Messung, Priorisierung und systematischer Abbau
- Gespräch vereinbaren: 60-Min-Strategiegespräch zu Ihrer Migrationssituation