Introduction Link to heading

Multi-tenancy is powerful - but when designed poorly, even simple operations become complex.

This article dives into a real-world migration challenge and how structured iteration mitigates its flaws.

Finally, we’ll explore how Domain-Driven Design (DDD) could have prevented the issue entirely.

Understanding the Multi-Tenancy Design Flaw Link to heading

Multi-tenancy can be straightforward when done correctly. A well-structured system ensures that tenants remain logically separate, with all tenant-related data encapsulated within well-defined aggregates. However, in this case, tenant identifiers were embedded directly into almost every entity instead of being managed at the aggregate level.

This led to significant architectural issues, making even basic operations like reassigning an entity to a different owner unnecessarily complex.

The Consequences of This Flawed Design Link to heading

  • Lack of Clear Boundaries:
    Instead of treating tenants as aggregates that encapsulate related entities, each entity independently tracked its own tenant ID, leading to unnecessary duplication.

  • Cascading Updates Required for Simple Changes:
    Because every entity stored its own tenant ID, moving an entity from one parent to another required updating multiple references instead of modifying ownership at the aggregate level.

  • Risk of Data Inconsistency:
    Without natural consistency enforcement, migrations required manual updates across multiple entities, increasing the risk of data corruption.

Breaking Down the Migration Process Link to heading

To manage this complexity, we need a structured way to identify and migrate all affected entities without hardcoding dependencies. Instead of manually tracking relationships, we break the process into two phases: Discovery and Migration.

Phase 1: Discovery – Identifying What Needs Migration Link to heading

Before updating any references, the system must identify all entities requiring migration. This is done using Discoverers, where each entity type has a dedicated Discoverer responsible for finding and adding relevant entities to the migration process.

Each Discoverer iterates over already-identified entities, detecting related entities that also require migration. Since relationships between entities may be deeply nested, a single pass is insufficient. Instead, the system repeats the discovery process until no new entities remain unprocessed - a fix-point iteration approach that dynamically accounts for all dependencies.

This approach eliminates the need for predefined execution order, allowing migrations to adapt to complex relationships without hardcoded traversal logic.

Phase 2: Migration – Updating References Link to heading

Once the Discovery Phase identifies all relevant entities, the Migration Phase ensures their references are updated correctly to reflect the intended changes.

To maintain consistency, Migrators apply updates to specific entity types. Each Migrator processes discovered entities and modifies their relationships according to the migration plan, ensuring that dependencies remain intact.

This structured approach automates reference updates, preventing data inconsistencies and manual intervention, while keeping all dependent entities correctly linked.

Coordinating the Migration Process Link to heading

While Discoverers and Migrators handle specific entity types, a centralized mechanism ensures that the migration runs in a structured and repeatable manner.

This is where the Orchestrator comes in. It:

  1. Runs the Discovery Phase iteratively until no new entities are found.
  2. Executes the Migration Phase, ensuring all discovered entities are updated systematically.

This structured approach is particularly valuable in real-world systems, where data models are often more complex than in simplified examples. With deeply nested relationships across multiple domains, a well-defined discovery and migration process prevents overlooked dependencies and ensures predictable, maintainable migrations.

Strengths and Limitations of This Approach Link to heading

This structured migration process provides a way to manage relationships dynamically, ensuring that all dependencies are discovered and updated systematically. However, it remains a workaround for a flawed design rather than an ideal solution.

Strengths Link to heading

  • No need to track execution order: Fix-point iteration ensures all dependencies are discovered naturally.
  • Scales to complex real-world models: Structured discovery prevents missing hidden relationships.
  • Prevents inconsistencies: The MigrationContext centralizes updates, reducing the risk of errors.

Limitations Link to heading

  • Requires ongoing maintenance: As the model evolves, the discovery and migration logic must be manually updated.
  • Lacks natural consistency enforcement: The process relies on external mechanisms rather than built-in aggregate boundaries.
  • Adds unnecessary complexity: A well-structured domain model would eliminate the need for a migration mechanism altogether.

While this approach provides structure and automation, it highlights the importance of designing systems with proper aggregate boundaries from the start. In the next section, we’ll break down how this process works in code.

Code Walkthrough Link to heading

Defining our Model Link to heading

