Site icon Fanzoo Technology, Inc.

Implementing Static Entities using Domain-driven Design and Entity Framework Core

One of the core tenets of Domain-driven Design (DDD) is separation of concerns. Separation is achieved in the domain layer by defining highly encapsulated entities, value objects, aggregates, and domain services. Another area of concern is separating the domain layer from the data-persistence layer. While modern ORM’s can make our lives easier in persisting data, they can also make separating that persistence from our domain models more difficult. This is because the role of an ORM is persisting a data model not a domain model. If you are using an anemic domain model where your data model is your domain model this is far less of a liability. However, using DDD where the domain model is encapsulated means mapping entities to data objects can pose some challenges. In this article I will discuss one such common challenge and how to solve it using EF Core.

A common pattern in many domain models are what would be called “statuses and types.”  For example, an Order entity would have an OrderStatus (i.e. Pending, Shipped, Canceled) and an OrderType (i.e. Customer, Commercial, Internal).  A feature of statuses and types is they are usually established at the beginning of the design as core indicators of business logic; implemented as immutables and not managed by the client through any kind of interface. In other words, they are hard-coded. However, we do still want to store these statuses and types in our database to make our queries more efficient. This compels us to implement these as entities as opposed to enums.

Consider a business rule that states when all OrderItem children of an Order are canceled, the Order becomes canceled. Here is how we may implement this in a non-DDD way:

public class OrderService
{
    private readonly Repository _repository;

    public OrderService(Repository repository)
    {
        _repository = repository;
    }

    public void CancelOrderItem(Guid orderItemId)
    {
        //cancel the item
        var orderItem = _repository.FindOrderItemById(orderItemId);

        var canceledOrderItemStatus = _repository.FindOrderItemStatusByName("Canceled");

        orderItem.Status = canceledOrderItemStatus;

        //check if all the items are canceled
        if(orderItem.Order.Items.TrueForAll(i => i.Status.Name == "Canceled"))
        {
            //if so, cancel the order
            var canceledOrderStatus = _repository.FindOrderStatusByName("Canceled");

            orderItem.Order.Status = canceledOrderStatus;
        }

        _repository.Save();
    }
}

While the code is functionally correct, it is not encapsulated in any meaningful way. We are free to set the OrderItem and Order statuses to whatever we please and potentially create an invalid model state. Later, if canceling an OrderItem is used in a different context, the person implementing the code may not even know this rule exists. This is where the role of an aggregate root in DDD comes into place. In the following example the Order entity is the aggregate root. OrderItems are children, fully encapsulated, within the Order. Here is how the previous code block would look after refactoring it using Order as the aggregate root:

public class Order
{
    public Guid Id { get; protected set; }

    protected OrderStatus Status { get; set; }

    protected List<OrderItem> Items { get; set; }

    public void Cancel()
    {
        Cancel(true);
    }

    private void Cancel(bool cancelItems)
    {
        Status = OrderStatus.Canceled;

        if (cancelItems)
        {
            foreach (var item in Items)
            {
                item.Status = OrderItemStatus.Canceled;
            }
        }
    }

    public void CancelOrderItem(OrderItem item)
    {
        item.Status = OrderItemStatus.Canceled;

        if (Items.TrueForAll(i => i.Status == OrderItemStatus.Canceled))
        {
            Cancel(false);
        }

    }
}

public class OrderService
{
    private readonly Repository _repository;

    public OrderService(Repository repository)
    {
        _repository = repository;
    }

    public void CancelOrderItem(OrderItem orderItem)
    {
        var order = _repository.FindOrderByOrderItemId(orderItem.Id);

        order.CancelOrderItem(orderItem);

        _repository.Save();
    }
}

As you can see the OrderItem itself is never directly manipulated outside of the Order. This ensures our business rule is always enforced. However, the more interesting part of this refactoring is the use of static OrderStatus and OrderItemStatus entities. In the non-DDD example these statuses were loaded from the repository and the corresponding status properties are being set directly. Once the code was refactored to encapsulate this functionality in Order, there was no longer access to the repository. This is due to the repository being the concern of the data-persistence layer, not the domain layer. Here a static property, Canceled of type OrderStatus, is used instead of loading the entity from the repository. Recall in the introduction it was established that these statuses and type entities are hard-coded. If we look at OrderStatus we can see how this was implemented:

public class OrderStatus : IStaticEntity
{
    public static readonly OrderStatus Open = new OrderStatus(
        Guid.Parse("9BFED191-1E69-494C-9EF6-BEE0E4510790"),
        "Open");

    public static readonly OrderStatus Canceled = new OrderStatus(
        Guid.Parse("714ADD40-F183-4979-A4CD-F2859B6DBA08"),
        "Canceled");

    protected OrderStatus(Guid id, string name)
    {
        Id = id;
        Name = name;
    }
            
    public Guid Id { get; protected set; }

    public string Name { get; protected set; }
}

Note that the constructor is protected. We will never create new OrderStatus entities directly. They will be added through migrations or scripts alone. This works great except for one problem—because the OrderStatus is not loaded through the DbContext the ChangeTracker always assumes you are adding a new OrderStatus. This will quickly blow up as a primary key violation when it hits the database. So how to solve this?

What we need to do is find a way to tell the ChangeTracker to ignore these entities that we do not want tracked. This can be accomplished by overriding DbContext.SaveChanges() or DbContext.SaveChangesAsync() depending on the use case. You can add your code directly to the method or more preferably use an Interceptor pattern. Note, as of EF Core 3.1 there is built-in Interceptor functionality. The below example uses a custom implementation, but the concept is the same. In our Interceptor we want to find any “static” entities and remove them from the ChangeTracker. We could look for specific entities or even better use a generic way of identifying them. This can be accomplished by implementing an empty interface called IStaticEntity on each of our entities we want to be static. After that, the code is straight-forward:

public class StaticEntityInterceptor : IDbActionInterceptor
{
    public Task OnSaveChangesAsync(AppDbContext dbContext)
    {
        var entries = dbContext
            .ChangeTracker.Entries<IStaticEntity>()
                .Where(e => e.State == EntityState.Modified || e.State == EntityState.Added);

        foreach (var entry in entries)
        {
            entry.State = EntityState.Unchanged;
        }

        return Task.CompletedTask;
    }
}

We simply search for any entities that implement IStaticEntity that the ChangeTracker is trying to add or modify and set their state to unmodified.

What’s Next?

Interested in learning more about Fanzoo and how we can help your business in 2021? Check out our Problems We Solve or Our Services page to learn more about our tried and process for everything from custom app development to business solutions. Or head on over to Our Clients page and get a full view of our current client roster. We’re proud of the work we do, and we’re excited to share it with you! Ready to get started? Fill out the form below.

Notice: JavaScript is required for this content.
Exit mobile version