Back to Software
Software Engineering

Email-as-Username Migration Console (Blazor SSR + Transactional Migrations)

A migration console that consolidates legacy user identities into email-based users, preserves permissions across hierarchies, and automates onboarding emails with rollback support.

dotnetblazoref-coresql-serverworkosserilogmigrationsdata-integrity

Executive Summary

I built a Blazor SSR migration console to move legacy “username-based” identities to a modern email-based identity model, while preserving permissions and organizational structure. The tool supports preview → validate → commit, writes a detailed MigrationHistory record for rollback, and sends onboarding emails via a background worker.

The core challenge

Identity migrations aren’t “just data updates.” They’re a combination of:

  • Translating legacy identities into a new canonical user model
  • Preserving authorization semantics across:
    • Distributor admins
    • End-user admins
    • Site/security users
    • Super users with cross-site permissions
  • Avoiding user-impacting mistakes (wrong access, duplicate accounts, orphaned links)
  • Making the process safe enough to run weekly on batches

Why a dedicated tool was necessary

This migration needed:

  • A guided UI for selecting migration targets and validating state
  • Repeatable transformations (not tribal-knowledge runbooks)
  • Visibility into what changed and what failed
  • Controlled commits and a rollback story if something goes wrong

My role

  • Implemented the migration engine and data shaping logic
  • Built the UI workflow (select → preview → validate → commit)
  • Implemented transactional commit + rollback support
  • Implemented onboarding email queue/worker
  • Implemented special-case scrubbing (e.g., scheduled report recipient lists)

System Design

The solution is a single .NET web app that connects to a CRM database and dynamically connects to multiple “site” databases. A preview step computes the full transformation and collects tasks; commit applies changes in a transaction and queues emails.

Architectural components

  • Blazor SSR UI
    • Handles selection, previews, and operator actions
  • Migration engine
    • Computes transformations using immutable structures to avoid accidental mutation
  • Persistence
    • EF Core factories for CRM DB access
    • Dynamic model cache key strategy for per-site DB contexts
  • WorkOS integration
    • User creation and registration token generation
  • Email worker
    • In-memory queue + background service that sends registration emails

The multi-database strategy uses a custom EF Core model cache key factory:

// Enable separate cached models per database schema
public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
{
    public object Create(DbContext context, bool designTime = false)
    {
        return context is SiteDbContext myContext
            ? (context.GetType(), myContext.Schema)
            : context.GetType();
    }
}

This allows EF Core to cache different models per schema, enabling dynamic connections to hundreds of site databases without model conflicts.

Why SSR (server-side rendering)

SSR keeps the browser from trying to hold massive migration state in-memory and makes it easier to process large object graphs without client-side limits.


Migration Workflow

Operators select a migration target, run Preview, inspect validations, then Commit. The system records a structured before/after “PreviousState” blob so changes can be reversed if needed.

Preview phase (compute everything without committing)

Preview builds a complete plan:

  1. Identify distributors being migrated
  2. Identify end users under each distributor
  3. Identify databases/sites under each end user
  4. Compute all users that must exist in the new model:
    • distributor admins
    • end-user admins
    • site users
    • super users
  5. Compute the link tables that must be created/updated
  6. Build a migration history record describing:
    • what was created
    • what was linked
    • what must be removed or reverted on rollback
  7. Build email tasks for newly created users (deduped by email)

Commit phase (apply changes + queue emails)

  • Write MigrationHistory rows
  • Apply entity/link mutations
  • Commit DB transaction
  • Enqueue onboarding emails for the email worker

Rollback phase (controlled undo)

Rollback is designed to undo what the tool introduced, not delete legacy data recklessly. The tool tracks created records and reversible edits so it can revert a partial migration safely.


Data Modeling & “Identity Consolidation” Rules

The most important logic is consolidating many legacy accounts into a smaller number of email-based identities while preserving permissions and site access.

Why consolidation is hard

Legacy systems tend to allow:

  • multiple usernames for the same person
  • reused emails across different orgs
  • super users that span multiple end users or even distributors
  • permission drift over time

