Skill v1.0.2
currentAutomated scan100/1002 files
version: "1.0.2" name: bwfc-data-migration description: "WORKFLOW SKILL — Migrate Web Forms data access and architecture to Blazor Server. Covers EF6→EF Core with IDbContextFactory, Session→SessionShim, Global.asax→Program.cs, Web.config→appsettings.json, DataSource controls→service injection. WHEN: \"migrate EF6\", \"session state to services\", \"Global.asax to Program.cs\", \"data access migration\", \"SelectMethod to delegate\". INVOKES: dotnet CLI for EF migrations. FOR SINGLE OPERATIONS: use bwfc-migration for markup, bwfc-identity-migration for auth."
Web Forms Data Access & Architecture Migration
Overview
Covers data access and architecture migration — the Layer 2/3 decisions requiring project-specific judgment.
Related: /bwfc-migration (markup), /bwfc-identity-migration (auth)
When to Use This Skill
- Convert
SelectMethodstring →SelectHandlerdelegate, replaceDataSourcecontrols with service injection - Migrate Entity Framework 6 → EF Core
- Convert
Session/ViewState/Applicationstate to Blazor patterns - Migrate
Global.asax→Program.cs,Web.config→appsettings.json - Replace HTTP Handlers/Modules with middleware
Critical Rules
Session State Migration
Use SessionShim (Default — Works Everywhere)
Pages inheriting WebFormsPageBase get a Session property backed by SessionShim. SessionShim works in BOTH SSR and interactive modes:
- SSR: Reads/writes to ASP.NET Core
ISession(cookie-backed) - Interactive: Uses in-memory
ConcurrentDictionaryscoped per circuit
Original Web Forms:
Session["CartId"] = Guid.NewGuid().ToString();var cartId = Session["CartId"].ToString();Session["payment_amt"] = 99.99m;
Migrated Blazor (IDENTICAL):
Session["CartId"] = Guid.NewGuid().ToString();var cartId = Session["CartId"].ToString();Session["payment_amt"] = 99.99m;
No IHttpContextAccessor. No Minimal API. No cookies. Just Session["key"].
For non-page components, inject SessionShim directly:
@inject SessionShim Session@code {protected override void OnInitialized(){var userId = Session["UserId"]?.ToString() ?? "guest";}}
When to Upgrade Beyond SessionShim
Only consider alternatives when you need cross-tab or cross-server persistence:
ProtectedBrowserStorage — For data that must survive page refreshes:
@inject ProtectedSessionStorage SessionStorageprotected override async Task OnAfterRenderAsync(bool firstRender){if (firstRender){var result = await SessionStorage.GetAsync<ShoppingCart>("cart");cart = result.Success ? result.Value! : new ShoppingCart();}}
Database-backed — For shopping carts that persist across sessions:
public class CartService(IDbContextFactory<AppDbContext> factory){public async Task<Cart> GetCartAsync(string userId){using var db = factory.CreateDbContext();return await db.Carts.Include(c => c.Items).FirstOrDefaultAsync(c => c.UserId == userId) ?? new Cart();}}
Scoped services — When the pattern doesn't fit key-value storage:
public class WizardStateService{public int CurrentStep { get; set; }public FormData Data { get; set; } = new();public bool IsComplete => CurrentStep == 5 && Data.IsValid();}// Program.csbuilder.Services.AddScoped<WizardStateService>();
Progression model:
- Start with SessionShim (zero migration cost)
- Move to scoped services if you need typed, structured state
- Move to database if you need persistence across circuits/sessions
1. Entity Framework 6 → EF Core
Web Forms: EF6 with DbContext instantiated directly in code-behind or via SelectMethod string binding. Blazor: EF Core 10.0.3 (latest .NET 10) with IDbContextFactory registered in DI.
Step 1: Detect the provider. The L1 script'sFind-DatabaseProviderfunction readsWeb.config<connectionStrings>and scaffolds the correct EF Core package. Check the L1 output's[DatabaseProvider]review item for the detected provider and connection string. Use these values in yourProgram.csconfiguration — do not guess or substitute.CRITICAL: Preserve the original database provider. Examine the Web Forms project'sWeb.configconnection strings and EF configuration to identify the database provider (SQL Server, PostgreSQL, MySQL, SQLite, Oracle, etc.). The migrated Blazor application MUST use the same database provider — do NOT switch providers unless explicitly requested by the user.⚠️ NEVER default to SQLite. The most common Web Forms database is SQL Server (often LocalDB for dev). If you seeSystem.Data.SqlClientor(LocalDB)in connection strings, useMicrosoft.EntityFrameworkCore.SqlServer— NOTMicrosoft.EntityFrameworkCore.Sqlite. SQLite is ONLY appropriate if the original application specifically usedSystem.Data.SQLite.
Database Provider Detection & Migration
Step 1: Identify the original provider from the Web Forms project:
| Web.config Indicator | Original Provider | EF Core Package | |
|---|---|---|---|
providerName="System.Data.SqlClient" | SQL Server | Microsoft.EntityFrameworkCore.SqlServer | |
providerName="System.Data.SQLite" | SQLite | Microsoft.EntityFrameworkCore.Sqlite | |
providerName="Npgsql" or Server=...;Port=5432 | PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL | |
providerName="MySql.Data.MySqlClient" | MySQL | Pomelo.EntityFrameworkCore.MySql or MySql.EntityFrameworkCore | |
providerName="Oracle.ManagedDataAccess.Client" | Oracle | Oracle.EntityFrameworkCore |
Step 2: Install the matching EF Core provider package in the Blazor project:
# Example for SQL Serverdotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 10.0.3# Example for PostgreSQLdotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.3# Example for MySQL (Pomelo)dotnet add package Pomelo.EntityFrameworkCore.MySql --version 10.0.3
Step 3: Configure the matching provider in Program.cs:
// SQL Server — matches System.Data.SqlClientoptions.UseSqlServer(connectionString)// PostgreSQL — matches Npgsqloptions.UseNpgsql(connectionString)// MySQL — matches MySql.Data.MySqlClientoptions.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))// SQLite — matches System.Data.SQLiteoptions.UseSqlite(connectionString)
Install matching EF Core packages for .NET 10:Microsoft.EntityFrameworkCore, the provider-specific package (see table above),.Tools, and.Design.
// Web Forms — direct DbContext in code-behindpublic IQueryable<Product> GetProducts(){var db = new ProductContext();return db.Products;}
// Blazor — Program.cs (use the provider that matches the original Web Forms database)builder.Services.AddDbContextFactory<ProductContext>(options =>options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));// ↑ Replace with UseNpgsql(), UseMySql(), UseSqlite(), etc. to match original provider
// Blazor — Service layerpublic class ProductService(IDbContextFactory<ProductContext> factory){public async Task<List<Product>> GetProductsAsync(){using var db = factory.CreateDbContext();return await db.Products.ToListAsync();}public async Task<Product?> GetProductAsync(int id){using var db = factory.CreateDbContext();return await db.Products.FindAsync(id);}}
Critical: UseIDbContextFactory, NOTAddDbContext, for Blazor Server. Blazor circuits are long-lived — a singleDbContextaccumulates stale data and tracking issues.
EF6 → EF Core API Changes
| EF6 | EF Core | Notes | |
|---|---|---|---|
using System.Data.Entity; | using Microsoft.EntityFrameworkCore; | Namespace change | |
DbModelBuilder in OnModelCreating | ModelBuilder | Same concepts, different API | |
HasRequired() / HasOptional() | Navigation properties + IsRequired() | Simpler relationship config | |
Database.SetInitializer(...) | Database.EnsureCreated() or Migrations | Different init strategy | |
db.Products.Include("Category") | db.Products.Include(p => p.Category) | Prefer lambda includes | |
WillCascadeOnDelete(false) | .OnDelete(DeleteBehavior.Restrict) | Cascade config | |
.HasDatabaseGeneratedOption(...) | .ValueGeneratedOnAdd() | Key generation |
Connection String Migration
<!-- Web Forms — Web.config --><connectionStrings><add name="DefaultConnection"connectionString="Data Source=(LocalDb)\MSSQLLocalDB;Initial Catalog=MyApp;Integrated Security=True"providerName="System.Data.SqlClient" /></connectionStrings>
// Blazor — appsettings.json{"ConnectionStrings": {"DefaultConnection": "Data Source=(LocalDb)\\MSSQLLocalDB;Initial Catalog=MyApp;Integrated Security=True"}}
2. DataSource Controls → Service Injection
Web Forms DataSource controls have no BWFC equivalent. Replace with injected services.
<!-- Web Forms — declarative data binding --><asp:SqlDataSource ID="ProductsDS" runat="server"ConnectionString="<%$ ConnectionStrings:DefaultConnection %>"SelectCommand="SELECT * FROM Products" /><asp:GridView DataSourceID="ProductsDS" runat="server" />
@* Blazor — service injection *@@inject IProductService ProductService<GridView Items="products" ItemType="Product" AutoGenerateColumns="true" />@code {private List<Product> products = new();protected override async Task OnInitializedAsync(){products = await ProductService.GetProductsAsync();}}
Service Registration Pattern
// Program.cs — use the provider that matches the original Web Forms databasebuilder.Services.AddRazorComponents().AddInteractiveServerComponents();builder.Services.AddBlazorWebFormsComponents(); // ⚠️ REQUIRED — registers BWFC servicesbuilder.Services.AddDbContextFactory<ProductContext>(options =>options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));// ↑ Match the original provider: UseNpgsql(), UseMySql(), UseSqlite(), etc.builder.Services.AddScoped<IProductService, ProductService>();builder.Services.AddScoped<ICategoryService, CategoryService>();builder.Services.AddScoped<IOrderService, OrderService>();// ... after builder.Build() ...app.UseBlazorWebFormsComponents(); // ⚠️ REQUIRED — .aspx URL rewriting middleware. BEFORE MapRazorComponents.app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
SelectMethod String → SelectHandler Delegate Conversion
BWFC's DataBoundComponent<ItemType> has a native SelectMethod parameter of type SelectHandler<ItemType> — a delegate with signature (int maxRows, int startRowIndex, string sortByExpression, out int totalRowCount) → IQueryable<ItemType>. When set, OnAfterRenderAsync automatically calls it to populate Items. This is the native BWFC data-binding pattern that mirrors how Web Forms did it.
Option A — Preserve SelectMethod as delegate (recommended):
| Web Forms SelectMethod | BWFC SelectMethod Delegate | |
|---|---|---|
SelectMethod="GetProducts" | SelectMethod="@productService.GetProducts" (if signature matches SelectHandler<T>) | |
SelectMethod="GetProduct" | SelectMethod="@productService.GetProduct" (or use DataItem for single-record controls) |
Option B — Items binding (ONLY when original used DataSource, NOT SelectMethod):
⚠️ Use Option B ONLY when the original Web Forms markup usedDataSource/DataBind(), NOT when it usedSelectMethod. If the original hadSelectMethod="GetProducts", you MUST use Option A above.
| Web Forms SelectMethod | Blazor Service Call | |
|---|---|---|
SelectMethod="GetProducts" | products = await ProductService.GetProductsAsync(); then Items="@products" | |
SelectMethod="GetProduct" | product = await ProductService.GetProductAsync(id); then DataItem="@product" |
CRUD methods (no BWFC parameter equivalent — wire to service calls in event handlers):
| Web Forms Method | Blazor Service Call | |
|---|---|---|
InsertMethod="InsertProduct" | await ProductService.InsertAsync(product); | |
UpdateMethod="UpdateProduct" | await ProductService.UpdateAsync(product); | |
DeleteMethod="DeleteProduct" | await ProductService.DeleteAsync(id); |
3. Session, ViewState, and Application State Migration
Web Forms: Session["key"], ViewState["key"], Application["key"] dictionaries. Blazor: SessionShim (auto-registered by AddBlazorWebFormsComponents()), component fields, and singleton services.
Session["key"] → SessionShim (Zero-Change Migration)
No code changes needed. WebFormsPageBase.Session delegates to SessionShim automatically:
// Web Forms — works IDENTICALLY in Blazor via SessionShimSession["ShoppingCart"] = cart;var cart = (ShoppingCart)Session["ShoppingCart"];// SessionShim also supports typed access:var cart = Session.Get<ShoppingCart>("ShoppingCart");Session.Set("ShoppingCart", cart);
How SessionShim works:
- SSR mode: Backed by ASP.NET Core
ISession(cookie-persisted) - Interactive mode: In-memory
ConcurrentDictionaryscoped per circuit - Seamless: Automatically switches based on render mode
For non-page components:
@inject SessionShim Session@code {private string GetUserId() => Session["UserId"]?.ToString() ?? "guest";}
ViewState["key"] → Component Fields
ViewState is component-instance state. Use normal C# fields/properties:
// Web FormsViewState["CurrentPage"] = pageIndex;var page = (int)ViewState["CurrentPage"];// Blazorprivate int currentPage;
Application["key"] → Singleton Services
Application-wide state becomes singleton services:
// AppStateService.cspublic class AppStateService{private readonly ConcurrentDictionary<string, object> _state = new();public void Set(string key, object value) => _state[key] = value;public T? Get<T>(string key) => _state.TryGetValue(key, out var val) ? (T)val : default;}// Program.csbuilder.Services.AddSingleton<AppStateService>();
State Storage Options
| Web Forms | Blazor Equivalent | Scope | |
|---|---|---|---|
Session["key"] | Scoped service | Per-circuit (lost on disconnect) | |
Session["key"] (persistent) | ProtectedSessionStorage | Browser session tab | |
Application["key"] | Singleton service | App-wide | |
Cache["key"] | IMemoryCache or IDistributedCache | Configurable | |
ViewState["key"] | Component fields/properties | Per-component | |
TempData["key"] | ProtectedSessionStorage | One read | |
Cookies | ProtectedLocalStorage or HTTP endpoints | Browser |
ProtectedSessionStorage Example
@inject ProtectedSessionStorage SessionStorage@code {protected override async Task OnAfterRenderAsync(bool firstRender){if (firstRender){var result = await SessionStorage.GetAsync<ShoppingCart>("cart");cart = result.Success ? result.Value! : new ShoppingCart();}}private async Task SaveCart(){await SessionStorage.SetAsync("cart", cart);}}
Note:ProtectedSessionStorageonly works after the first render (it requires JS interop). Always check inOnAfterRenderAsync, notOnInitializedAsync.
Reference Documents
Architecture migration patterns (Global.asax, Web.config, routes, handlers, enhanced navigation) are in the child document:
- [ARCHITECTURE-TRANSFORMS.md](ARCHITECTURE-TRANSFORMS.md) Global.asax → Program.cs, Web.config → appsettings.json, route table → @page directives, HTTP handlers/modules → middleware, third-party integrations → HttpClient, files to create during migration, and Blazor enhanced navigation workarounds.
Common Data Migration Gotchas
DbContext Lifetime — CRITICAL
Blazor Server circuits are long-lived. Always use IDbContextFactory and create short-lived DbContext instances per operation.
WRONG — IQueryable returned from disposed context:
private IQueryable<Product> GetProducts(int categoryId){using var db = DbFactory.CreateDbContext();return db.Products.Where(p => p.CategoryId == categoryId); // Context disposed before query executes!}
RIGHT — materialize inside using block:
private IQueryable<Product> GetProducts(int categoryId){using var db = DbFactory.CreateDbContext();var results = db.Products.Where(p => p.CategoryId == categoryId).ToList(); // Execute query NOW while context is alivereturn results.AsQueryable(); // Return materialized data as IQueryable}
For SelectHandler delegates, the delegate is invoked by BWFC infrastructure AFTER your method returns. You MUST materialize:
// BWFC SelectHandler delegate — MUST materializeprivate IQueryable<Product> SelectProducts(int maxRows, int startRowIndex,string sortByExpression, out int totalRowCount){using var db = DbFactory.CreateDbContext();totalRowCount = db.Products.Count();var results = db.Products.OrderBy(p => p.Name).Skip(startRowIndex).Take(maxRows).ToList(); // CRITICAL — materialize NOWreturn results.AsQueryable();}
No Page-Level Transaction Scope
Web Forms SelectMethod runs inside a page lifecycle. Blazor doesn't have this. Use explicit transaction scopes in services if needed:
using var db = factory.CreateDbContext();using var transaction = await db.Database.BeginTransactionAsync();// ... operationsawait transaction.CommitAsync();
Async All the Way
Web Forms SelectMethod returns IQueryable synchronously. Blazor services should be async:
// WRONG: return db.Products.ToList();// RIGHT: return await db.Products.ToListAsync();
ConfigurationManager Shim Available
ConfigurationManager.AppSettings["key"] works via BWFC's ConfigurationManager shim. Call app.UseConfigurationManagerShim() in Program.cs to bind it to IConfiguration. For new code, prefer injecting IConfiguration or using the Options pattern.
Static Helpers with HttpContext
Web Forms often has static helper classes that access HttpContext.Current. These must be refactored to accept dependencies via constructor injection.
ThreadAbortException Dead Code Warning
Web Forms throws ThreadAbortException when Response.Redirect(url, true) is called with endResponse=true. Blazor does not throw this exception — ResponseShim.Redirect() silently ignores the endResponse parameter. Any catch (ThreadAbortException) blocks become dead code after migration. Review and remove them. Code that runs AFTER Response.Redirect(url, true) will execute in Blazor (unlike Web Forms where execution stopped).
❌ Common Anti-Patterns to Avoid
DO NOT Create Minimal API Endpoints for Page Actions
Minimal APIs are for real HTTP endpoints (REST APIs, webhooks), NOT for migrating Web Forms page actions.
WRONG:
// Program.cs — creating API endpoint for a page actionapp.MapPost("/api/cart/add", async (CartItem item, CartService cart) =>{cart.Add(item);return Results.Ok();});// Cart.razor — calling the APIawait Http.PostAsJsonAsync("/api/cart/add", item);
RIGHT:
// Cart.razor — just call the service directly@inject CartService CartService<button @onclick="() => CartService.Add(item)">Add to Cart</button>
When Minimal APIs ARE appropriate:
- External REST API consumed by mobile apps, SPAs, or third parties
- Webhooks from payment processors, GitHub, etc.
- Form POST endpoints for authentication (login/logout/register) — these need HTTP context for cookies
When they are NOT appropriate:
- Replacing button click handlers in migrated Web Forms pages
- Working around Session["key"] access — use SessionShim instead
- "Because HttpContext is null" — you don't need HttpContext for most operations
DO NOT Use IHttpContextAccessor to Access Session
You already have Session via WebFormsPageBase or @inject SessionShim.
WRONG:
@inject IHttpContextAccessor HttpContextAccessorvar session = HttpContextAccessor.HttpContext?.Session;var cartId = session?.GetString("CartId");
RIGHT:
@inherits WebFormsPageBasevar cartId = Session["CartId"]?.ToString();
DO NOT Replace Session with Cookies
If the original Web Forms code used Session["key"], use SessionShim. Don't invent cookie-based workarounds.
WRONG:
// Creating cookie-based cart ID because "Session doesn't work in Blazor"Response.Cookies.Append("CartId", Guid.NewGuid().ToString());var cartId = Request.Cookies["CartId"];
RIGHT:
// SessionShim handles the storage — just use SessionSession["CartId"] = Guid.NewGuid().ToString();var cartId = Session["CartId"]?.ToString();
DO NOT Use HttpContext.Current.Session
There is no HttpContext.Current in ASP.NET Core. Use the Session property.
WRONG:
HttpContext.Current.Session["UserId"] = userId;
RIGHT:
Session["UserId"] = userId; // From WebFormsPageBase or injected SessionShim
| Error Signature | Recipe File | |
|---|---|---|
CS1503: SelectMethod ... 'string' to 'SelectHandler' | ../bwfc-migration/recipes/selectmethod-string-binding.md | |
CS7036: no argument ... 'options' of 'XxxContext' | ../bwfc-migration/recipes/new-dbcontext-to-di.md | |
CS0246: 'IDatabaseInitializer' | ../bwfc-migration/recipes/database-seed-initializer.md |