alhpe
alhpe

Reputation: 1514

Avoid Exposing Private Collection Properties to Entity Framework. DDD principles

I try do adhere DDD principles on C# collections see more here

And I notice that the model builder method for initial seed HasData relies on the Add Method of ICollection. There is any way to circumvent or trick that method when is called from database update / migrate process?

All I had done until now to trick it follows this path.

1) Create a wrapper around the ICollection named ReadOnlyKollection

2) Have a private ICollection on the model, to avoid exposing to the outside world the collection.

3) Expose the wraper making obsolete Add and some other methods that will trow NotImplementedException if used.

However still the Add method despite of the obsolete warning could be used since is still public and needed for the seed HasData method used on update / migrate database process.

I am thinking on at least restrict the calling methods from inside the Add method of the wrapper class.

I could be good to know the calling member when HasData would run and allow only this Method to process and throw an exception for any other.

Notice that CallerMethodName compile-type feature can't be used since will break the ICollectoion interface contract..

Any ideas to avoid Exposing Private Collection Properties to Entity Framework following DDD principles? (and still have the enhancement of HasData method to update / migrate database process). see some code below..

public interface IReadOnlyKollection<T> : ICollection<T>
{
}

public class ReadOnlyKollection<T> : IReadOnlyKollection<T>
{
    private readonly ICollection<T> _collection;

    public ReadOnlyKollection(ICollection<T> collection)
    {
        _collection = collection;
    }

    public int Count => _collection.Count;
    public bool IsReadOnly => _collection.IsReadOnly;

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    public IEnumerator<T> GetEnumerator() => _collection.GetEnumerator();

    public bool Contains(T item) => _collection.Contains(item);
    public void CopyTo(T[] array, int arrayIndex) => _collection.CopyTo(array, arrayIndex);

    [Obsolete]
    public void Add(T item) => _collection.Add(item); // CallerMethodName trick to be applied here or ??

    [Obsolete] 
    public void Clear() => throw new NotImplementedException();

    [Obsolete] 
    public bool Remove(T item) => throw new NotImplementedException();
}

public class StateProvince
{
    public StateProvince() //EF Constructor
    {
    }

    public StateProvince(string id, string name)
    : this(name)
    {
        Id = id;
    }

    public string Id { get; protected set; }
    public string Name { get; protected set; }

    public string CountryRegionId { get; protected set; }
    public virtual CountryRegion CountryRegion { get; protected set; }
}

public class CountryRegion
{
    public CountryRegion() //EF Constructor
    {
    }

    public CountryRegion(string id, string name)
    : this(name)
    {
        Id = id;
    }

    public string Id { get; protected set; }
    public string Name { get; protected set; }

    private readonly ICollection<StateProvince> _stateProvinces = new List<StateProvince>(); // Private collection for DDD usage
    public IReadOnlyKollection<StateProvince> StateProvinces => new ReadOnlyKollection<StateProvince>(_stateProvinces); // Public like read only collection public immutable exposure
}


EntityTypeBuilder<StateProvince> // Code reduced for brevity

builder.HasIndex(e => e.CountryRegionId);
builder.Property(e => e.Id).IsUnicode(false).HasMaxLength(3).ValueGeneratedNever();
builder.Property(e => e.CountryRegionId).IsRequired().IsUnicode(false).HasMaxLength(3);
builder.Property(e => e.Name).IsRequired().HasMaxLength(50);


EntityTypeBuilder<CountryRegion> builder // Code reduced for brevity

builder.Property(e => e.Id).IsUnicode(false).HasMaxLength(3).ValueGeneratedNever();
builder.Property(e => e.Name).IsRequired().HasMaxLength(50);

builder.HasMany(e => e.StateProvinces)
    .WithOne(e => e.CountryRegion)
    .HasForeignKey(e => e.CountryRegionId)
    .IsRequired()
    .OnDelete(DeleteBehavior.Restrict);

builder.HasData(GetData())  

private static object[] GetData()
{   
    return new object[]
    {
        new { Id = "AF", Name = "Afghanistan", IsDeleted = false, LastModified = DateTimeOffset.UtcNow  },
        new { Id = "AL", Name = "Albania", IsDeleted = false, LastModified = DateTimeOffset.UtcNow  },
        new { Id = "DZ", Name = "Algeria", IsDeleted = false, LastModified = DateTimeOffset.UtcNow  },
        new { Id = "AS", Name = "American Samoa", IsDeleted = false, LastModified = DateTimeOffset.UtcNow  },

Upvotes: 1

Views: 3263

Answers (1)

Ivan Stoev
Ivan Stoev

Reputation: 205589

The linked post is for EF6, while HasData method indicates EF Core. And in EF Core the things are much simpler and do not need any tricks in that regard.

  • EF Core does not require ICollection<T> for collection navigation property. Any public property returning IEnumerable<T> or derived interface / class is discovered by convention as collection navigation property. Hence you can safely expose your collections as IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T> etc.

  • EF Core does not require property setter because it can be configured to use the backing field directly.

Additionally, there is no need of special "EF Constructor" because EF Core supports constructors with parameters.

With that being said, you don't need a custom collection interface / class. The sample model could be like this:

public class CountryRegion
{
    public CountryRegion(string name) => Name = name;    
    public CountryRegion(string id, string name) : this(name) => Id = id;

    public string Id { get; protected set; }
    public string Name { get; protected set; }

    private readonly List<StateProvince> _stateProvinces = new List<StateProvince>(); // Private collection for DDD usage
    public IReadOnlyCollection<StateProvince> StateProvinces => _stateProvinces.AsReadOnly(); // Public like read only collection public immutable exposure
}

public class StateProvince
{
    public StateProvince(string name) => Name = name;
    public StateProvince(string id, string name) : this(name) => Id = id;

    public string Id { get; protected set; }
    public string Name { get; protected set; }

    public string CountryRegionId { get; protected set; }
    public virtual CountryRegion CountryRegion { get; protected set; }
}

and add either the following (simplest - for all properties of all entities)

modelBuilder.UsePropertyAccessMode(PropertyAccessMode.Field);    

or for all properties of CountryRegion

builder.UsePropertyAccessMode(PropertyAccessMode.Field);

or just for that navigation property

builder.HasMany(e => e.StateProvinces)
    .WithOne(e => e.CountryRegion)
    .HasForeignKey(e => e.CountryRegionId)
    .IsRequired()
    .OnDelete(DeleteBehavior.Restrict)
    .Metadata.PrincipalToDependent.SetPropertyAccessMode(PropertyAccessMode.Field);

And that's all. You'll be able to use all EF Core functionality like Include / ThenInclude, "navigating" inside LINQ to Entities queries etc. (including HasData). The backing filed allows EF Core to add/remove elements when needed, or even replace the whole collection (in case the field is not readonly).

Upvotes: 12

Related Questions