Business logic

Business logic should be encapsulated in the domain layer of the project. This should represent the core structure of the business objects and the rules that govern them. This is distinct from application logic which is the business domain in the specific context of the application.

For example, in a system which manages appointments, it is a business rule that each booking can only have one appointment associated with it. It is an application rule that only one person can have an appointment at the same time.

General guidance

  1. The domain layer should be completely unaware of the application layer and its rules, and also unaware of any persistence layer (see the Entity framework section below for more advice).
  2. It shouldn’t be possible to instantiate the object without the required properties being set. This can be achieved in three ways:
    1. Make the public constructor set the required properties.
    2. Use the required modifier (available in .Net8 and beyond) to indicate to the compiler/intellisense which properties must be set.
    3. Use the Factory pattern - private constructor, with public static create method.
  3. Prefer private (protected for abstract classes) setters for properties, use methods for updating the properties so that business rules can be enforced.
  4. Use Enum’s as enums - don’t store a database-based object representation of the enum.
  5. Use the Single Responsibility Principle: methods should have only one reason to change.

Aggregate roots

When determining the access for an object, public methods should only be available when it is OK for that object to be manipulated directly. In most complex systems they should only be modified through the aggregate root.

For example, in an orders system, the order items should only be creatable via the order. This allows the business rule - that items can only be added to the order when the OrderStatus is New - to be enforced.

public class Order : Entity<OrderId>
{
    private Order(OrderId id) : base(id)
    {
    }

    private readonly ICollection<OrderItem> _orderItems = [];
    public IReadOnlyCollection<OrderItem> OrderItems => [.. _orderItems];
    public OrderStatus OrderStatus { get; private set; } = OrderStatus.New;
    public static Order Create()
    {
        return new Order(OrderId.NewId);
    }
    public Result<OrderItemId> AddOrderItem(string sku, int quantity, Money price)
    {
        if (OrderStatus != OrderStatus.New)
        {
            return Result.Fail("Items cannot be added to this order");
        }
        var orderItem = OrderItem.Create(sku, quantity, price);
        _orderItems.Add(orderItem);
        return Result.Ok(orderItem.Id);
    }
}

public sealed class OrderItem : Entity<OrderItemId>
{
    private OrderItem(OrderItemId id) : base(id)
    {
    }
    public string Sku { get; private set; } = null!;
    public int Quantity { get; private set; }
    public Money Price { get; private set; } = null!;
    internal static OrderItem Create(string sku, int quantity, Money price)
    {
        return new OrderItem(OrderItemId.NewId)
        {
            Sku = sku,
            Quantity = quantity,
            Price = price
        };
    }
}

Entity framework

Avoid including items in the domain layer which exist only for compatibility with Entity.

Examples include:

  • Attributes which express how the property should be implemented in the database
  • Additional domain objects which are only intended for the database:
    • Enum backing tables - writing the values of enums into a database table, but then making that object a key part of the domain
    • Join tables - creating additional domain objects to support many-to-many relationships
    • Properties - named for the database not the domain or not required for the domain.

Poor usage

[Key]
[Column("AppointmentId")]
public int Id { get; set; }

[Column(TypeName = EntityConstants.TIME7_SQL_TYPENAME)]
public TimeSpan StartTime { get; set; }

In the first property, a tight coupling with the database implementation is created (by both the key attribute and the name of the column), plus it introduces a value which shouldn’t be part of the domain because an integer isn’t suitable for resource identification. In the second example, there is a tight coupling created between the entity implementation and the property.

Better usage

[MaxLength(250)]
public string? Description { get; set; }

This type of decoration is OK. It enforces a domain rule and will be carried through to Entity to set a size on the column, it also provides useful information to developers on the expected characteristics of the field. Note: you need to manually enforce the validation if using these decorators on an entity object.