Skip to content

Entity

An Entity is one of the core concepts in Domain-Driven Design. In MiCake, an entity is a domain object with a unique identity, identified and compared by its Id.

Entities have the following characteristics:

  1. Unique identity: every entity has a unique Id property
  2. Mutability: an entity’s property values can change, but its identity does not
  3. Identity equality: two entities are equal if and only if their Id values are the same
  4. Lifecycle: entities have an explicit lifecycle, from creation to deletion

MiCake provides the Entity<TKey> base class for defining entities:

using MiCake.DDD.Domain;
// Use a custom Key type
public class Customer : Entity<Guid>
{
public string Name { get; private set; }
public string Email { get; private set; }
public DateTime RegisterDate { get; private set; }
// Constructor
private Customer() { } // Required by EF Core
// MiCake recommends using a static Create method to create domain objects
public static Customer Create(Guid id, string name, string email)
{
return new Customer
{
Id = id,
Name = name,
Email = email,
RegisterDate = DateTime.UtcNow
};
}
}

For entities using int as the primary key, you can use the simplified version:

// Uses int as the Key type by default
public class Product : Entity
{
public string Name { get; private set; }
public decimal Price { get; private set; }
public int Stock { get; private set; }
private Product() { }
public Product(string name, decimal price, int stock)
{
Name = name;
Price = price;
Stock = stock;
}
}

An entity’s Id property is its unique identity:

public abstract class Entity<TKey> : IEntity<TKey> where TKey : notnull
{
public virtual TKey Id { get; init; } = default!;
}

Characteristics:

  • Uses the init accessor to ensure the Id is immutable after initialization
  • Can be set in the constructor or in an object initializer
  • Supports any non-null type as the Key
// int type (most common)
public class Order : Entity<int> { }
// Guid type (recommended for distributed systems)
public class Customer : Entity<Guid> { }
// long type (for large data volumes)
public class LogEntry : Entity<long> { }
// string type (business numbers)
public class Invoice : Entity<string> { }
// Option 1: generated by a factory method (recommended)
public class Product : Entity<Guid>
{
private Product() { }
public static Product Create(string name, decimal price)
{
return new Product
{
Id = Guid.NewGuid(),
Name = name,
Price = price
};
}
}
// Option 2: generated in the constructor
public class Customer : Entity<Guid>
{
public Customer(string name)
{
Id = Guid.NewGuid(); // Generated in the constructor
Name = name;
}
}
// Option 3: database auto-increment
public class Order : Entity<int>
{
// Id is generated by the database auto-increment, no need to set it manually
public Order(Customer customer)
{
Customer = customer;
}
}

The MiCake entity base class implements identity-based equality comparison:

var customer1 = new Customer(Guid.NewGuid(), "Zhang San", "zhang@example.com");
var customer2 = new Customer(customer1.Id, "Li Si", "li@example.com");
// As long as the Id is the same, they are considered the same entity
bool areEqual = customer1 == customer2; // true
// Even if property values differ, the entities are still equal when the Id is the same
Console.WriteLine(customer1.Name); // Zhang San
Console.WriteLine(customer2.Name); // Li Si
Console.WriteLine(customer1 == customer2); // true

Entities can raise domain events to capture important events that occur in the business.

public class Order : Entity<int>
{
public OrderStatus Status { get; private set; }
public List<OrderItem> Items { get; private set; } = new();
public void AddItem(Product product, int quantity)
{
var item = new OrderItem(product, quantity);
Items.Add(item);
// Raise a domain event
RaiseDomainEvent(new OrderItemAddedEvent(Id, product.Id, quantity));
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be submitted");
Status = OrderStatus.Submitted;
// Raise the submit event
RaiseDomainEvent(new OrderSubmittedEvent(Id, Items.Sum(i => i.TotalPrice)));
}
}
// Get all pending events of the entity
IReadOnlyCollection<IDomainEvent> events = order.DomainEvents;
// Clear all events (usually called automatically by the framework)
order.ClearDomainEvents();

Events are dispatched automatically when the unit of work commits (IUnitOfWork.CommitAsync()); see the Domain Events chapter for details.

Entities should contain related business logic rather than being mere data containers:

public class BankAccount : Entity<Guid>
{
public decimal Balance { get; private set; }
public AccountStatus Status { get; private set; }
private BankAccount() { }
public static BankAccount Open(Guid id, decimal initialDeposit)
{
if (initialDeposit < 0)
throw new DomainException("Initial deposit cannot be negative");
var account = new BankAccount
{
Id = id,
Balance = initialDeposit,
Status = AccountStatus.Active
};
account.RaiseDomainEvent(new AccountOpenedEvent(id, initialDeposit));
return account;
}
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new DomainException("Deposit amount must be positive");
if (Status != AccountStatus.Active)
throw new DomainException("Account is not active");
Balance += amount;
RaiseDomainEvent(new MoneyDepositedEvent(Id, amount, Balance));
}
public void Withdraw(decimal amount)
{
if (amount <= 0)
throw new DomainException("Withdrawal amount must be positive");
if (Status != AccountStatus.Active)
throw new DomainException("Account is not active");
if (Balance < amount)
throw new DomainException("Insufficient balance");
Balance -= amount;
RaiseDomainEvent(new MoneyWithdrawnEvent(Id, amount, Balance));
}
public void Close()
{
if (Balance != 0)
throw new DomainException("Cannot close account with non-zero balance");
Status = AccountStatus.Closed;
RaiseDomainEvent(new AccountClosedEvent(Id));
}
}
  1. Use private setters: prevent properties from being modified directly from outside
  2. Validate business rules: validate business constraints inside methods
  3. Throw domain exceptions: use DomainException to report business errors
  4. Raise domain events (optional): record important business events
  5. Keep state consistent: ensure the entity is in a valid state after a method executes

Regular entities have no independent lifecycle and must belong to some aggregate:

// OrderItem is a regular entity, not an aggregate root
public class OrderItem : Entity<int>
{
public int OrderId { get; private set; }
public int ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal Price { get; private set; }
// Can only be accessed and modified through the Order aggregate root
}

An aggregate root is a special entity that can exist independently and serves as the entry point of an aggregate:

// Order is an aggregate root
public class Order : AggregateRoot<int>
{
private List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public void AddItem(Product product, int quantity, decimal price)
{
var item = new OrderItem
{
ProductId = product.Id,
Quantity = quantity,
Price = price
};
_items.Add(item);
}
// All operations on OrderItem go through Order
}

Key differences:

  • Regular entity: Entity<TKey>
  • Aggregate root: AggregateRoot<TKey> (inherits from Entity<TKey>)
  • Only aggregate roots can have repositories
  • Regular entities can only be accessed through the aggregate root

See the Aggregate Root chapter for details.

public class Customer : Entity<Guid>
{
private Customer() { }
// Factory methods ensure business rules are satisfied when creating an entity
public static Customer Create(string name, string email)
{
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Customer name is required");
if (!email.Contains("@"))
throw new DomainException("Invalid email format");
return new Customer
{
Id = Guid.NewGuid(),
Name = name,
Email = email,
RegisterDate = DateTime.UtcNow
};
}
}
public class Order : Entity<int>
{
private List<OrderItem> _items = new();
private decimal _totalAmount;
public void AddItem(OrderItem item)
{
_items.Add(item);
// Update the total amount immediately to keep consistency
_totalAmount += item.TotalPrice;
}
public void RemoveItem(OrderItem item)
{
if (_items.Remove(item))
{
_totalAmount -= item.TotalPrice;
}
}
}
public class Product : Entity<int>
{
// Use private set to prevent external modification
public string Name { get; private set; }
public decimal Price { get; private set; }
// Modify through methods so validation logic can be added
public void UpdatePrice(decimal newPrice)
{
if (newPrice < 0)
throw new DomainException("Price cannot be negative");
Price = newPrice;
}
}
public class ShoppingCart : Entity<Guid>
{
private List<CartItem> _items = new();
private const int MaxItemsCount = 100;
public void AddItem(Product product, int quantity)
{
// Business rule validation
if (quantity <= 0)
throw new DomainException("Quantity must be positive");
if (_items.Count >= MaxItemsCount)
throw new DomainException("Cart is full");
if (product.Stock < quantity)
throw new DomainException("Insufficient stock");
// Perform the operation
_items.Add(new CartItem(product, quantity));
}
}
public class User : Entity<int>
{
public string Email { get; private set; }
public bool IsEmailVerified { get; private set; }
public void VerifyEmail(string verificationCode)
{
// Verification logic
if (IsEmailVerified)
throw new DomainException("Email already verified");
// Verify the code...
IsEmailVerified = true;
// Raise an event so other parts can respond (e.g., send a welcome email)
RaiseDomainEvent(new EmailVerifiedEvent(Id, Email));
}
}
// Wrong: the entity only has data, no behavior
public class Order : Entity<int>
{
public int CustomerId { get; set; }
public OrderStatus Status { get; set; }
public decimal TotalAmount { get; set; }
}
// Business logic lives in external services
public class OrderService
{
public void SubmitOrder(Order order)
{
order.Status = OrderStatus.Submitted;
// ...
}
}
// Correct: the entity contains business logic
public class Order : Entity<int>
{
public int CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be submitted");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmittedEvent(Id));
}
}

Entities are a fundamental concept in DDD. In MiCake:

  • Inherit from the Entity<TKey> or Entity base class
  • Identified by a unique Id
  • Can raise domain events
  • Should contain related business logic
  • Use private setters to protect data
  • Modify state through methods and validate business rules

Next steps: