Documentation

how-to/entity-refactoring-guid.md

Pattern: Entity Refactoring to GUID Primary Keys

This pattern documents the step-by-step process for refactoring a component's entities from long/int primary keys to Guid primary keys, following ADR 034.

When to Use This Pattern

Apply this pattern when:

  • Refactoring an existing component to use GUID primary keys
  • Creating a new component and want to follow current standards
  • Converting junction entities to implicit many-to-many relationships
  • Standardizing TenantId across components

Prerequisites

  • Read ADR 034: GUID Primary Keys
  • Understand the component's current entity structure
  • Identify which entities are domain entities (get Guids) vs reference data (keep int/short)
  • Backup database if refactoring existing data

Step-by-Step Process

Phase 1: Analysis

1. Inventory Entities

List all entity classes in the component and categorize:

Domain Entities (→ Guid):
- User
- Role
- Permission
- Group
- RefreshToken

Reference Data (→ int/short):
- UserStatus
- Tenant (special case - discuss)

Junction Tables (→ Delete):
- UserRole
- RolePermission

2. Identify Passport Entities

Which entities participate in the global Passport registry?

  • Usually: Core domain entities with cross-component references
  • Example: User, Group in Identity component

3. Map Relationships

Document all foreign key relationships:

User.Id ← RefreshToken.UserId
Role.Id ← UserRole.RoleId (will become implicit)
Permission.Id ← RolePermission.PermissionId (will become implicit)

Phase 2: Delete Junction Entities

For each junction table being replaced with implicit many-to-many:

  1. Delete the entity class file:

    rm src/Component.Abstractions/Database/UserRole.cs
    
  2. Remove DbSet from DbContext:

    // REMOVE:
    public virtual DbSet<UserRole> UserRoles { get; set; }
    
  3. Remove entity configuration from OnModelCreating:

    // REMOVE entire entity configuration block:
    modelBuilder.Entity<UserRole>(entity => { ... });
    

Phase 3: Refactor Domain Entities

For each domain entity, apply this template:

BEFORE:

[Table("users")]
public class User
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("id", Order = 1)]
    public long Id { get; set; }

    [Column("tenant_id", Order = 34)]
    public long TenantId { get; set; }

    // Navigation - old junction
    [InverseProperty(nameof(UserRole.User))]
    public virtual ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
}

AFTER:

[Table("users")]
public class User
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    [Column("id", Order = 1)]
    public Guid Id { get; set; }

    [Column("platform_type_id", Order = 2)]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public short PlatformTypeId { get; set; } = 30; // PTIDS.USER

    [Column("tenant_id", Order = 34)]
    public Guid TenantId { get; set; }

    // Navigation - implicit many-to-many
    public virtual ICollection<Role> Roles { get; set; } = new List<Role>();
}

Checklist for each entity:

  • Change Id type from long/int to Guid
  • Change [DatabaseGenerated] to None
  • Add PlatformTypeId property if entity uses Passports
  • Change TenantId type to Guid (and make non-nullable if applicable)
  • Add TenantId property if missing
  • Update foreign key property types (e.g., UserIdGuid)
  • Replace junction collection navigation with direct collection (e.g., UserRolesRoles)
  • Remove [InverseProperty] attributes on many-to-many navigations

Phase 4: Update DbContext

1. Remove deleted entity DbSets and configurations (done in Phase 2)

2. Add implicit many-to-many configurations:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // ... existing configurations ...

    // Many-to-many: User <-> Role
    modelBuilder.Entity<User>()
        .HasMany(u => u.Roles)
        .WithMany(r => r.Users)
        .UsingEntity(j => j.ToTable("user_roles", "identity"));

    // Many-to-many: Role <-> Permission
    modelBuilder.Entity<Role>()
        .HasMany(r => r.Permissions)
        .WithMany(p => p.Roles)
        .UsingEntity(j => j.ToTable("role_permissions", "identity"));
}

3. Add Passport configurations (if Prism.Abstractions referenced):

using Acsis.Dynaplex.Engines.Prism.Abstractions;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // ... other configurations ...

    modelBuilder.Entity<User>().HasPassport(PTIDS.USER);
    modelBuilder.Entity<Group>().HasPassport(PTIDS.GROUP);
}

Phase 5: Reference Data Entities

For lookup/reference data entities:

Option A: Keep numeric IDs (recommended for frequently-joined lookups)

[Table("user_statuses")]
public class UserStatus
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("id")]
    public int Id { get; set; }  // Keep as int

    [Column("name")]
    public string Name { get; set; } = null!;
}

