3

I am trying to modify the existing Entity Framework classes to be read/write from the service instead of the database based on the config key.

For few weeks we would like to save the data in both the places which is the service and the existing database, later verify data consistency then completely switch to the service.

This is the approach I have it in mind:

public class UserDbContext : IdentityDbContext<AspNetUsers>
{
    public DbSet<Address> Addresses { get; set; }
    public DbSet<Person> Persons { get; set; }
}

public class AspNetUsers : IdentityUser
{
    public virtual ICollection<Person> Persons { get; set; }
    public virtual ICollection<Address> Addresses { get; set; }
    
    public AspNetUsers()
    {
        Addresses = new List<Address>();
        Persons = new List<Person>();
    }
}

How we access today from database

class Program
{
    static void Main(string[] args)
    {
       var context = new UserDbContext();
       var address = context.Addresses.FirstOrDefault(x => x.UserId == "user12345"); // Load
       
       var newAddress = new Address("street", "city");
       context.Addresses.Add(newAddress);
       context.SaveChanges();
    }
}

But we have moved a Address part to the separate service and want to use it in the existing code, instead of reading from the database

class Program
{
    static void Main(string[] args)
    {
       var context = new UserDbContext();
       var address = new AddressProvider().GetAddress("user12345"); // Load
       
       var newAddress = new Address("street", "city");
       new AddressProvider().SaveAddress(newAddress);
       
       // How to delegate the Address from database to service here?
       var user = context.Users.FirstOrDefault(x => x.UserId == "user12345"); 
       user.Addresses.FirstOrDefault();
       user.Addresses.Add(newAddress);
    }
}

public class AddressProvider
{
    public Address GetAddress(string userId, UserDbContext context)
    {
        bool loadFromService = true; // will be from config

        if (loadFromService) 
        {
            return new Address(); // this will be fetch from api via HttpClient
        }

        return context.Addresses.FirstOrDefault(x => x.UserId == userId);
    }
  
    public void SaveAddress(Address address, UserDbContext context)
    {
        bool loadFromService = true; // will be from config

        if (loadFromService)
        {
            SaveAddress(address); // this will be sent to API
            return;
        }

        context.Addresses.Add(newAddress);
        context.SaveChanges();
    }
}

Now the problem is when this Address is loaded via Navigation properties we could not be able to use service directly.

Is there a way to delegate this using any Entity Framework features? So whenever the Address object is called it will be delegated to other class to load an entity instead of the database?

Developer
  • 487
  • 9
  • 28
  • So you want to allow the address provider to be disabled or have you implemented that switch because you think it would be easier? It would be far simpler to remove Addresses from the `UserDbContext` model altogether and turn that into a methods on the `User`/`Person` to get or update the address through the provider. – Chris Schaller Jun 01 '21 at 14:49
  • @ChrisSchaller, Yes but its based on configuration. We would like to use existing database for couple of weeks and write in both the places and verify data consistency before we switch entirely to use the service – Developer Jun 01 '21 at 14:54
  • Just to confirm, `AspNetUsers` has a collection of address in the _DbContext_ (_Database_) but in the service there will only ever be a single `Address` for each `AspNetUsers` (_User_) instance? – Chris Schaller Jun 01 '21 at 15:13

1 Answers1

1

When performing data queries against a DbContext, Entities are either mapped to the current database or they are NOT. There is no grey area on this one. What you are asking is similar to Entity Framework - “Navigation Property” In A Different Data Context?. In this case it is not another DbContext, but the problems are the same.

When you query against the DbContext the following high level steps are involved in servicing that query: (unless the query is against a cached result set)

  1. EF translates the expression into a SQL command by consulting the mappings expressed in your DbModelBuilder configuration
  2. The SQL is passed to the database and evaluated
  3. The response is deserialized from the database into the EF Model Objects

So ultimately, we need to tell EF to ignore any query paths (and .Includes) that need to resolve the Addresses, because it won't be able to find them. The ObsoleteAttribute is a great tool for this, we can track down any references in the code via warnings, whilst still allowing the old code to compile while you are in a transition state. It is also a hint for any new code, to NOT use this legacy reference.

To try and silently inject this conditional functionality into the DbContext might be possible, but it's not going to be practical. The best solution for a hybrid approach would be to "Bolt On" the new AddressProvider implementation side by side.

Then after a time, when you have removed all previous references and queries to the old DbSet of Addresses and you want to remove the old Address table you can simply remove it from the UserDbContext, make sure you also Ignore it in the mapping.

Simply add the new provider implementation into the DbContext, this way you don't have to copy around references to every call.

public class UserDbContext : IdentityDbContext<AspNetUsers>
{
    #region Disposable AddressProvider 
    AddressProvider AddressProvider { get; private set; } = new AddressProvider(this);
    /// <summary>
    /// Dispose managed members such as AddressProvider, otherwise they will keep this context active
    /// </summary>
    protected override void Dispose(bool disposing)
    {
        if(this.AddressProvider != null)
        {
            try
            {
                this.AddressProvider.Dispose();
                this.AddressProvider = null;
            }
            catch (Exception) { /*Ignore errors during dispose*/ }
        }
        base.Dispose(disposing);
    }
    #endregion Disposable AddressProvider 

    public DbSet<Address> Addresses { get; set; }
    public DbSet<Person> Persons { get; set; }
}

public class AddressProvider : IDisposable
{
    UserDbContext _userContext;
    public readonly bool LoadFromService;
    public readonly bool UpdateDatabase;
    public AddressProvider(UserDbContext dbContext, bool loadFromService = true, bool updateDatabase = true)
    {
        _userContext = dbContext;
        LoadFromService = loadFromService;
        UpdateDatabase = updateDatabase;
    }

    #region IDiposable - Release the dbContext reference

    public void Dispose()
    {
        _userContext = null;
    }

    #endregion IDiposable - Release the dbContext reference

    public Address GetAddress(string userId)
    {
        if (LoadFromService) 
        {
            return GetAddressFromAPI(userId);
        }

        return _userContext.Addresses.FirstOrDefault(x => x.UserId == userId);
    }
  
    public void SetAddress(Address address)
    {
        // To support Hybrid operations, we can update both the service and the database
        if (LoadFromService)
        {
            SaveAddressInAPI(address);
        }
        if (UpdateDatabase)
        {
            var userId = address.UserId;
            // It's probably simpler to delete any existing address for this user
            foreach(var a in context.Addresses.Where(x => x.UserId == userId).ToList())
                context.Entry(fund).State = System.Data.Entity.EntityState.Deleted;
            _userContext.Addresses.Add(address);
            _userContext.SaveChanges();
        }
    }

    public void GetAddressFromAPI(string userId)
    {
        // this will be fetch from api via HttpClient
        return new Address { UserId = userId }; 
        //throw new NotImplementedException();
    }
    public void SaveAddressInAPI(Address address)
    {
        // this will be sent to API
        //throw new NotImplementedException();
    }
}

public class AspNetUsers : IdentityUser
{
    public virtual ICollection<Person> Persons { get; set; }
    [Obsolete("Stop using the Addresses collection directly, see 'GetAddress(UserDbContext)'")]
    public virtual ICollection<Address> Addresses { get; set; }
    
    public AspNetUsers()
    {
        Addresses = new List<Address>();
        Persons = new List<Person>();
    }
}

public static class AddressExtensions
{
    public static Address GetAddress(this AspNetUsers user, UserDbContext context)
    {
        return context.AddressProvider.GetAddress(user.UserId);
    }
    public void SetAddress(this AspNetUsers user, Address address, UserDbContext context)
    {
        // ensure the UserId is set to this user
        address.UserId = user.UserId;
        context.AddressProvider.SetAddress(address);
    }
}

This isn't much different from your original implementation, but atleast the provider implementation is neatly packaged within the DbContext

static void Main(string[] args)
{
   var context = new UserDbContext();
   var user = context.Users.FirstOrDefault(x => x.UserId == "user12345"); 
   // load the address
   var address = user.GetAddress(context);
   
   var newAddress = new Address("street", "city");
   user.SetAddress(newAddress, context);       
}

I don't reccomend this, but you could also add a LoadAddresses method to manage the conditional load logic. I don't like this because it allows too much of your code to operate as if it is natively part of the EF context when in fact it is not, but the answer wouldn't be complete without it...

public static class MoreAddressExtensions
{
    public static Address LoadAddress(this AspNetUsers user, UserDbContext context)
    {
        if (context.AddressProvider.LoadFromService)
        {
            if (this.Addresses == null)
                this.Addresses = new HashSet<Address>();
            else
                this.Addresses.Clear();
            var externalAddress = context.AddressProvider.GetAddress(user.UserId));
            if (externalAddress != null)
                this.Addresses.Add(externalAddress);
        }
        else
            this.Addesses = context.Addresses.Where(x => x.UserId == userId).ToList();
    }
}

Then this could be used...

static void Main(string[] args)
{
   var context = new UserDbContext();
   var user = context.Users.FirstOrDefault(x => x.UserId == "user12345"); 
   // load the address
   user.LoadAddress(context);
   var address = user.Addresses.FirstOrDefault();
   
   var newAddress = new Address("street", "city");
   user.SetAddress(newAddress, context);       
}

Later, when you want to remove the table from the context amke sure you remove the public DbSet<Address> Addresses { get; set; } from the context, AND ignore the type in the model configuration:

modelBuilder.Ignore<Address>();

If instead of a transition, you were interested in a permanent hybrid approach, or you had lots of existing code pathways that might be updating the old address records, then you could also intercept the SaveChanges method on the DbContext, if you do this, then you need to be aware of and avoid race conditions with the existing code, maybe you remove the previous SetAddress logic, this is an example of how you might go about it:

/// <summary> detect changes to Address Entities and redirect them through the provider </summary>
public override int SaveChanges()
{
    foreach (var entry in this.ChangeTracker.Entries())
    {
        if (entry.Entity is Address a)
        {
            switch(entry.State)
            {
                case EntityState.Modified:
                case EntityState.Added:
                { 
                    if (AddressProvider.LoadFromService)
                    {
                        if (String.IsNullOrEmpty(a.UserId))
                            a.UserId = a.User.Id;
                        AddressProvider.SaveAddressInAPI(a);
                    }
                    if (!AddressProvider.UpdateDatabase)
                        entry.State = EntityState.Unchanged;
                    break;
                }
                case EntityState.Deleted:
                {
                    // no mention of how to handle delete, so we'll add a blank one
                    if (AddressProvider.LoadFromService)
                    {
                        string userId = a.UserId;
                        if (String.IsNullOrEmpty(a.UserId))
                            userId = a.User.Id;
                        AddressProvider.SaveAddressInAPI(new Address { UserId = userId });
                    }
                    if (!AddressProvider.UpdateDatabase)
                        entry.State = EntityState.Unchanged;
                    break;
                }
                case EntityState.Unchanged:
                default:
                    continue;
            }
        }
    }

    return base.SaveChanges();
}
Chris Schaller
  • 13,704
  • 3
  • 43
  • 81