Before implementing the migration process, let’s define the example data model.

The diagram below represents a simplified multi-tenant structure, where each entity tracks its own tenant ID instead of inheriting it from a well-defined aggregate.

public class LawFirm
{
    public Guid Id { get; init; } = Guid.NewGuid();
    
    // Other fields
}

public class Clerk
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public required Guid LawFirmId { get; set; }
    
    // Other fields
}

public class Debtor
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public required Guid LawFirmId { get; set; }
    public required Guid ClerkId { get; set; }
    
    // Other fields
}

public class Claim
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public required Guid LawFirmId { get; set; }
    public required Guid DebtorId { get; set; }
    
    // Other fields
}

Now, let’s step through the migration process and see how we can systematically discover and update affected entities.

Core Components Link to heading

To manage the migration efficiently, the process is broken down into distinct components, each responsible for a specific aspect of discovery and updating:

  • MigrationContext: Tracks entity mappings and ensures data is updated consistently.
  • Discoverers: Identify all related entities that need to be migrated.
  • Migrators: Apply updates to maintain data consistency.
  • Orchestrator: Coordinates the entire process iteratively.

Each of these components plays a role in ensuring a structured and predictable migration.

MigrationContext: Keeping Track of Entity Mappings Link to heading

The MigrationContext serves as the central store for tracking entity relationships throughout the migration process. It performs two main tasks:

  • Discovery: Keeps track of entities identified for migration.
  • Mapping: Stores source-to-destination mappings to ensure references are updated consistently.

Instead of relying on direct entity lookups, the MigrationContext dynamically maintains discovered entities and their corresponding migration targets, ensuring data consistency.

public class MigrationContext
{
    private readonly Dictionary<Type, Dictionary<object, object>> _mappings = new();

    public IEnumerable<T> GetDiscoveredEntitiesOfType<T>() where T : class
    {
        _mappings
            .TryGetValue(typeof(T), out var discoveredEntities);
            
        return discoveredEntities?
            .Keys
            .Select(o => (T) o) ?? [];
    }

    public void RegisterMapping<T>(T source, T destination) where T : class
    {
        if (!_mappings.ContainsKey(typeof(T)))
        {
            _mappings[typeof(T)] = new Dictionary<object, object>();
        }
        _mappings[typeof(T)].Add(source, destination);
    }
    
    public void DiscoverEntity<T>(T entity) where T : class
    {
        if (!_mappings.ContainsKey(typeof(T)))
        {
            _mappings[typeof(T)] = [];
        }
        _mappings[typeof(T)].Add(entity, entity);
    }

    public bool IsDiscovered<T>(T entity) where T : class
    {
        return _mappings
            .TryGetValue(typeof(T), out var discoveredEntitiesOfType) && 
               discoveredEntitiesOfType.ContainsKey(entity);
    }

    public T GetDestinationEntity<T>(T entity) where T : class
    {
        return (T) _mappings[typeof(T)][entity];
    }

    public int GetDiscoveredCount()
    {
        return _mappings
            .Values
            .SelectMany(mappings => mappings.Values)
            .Count();
    }
}

Discoverers: Identifying What Needs to Be Migrated Link to heading

Discoverers are responsible for identifying all entities that require migration. Each entity type has a dedicated Discoverer that ensures no dependencies are overlooked.

The discovery process is iterative:

  • It starts with already-known entities.
  • It finds related entities that should also be migrated.
  • The process repeats until no new entities are found - ensuring all dependencies are included dynamically.

Here’s an example of a DebtorDiscoverer, which finds all debtors that belong to already-discovered clerks.

public class DebtorDiscoverer(DebtorRepository repository) : IDiscoverer
{
    public void Discover(MigrationContext context)
    {
        foreach (var clerk in context.GetDiscoveredEntitiesOfType<Clerk>())
        {
            var debtors = repository.GetByClerkId(clerk.Id);
            foreach (var debtor in debtors)
            {
                if (!context.IsDiscovered(debtor))
                {
                    context.DiscoverEntity(debtor);
                }
            }
        }
    }
}

What’s happening here? Link to heading

  • The DebtorDiscoverer scans all already-discovered Clerks.
  • It fetches all Debtors associated with those Clerks.
  • If a Debtor has not yet been added to the migration context, it is discovered and registered.
  • This process repeats iteratively until no new entities are found, ensuring all dependencies are included.