Option B: Optimize long → int/short

// BEFORE: long Id
// AFTER: int Id (if < 2 billion records) or short Id (if < 32k records)
public short Id { get; set; }

Criteria for keeping numeric:

  • Small, rarely-changing lookup tables
  • Frequently used in joins
  • No cross-component references
  • Examples: statuses, types, categories

Phase 6: Build and Verify

1. Build the Abstractions project:

dotnet build engines/{component}/src/Acsis.Dynaplex.Engines.{Component}.Abstractions/

2. Check for compilation errors:

  • Fix any foreign key type mismatches
  • Update any DTO/Response classes that reference entity IDs
  • Update any queries that cast IDs

3. Review warnings:

  • Nullable reference warnings are acceptable (pre-existing)
  • Watch for actual type mismatch warnings

Phase 7: Migration Generation (Future Step)

Note: Don't generate migrations yet! This is a breaking schema change that requires careful data migration planning.

When ready to migrate data:

  1. Generate EF Core migration
  2. Review the migration for correctness
  3. Add custom migration code for data conversion if needed
  4. Test on non-production database first

Common Patterns

Pattern: Entity with Passport

[Table("users")]
public class User
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    [Column("id")]
    public Guid Id { get; set; }

    [Column("platform_type_id")]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public short PlatformTypeId { get; set; } = PTIDS.USER;

    [Column("tenant_id")]
    public Guid TenantId { get; set; }

    // No separate passport_id column!
}

Pattern: Entity without Passport

[Table("permissions")]
public class Permission
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    [Column("id")]
    public Guid Id { get; set; }

    [Column("tenant_id")]
    public Guid TenantId { get; set; }

    // No PlatformTypeId needed
}

Pattern: Implicit Many-to-Many

// Entity 1
public class User
{
    public Guid Id { get; set; }
    public virtual ICollection<Role> Roles { get; set; } = new List<Role>();
}

// Entity 2
public class Role
{
    public Guid Id { get; set; }
    public virtual ICollection<User> Users { get; set; } = new List<User>();
}

// DbContext
modelBuilder.Entity<User>()
    .HasMany(u => u.Roles)
    .WithMany(r => r.Users)
    .UsingEntity(j => j.ToTable("user_roles", "identity"));

Completed Examples

Identity Component (Reference Implementation)

The Identity component serves as the canonical example:

Files Changed:

  • User.cs - long → Guid, added PlatformTypeId
  • Group.cs - long → Guid, added PlatformTypeId
  • Role.cs - long → Guid
  • Permission.cs - long → Guid, added TenantId
  • RefreshToken.cs - UserId long → Guid, added TenantId
  • IdentityDb.cs - Updated with implicit many-to-many

Files Deleted:

  • UserRole.cs
  • RolePermission.cs
  • SecurityQuestion.cs
  • SecurityQuestionResponse.cs
  • NotificationPreference.cs

Build Result: ✅ Success (0 errors, 19 pre-existing warnings)

Troubleshooting

Issue: "Cannot convert Guid to long"

Cause: Missed updating a foreign key property type

Solution: Find all properties that reference the changed entity's ID and update their type:

// Find:
public long UserId { get; set; }

// Change to:
public Guid UserId { get; set; }

Issue: "The entity type requires a primary key"

Cause: Removed [Key] attribute accidentally

Solution: Ensure [Key] attribute remains on the Id property

Issue: "Navigation property not found"

Cause: Removed navigation property or changed its name

Solution: Update the [InverseProperty] attribute or remove it for many-to-many

Checklist: Component Refactoring

Use this checklist for each component:

Planning:

  • Read ADR 001
  • Inventory all entities
  • Identify domain vs reference data
  • Identify Passport entities
  • Map all relationships
  • Identify junction tables for deletion

Execution:

  • Delete junction entity files
  • Remove junction DbSets from DbContext
  • Remove junction configurations from OnModelCreating
  • Refactor each domain entity (ID, PlatformTypeId, TenantId)
  • Update all foreign key property types
  • Update navigation properties
  • Add implicit many-to-many configurations
  • Optimize reference data entities (long → int/short)

Validation:

  • Build Abstractions project successfully
  • Review all compilation warnings
  • Verify navigation properties are correct
  • Check that TenantId is Guid everywhere
  • Confirm Passport entities have PlatformTypeId

Documentation:

  • Update component's README if ID types mentioned
  • Note any deviations from pattern
  • Document any tricky migration steps

References