The Migration Playbook: ABP to Granit Without Downtime
A team just finished moving a 15-module .NET service from ABP Framework to Granit without a maintenance window, code freeze, or parallel run. The migration took ten weeks alongside feature work. Here's the playbook.
Why Move Off ABP?
ABP is a fine framework, but four trends in the codebase added up:
- Minimal API endpoints were being written by hand for new features for two years —
CrudAppServicestopped earning its keep on anything that wasn't pure CRUD. - DDD layering (Domain / Application.Contracts / Application / EntityFrameworkCore) generated four projects per module and a lot of
IObjectMapperboilerplate. - The team adopted Wolverine for messaging and fought
IDistributedEventBusto make it route correctly. - New hires kept asking "why is there a parallel
IStringLocalizerabstraction on top of the BCL one?"
Granit crystallized out of those four answers: a modular .NET 10 stack with native Wolverine, Minimal API as the endpoint surface, BCL primitives, and CQRS as the default.
The Strangler-Fig Architecture
No big-bang rewrite. The new Granit host ran alongside the existing ABP host, behind the same ingress (YARP), sharing the same database and Keycloak issuer. The ingress decides per-URL which host serves the request. Each module flips over independently.
Key rules:
- Schema ownership belongs to the host that wrote the table first. EF Core migrations are generated by that host; the other side reads via a read-only DbContext or generates its own migrations into the same
__EFMigrationsHistorytable. - Audit and tenant columns must keep the same shape during cutover. ABP uses
CreatorId/CreationTime; Granit usesCreatedBy/CreatedAt. The team solved this with EF Core column overrides on the Granit side (HasColumnName("CreationTime")). - OIDC stays as-is. Tokens minted for the ABP host work on the Granit host. No parallel user store.
Conceptual Mapping (Cheat Sheet)
Most ABP primitives have a direct Granit equivalent:
AbpModule+[DependsOn(...)]→GranitModule+[DependsOn(...)]IApplicationService/CrudAppService<...>→ Minimal API endpoint class with handler methodsIRepository→DbContextdirectly or a thin custom repositoryAuditedEntity/FullAuditedEntity→AuditedEntity/FullAuditedEntity(Id is always Guid)IMultiTenant+ICurrentTenant.Id→ same interface namesISettingProvider.GetOrNullAsync(name)→ same shapeIPermissionChecker+PermissionDefinitionProvider→ ASP.NET Core authorization policies (per-endpoint)IDistributedEventBus.PublishAsync→ Wolverinebus.PublishAsyncIBackgroundJobManager.EnqueueAsync→ Wolverinebus.ScheduleAsyncISpecification/Specification→Granit.Persistence.Specification+ inlineSpec.For()AuditLogtable →AuditEntryrows viaGranitAuditingModuleIObjectMapper(AutoMapper) → Mapperly (compile-time) or manual
The migration is mostly namespace changes plus a few opinionated rewrites where Granit picks a different default.
Before/After: CrudAppService → Minimal API
ABP standard shape:
[Authorize(MyAppPermissions.Inventory.Default)]
public class InventoryAppService
: CrudAppService,
IInventoryAppService
{
public InventoryAppService(IRepository repository) : base(repository)
{
GetPolicyName = MyAppPermissions.Inventory.Default;
GetListPolicyName = MyAppPermissions.Inventory.Default;
CreatePolicyName = MyAppPermissions.Inventory.Create;
UpdatePolicyName = MyAppPermissions.Inventory.Edit;
DeletePolicyName = MyAppPermissions.Inventory.Delete;
}
}
Plus an IApplicationService interface in *.Application.Contracts, DTOs split across two projects, and a PermissionDefinitionProvider. Five REST endpoints auto-generated, but five points of indirection.
Granit explicit shape:
public static class InventoryEndpoints
{
public static IEndpointRouteBuilder MapGranitInventory(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/inventory")
.WithTags("Inventory")
.RequireAuthorization("inventory.read");
group.MapGet("/{id:guid}", GetById);
group.MapGet("/", List);
group.MapPost("/", Create).RequireAuthorization("inventory.create");
group.MapPut("/{id:guid}", Update).RequireAuthorization("inventory.edit");
group.MapDelete("/{id:guid}", Delete).RequireAuthorization("inventory.delete");
return app;
}
public static async Task> Create(
InventoryItemCreateRequest request,
IInventoryRepository repo,
ICurrentTenant currentTenant,
CancellationToken ct)
{
var item = InventoryItem.Create(
Guid.NewGuid(), currentTenant.Id,
request.Name, request.Sku, request.Quantity, request.UnitPrice);
await repo.AddAsync(item, ct);
return TypedResults.Created($"/inventory/{item.Id}",
new InventoryItemResponse(item.Id, item.Name, item.Sku, item.Quantity, item.UnitPrice, item.CreatedAt));
}
// GetById / List / Update / Delete follow the same pattern.
}
The trade-off: you write each endpoint signature yourself. In return:
- Number of projects per module: 4 → 1 (or 2 if you split persistence).
- DTO ↔ entity mapping: runtime reflection → static
ToResponsemethod or Mapperly (compile-time). - Permissions:
PermissionDefinitionProvider+[Authorize(name)]→ ASP.NET Core policies declared inConfigureServices. - Validation: data annotations → FluentValidation classes, auto-discovered by
AddGranitValidatorsFromAssemblyContaining(). - Endpoint registration: declarative → explicit (you call
api.MapGranitInventory()fromProgram.cs).
You can read all five endpoint signatures in one sitting. That is the point.
The One Gotcha: Guid Keys
Granit's Entity base type fixes Id as Guid. There is no Entity, no AuditedAggregateRoot. If your ABP entities use AggregateRoot or ``, you need a one-shot key rewrite before any module is ported.
The team used deterministic UUID v5 in PostgreSQL:
-- Add new key column, backfilled with deterministic UUIDs.
ALTER TABLE inventory_items ADD COLUMN id_new uuid;
UPDATE inventory_items SET id_new = uuid_generate_v5('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', id::text);
-- Rewrite every dependent FK the same way (one statement per FK).
ALTER TABLE inventory_movements ADD COLUMN inventory_item_id_new uuid;
UPDATE inventory_movements SET inventory_item_id_new = uuid_generate_v5('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', inventory_item_id::text);
-- Swap the primary key.
ALTER TABLE inventory_items DROP CONSTRAINT inventory_items_pkey;
ALTER TABLE inventory_items DROP COLUMN id;
ALTER TABLE inventory_items RENAME COLUMN id_new TO id;
ALTER TABLE inventory_items ADD PRIMARY KEY (id);
-- Re-create each FK constraint.
ALTER TABLE inventory_movements DROP COLUMN inventory_item_id;
ALTER TABLE inventory_movements RENAME COLUMN inventory_item_id_new TO inventory_item_id;
ALTER TABLE inventory_movements ADD CONSTRAINT fk_inventory_movements_item FOREIGN KEY (inventory_item_id) REFERENCES inventory_items(id);
Critical sequencing: regenerate the ABP-side entity to Guid Id before running the SQL. If the ABP host stays on int Id after the rewrite, its EF Core model diverges from the physical schema and every read throws.
License Footnote
ABP Framework (OSS core) is LGPL-3.0 — you can link against it from proprietary code, but modifications to the framework must be published under LGPL. Many enterprise SCA tools flag LGPL as "needs legal review." ABP Commercial is proprietary, paid per-seat, vendor-tied. Granit (entire OSS surface) is Apache-2.0 — permissive, can use in proprietary derivatives.
What to Do Now
If you're on ABP and considering a move, start with the strangler-fig pattern. Map your primitives using the cheat sheet. And check your key types — that Guid gotcha will bite you.