This approach removes the need to manually define execution order - fix-point iteration ensures that all necessary entities are discovered before migration begins.

Migrators: Applying the Migration Changes Link to heading

Migrators are responsible for applying reference updates to entities once they have been identified in the discovery phase. Each entity type has a dedicated Migrator that ensures all references are updated correctly.

Unlike Discoverers, which focus on identifying entities, Migrators apply structural modifications by updating foreign key relationships to reflect the new hierarchy.

Code Example: Updating Entity References Link to heading

Here’s an example of a DebtorMigrator, which updates a Debtor’s reference to its potentially new parent entity.

public class DebtorMigrator(DebtorRepository repository) : IMigrator
{
    public void Migrate(MigrationContext context)
    {
        foreach (var debtor in context.GetDiscoveredEntitiesOfType<Debtor>())
        {
            var discoveredClerkParent = context
                .GetDiscoveredEntitiesOfType<Clerk>()
                .SingleOrDefault(clerk => clerk.Id == debtor.ClerkId);

            if (discoveredClerkParent is null)
            {
                continue;
            }
            
            var destinationClerk = context
                .GetDestinationEntity(discoveredClerkParent);
            
            debtor.LawFirmId = destinationClerk.LawFirmId;
            debtor.ClerkId = destinationClerk.Id;

            repository.Update(debtor);
        }
    }
}

What’s happening here? Link to heading

  • The DebtorMigrator retrieves all discovered Debtors.
  • It finds the Clerk that each Debtor is currently assigned to.
  • If the Clerk has been migrated, the Debtor’s references are updated to reflect the new association.
  • The updated Debtor is then saved back to the repository, ensuring persistence.

By structuring the migration logic this way, we automate reference updates while ensuring that all dependent entities remain correctly linked. This method prevents inconsistencies and ensures that migrations follow a predictable and repeatable process.

Orchestrator: Running the Full Process Link to heading

The Orchestrator acts as the central coordinator of the migration process. Instead of executing rigidly ordered steps, it dynamically orchestrates both the Discovery and Migration phases to ensure a structured and reliable migration.

How It Works Link to heading

  1. Discovery Phase: Runs iteratively, allowing discoverers to identify related entities until no new entities are found.
  2. Migration Phase: Once discovery is complete, migrators update entity references to reflect the migration.

Code Example: Orchestrator Execution Link to heading

public class Orchestrator(IEnumerable<IDiscoverer> discoverers, IEnumerable<IMigrator> migrators)
{
    public void Orchestrate(MigrationContext context)
    {
        bool newEntitiesDiscovered;
        do
        {
            newEntitiesDiscovered = false;
            foreach (var discoverer in discoverers)
            {
                var initialCount = context.GetDiscoveredCount();
                discoverer.Discover(context);
                if (context.GetDiscoveredCount() > initialCount)
                {
                    newEntitiesDiscovered = true;
                }
            }
        } while (newEntitiesDiscovered);

        foreach (var migrator in migrators)
        {
            migrator.Migrate(context);
        }
    }
}

What’s happening here? Link to heading

  • The Discovery Phase runs iteratively until no new entities are found, ensuring all dependencies are accounted for dynamically.
  • Each Discoverer processes known entities and identifies new related entities to be migrated.
  • Fix-point iteration ensures completeness, meaning the process repeats until no further entities need to be migrated.
  • Once discovery is complete, each Migrator updates entity references according to the migration plan, ensuring consistency.

This structured execution ensures that all related entities are handled correctly without requiring hardcoded migration sequences.

Putting It All Together Link to heading

To migrate a set of Debtors and their Claims from one Clerk to another, we pre-populate the MigrationContext and execute the Orchestrator:

    var migrationContext = new MigrationContext();
    migrationContext.RegisterMapping(sourceClerk, destinationClerk);

    var orchestrator = new Orchestrator(discoverers, migrators);
    orchestrator.Orchestrate(migrationContext);

By defining what should be migrated, the system automatically discovers all dependent entities and applies the necessary updates.

Flexible Migration Capabilities Link to heading