The tool treats email as the canonical identity and then builds the correct associations.

How the tool stays deterministic

  • Uses immutable collections for computed sets (reduces accidental mutation)
  • Uses explicit lookups and grouping keys to avoid “implicit” joins
  • Dedupes email tasks by normalized email identity
  • Records what it did for every distributor so the operator can audit

Super-user classification and edge cases

A key section of the migration logic separates:

  • “end-user scoped” super users
  • “distributor scoped” super users based on where their permissions and database affiliations truly land.

The classification uses a deterministic algorithm based on database affiliation counts:

// Determine if super user spans multiple end users
private async Task<bool> IsDistributorSuperUser(
    Account account, Account distributorAccount)
{
    // Step 1: Check if username contains distributor name
    if (!string.IsNullOrEmpty(account.Username))
    {
        var usernameParts = account.Username.Split('-');
        if (usernameParts.Length > 1)
        {
            var usernameMatch = distributorAccount.Company
                .Contains(usernameDistributorPart, OrdinalIgnoreCase);
            if (usernameMatch) return true;
        }
    }

    // Step 2: Check unique parent count
    var uniqueParentIdCount = GetUniqueParentIdCount(account);
    return uniqueParentIdCount != 1;  // Spans multiple = Distributor SU
}

This avoids granting overly broad access during consolidation.


Special Case: Report Schedule Recipient Scrubbing

Some legacy tables stored usernames in scheduled email recipient lists. The tool scrubs those lists by translating usernames to the correct email identity so scheduled reports keep reaching the right people after migration.

What this fixes

If a schedule stored:

  • userA;userB;userC

and usernames become defunct, then schedules silently fail.

The tool:

  • parses and normalizes recipient lists
  • replaces usernames with emails where a mapping exists
  • dedupes recipients after replacement
// Translate username-based recipient lists to email addresses
public async Task ScrubReportSchedules(
    Dictionary<string, string?> usernameEmailDict,
    SiteDbContext siteDbContext)
{
    var schedules = siteDbContext.ReportSchedules
        .Where(e => !string.IsNullOrWhiteSpace(e.EmailAddress))
        .ToList();

    foreach (var report in schedules)
    {
        List<string> userNames = [..report.EmailAddress.Split(';')];

        var result = userNames
            .Select(un => usernameEmailDict.GetValueOrDefault(un) ?? un)
            .Distinct().ToList();

        report.EmailAddress = string.Join(';', result);
    }
}

This is the kind of “hidden integration surface” that breaks migrations in production if you don’t hunt it down.


Reliability & Operational Safety

This tool is built for real operations: clear preview output, transactional commits, and a migration history trail that supports auditing and rollback.

Safety features

  • Transaction-backed commits (avoid partial state)
  • MigrationHistory persisted per distributor
  • New user onboarding emails only sent after commit
  • Logging via Serilog for “what happened in batch X?”

The commit pattern ensures emails are only sent after successful persistence:

private IDbContextTransaction? _transaction = ctx.Database.BeginTransaction();

public async Task CommitMigration()
{
    ctx.MigrationHistory.AddRange(_migrationHistory);
    await ctx.SaveChangesIfAnyAsync();

    // Only queue emails after successful SaveChanges
    foreach (var emailTask in _emailTasks)
    {
        emailQueue.Enqueue(emailTask);
    }

    await _transaction?.CommitAsync()!;
}

public async Task RollbackMigration()
{
    _migrationHistory.Clear();
    await _transaction?.RollbackAsync()!;
}

What I’d harden next

  • A “dry-run export” (write preview plans to files for peer review)
  • More explicit idempotency controls if reruns become common
  • Integration test harness against sanitized DB snapshots
  • Built-in “post-migration validation scripts” surfaced in the UI

Technologies Used

  • .NET 8
  • Blazor SSR
  • EF Core (SQL Server)
  • WorkOS integration (user provisioning)
  • Serilog
  • Background worker + in-memory task queue