This system isn’t limited to Clerk-level migrations. Because we can pre-populate the MigrationContext with different entity mappings, it offers some flexibility in handling migrations at different levels.

For example, it could also be used to move Claims from one Debtor to another by pre-populating the context accordingly:

    var migrationContext = new MigrationContext();
    migrationContext.RegisterMapping(sourceDebtor, destinationDebtor);

    var orchestrator = new Orchestrator(discoverers, migrators);
    orchestrator.Orchestrate(migrationContext);

In this case, all affected Claims would automatically update their references to the new Debtor and Law Firm, following the same structured discovery and migration process.

Flexibility Within the Given Structure Link to heading

While this example demonstrates some flexibility, the approach is still limited by the underlying model. If the relationships between entities were different or more complex, adjustments might be necessary to ensure correctness. However, within the constraints of this system, the core migration principle remains applicable to similar scenarios.

A Note on This Demonstration Link to heading

This demonstration is strongly simplified to focus on the principles applied, removing unnecessary implementation details like transactions or persistence layers. In real-world scenarios, adjustments may be needed based on specific constraints and business rules.

It is important to note that this is not a one-size-fits-all solution but rather a structured approach to solving a migration problem in a flawed multi-tenant system. A well-designed DDD-based architecture would have eliminated much of this complexity in the first place.

For the full implementation, check out the GitHub repository.

How It Should Have Been: The Power of DDD Link to heading

In a well-designed system, migrating Debtors between Clerks should not require a complex migration mechanism. The reason our approach was necessary is that the system was not designed with proper Aggregate Roots, forcing us to update foreign keys across multiple entities manually.

The Core Issue: Scattered Tenant IDs Link to heading

The biggest design flaw in our system was that each entity (Clerk, Debtor, Claim) stored its own tenant ID, leading to:

  • Tightly coupled entities that required cascading updates during migration.
  • No clear ownership structure, meaning relationships had to be discovered dynamically.
  • Manual consistency enforcement, making migration error-prone and high-risk.

In a DDD-compliant system, we would model our data in a way that ensures consistency naturally, avoiding the need for such migration logic.

How Aggregate Roots Solve This Problem Link to heading

In Domain-Driven Design (DDD), an Aggregate Root is the single point of reference for a group of entities that should always be consistent. Instead of each entity tracking its own tenant, a single Aggregate would enforce consistency at the root level.

What this means in practice:

  • A Clerk owns its Debtors: Debtors reference their Clerk, not the Law Firm directly.
  • A Debtor owns its Claims: Claims reference their Debtor, not the Law Firm directly.
  • The Law Firm owns Clerks: Moving a Clerk moves everything inside it automatically.

How Migration Would Work in a DDD-Based System Link to heading

In a properly designed system, migrating Debtors wouldn’t require foreign key updates across multiple tables - it would simply be a change in ownership at the Aggregate level.

Here’s how the migration would work:

  • Find all Debtors belonging to the source Clerk.
  • Reassign them to the destination Clerk.
  • Since Claims are part of the Debtor Aggregate, they remain correctly associated.

With the right Aggregate boundaries, this operation is naturally consistent - no need for a Discovery Phase, no Migrators, and no complex MigrationContext.

Lessons Learned: Why DDD Matters Link to heading

  • A well-structured model eliminates complexity: When relationships are correctly modeled, data naturally stays consistent.
  • Aggregate Roots provide built-in consistency: Instead of relying on manual updates, changes propagate naturally.
  • Migrating an Aggregate is a simple operation: No need for a multi-step process, just an update at the root level.

Closing Thoughts Link to heading

This example highlights how critical it is to establish strong domain boundaries early. Without proper aggregates, even simple operations can become complex and error-prone. A well-designed system would have eliminated the need for a migration mechanism altogether.

Of course, not every system starts out with the right architecture. Many businesses inherit legacy systems, making it difficult to apply DDD principles retroactively. However, recognizing these structural flaws is the first step toward improving long-term maintainability.

By focusing on Aggregate Root design and clear ownership boundaries, we can prevent tenant migration problems before they happen. These principles don’t just simplify one-off migrations - they create scalable, maintainable architectures that eliminate technical debt before it accumulates.

With a well-structured multi-tenancy model, migrations don’t need to be complex—they simply happen.