-
Notifications
You must be signed in to change notification settings - Fork 0
i. ✨Aggregates
Domain-Driven Design (DDD) introduces several key tactical and strategic core concepts to help model complex business domains effectively.
Entities are objects that have a distinct identity that persists over time, even as their attributes change. They are defined by their identity rather than their attributes. For example, a Customer
entity might have an ID of 12345 - even if the customer changes their name, address, or email, they remain the same customer because their identity remains constant.
Key characteristics:
- Have a unique identifier (ID)
- Mutable - their attributes can change
- Identity equality - two entities are equal if they have the same ID
- Have a lifecycle - they can be created, modified, and potentially deleted
Value objects represent concepts that are defined entirely by their attributes rather than having a distinct identity. They are immutable and interchangeable with other value objects that have the same values. A classic example is Money
- two $20 bills are equivalent regardless of their serial numbers, or an Address
where "123 Main St, New York, NY 10001" is the same wherever it appears.
Key characteristics:
- No identity - defined by their attributes
- Immutable - cannot change once created
- Structural equality - two value objects are equal if all their attributes are equal
- Side-effect free behavior - operations don't modify the object but return new instances
An aggregate is a cluster of related entities and value objects that are treated as a single unit for data consistency. The aggregate root is the only entity within the aggregate that external objects can hold references to. It acts as the gatekeeper, ensuring that all business rules and invariants within the aggregate are maintained.
For example, an Order
aggregate might contain OrderItem
entities and ShippingAddress
value objects. External code can only access the order items through the Order
aggregate root, not directly.
Key characteristics:
- Single entry point for accessing the aggregate
- Enforces business invariants across the entire aggregate
- Controls the lifecycle of entities within the aggregate
- Only aggregate roots can be retrieved from repositories
- Changes to the aggregate are committed as a single transaction
A bounded context is a strategic design pattern that defines explicit boundaries within which a particular domain model applies. It's essentially a conceptual boundary where specific terms, definitions, and rules have consistent meaning. Different bounded contexts can have different models for the same real-world concept.
For example, a Customer
in a Sales context might focus on purchase history and preferences, while a Customer
in a Support context might emphasize ticket history and service level agreements. These are different models of the same concept, each valid within its bounded context.
Key characteristics:
- Provides explicit boundaries for domain models
- Ensures ubiquitous language consistency within the boundary
- Allows different models of the same concept in different contexts
- Helps manage complexity in large systems
- Guides team organization and service boundaries
These concepts work together to create a cohesive domain model:
- Bounded contexts define the overall boundaries and scope
- Aggregate roots organize entities and value objects into consistency boundaries
- Entities represent important business objects with identity
- Value objects capture important concepts without identity
This layered approach helps manage complexity by providing clear boundaries, consistent language, and well-defined responsibility areas within your domain model.
To define a root aggregate you need to derive from Aggrgate<TEntity>
base class. The TEntity
is the root entity that is represented by the Aggregate.
Example: Suppose when we need to define a root aggregate that can encapsulate the business functions or behaviour applicable to a bank account entity. Then we first need to define the BankAccount
entity that represents the domain object that stores the state of the account. To define an Entity we need to derive from IEntity
interface.
public class BankAccount : IEntity
{
public int Id { get; set; }
public DateTime CreatedOn { get; set; } = DateTime.UtcNow;
public string AccountName { get; set; } = string.Empty;
public decimal Balance { get; set; }
public bool IsClosed { get; set; }
public string ClosureReason { get; internal set; }
public DateTime ActiveOn { get; internal set; }
}
Once you define the root entity, you can proceed to implement the Aggregate class to define the business operation that could change the state of the aggregate entity.
To define the Bank Acount root aggregate. You need to implement from Aggregate<BankAccount
base class.
public class AccountAggregate : Aggregate<BankAccount>
{
public void CreateAccount(int accountId, string holder, decimal amount)
{
Send(new CreateAccount(new Payload
{
Id = accountId,
AccountName = holder,
InitialAmount = amount
}));
}
public void Deposit(int accountId, decimal amount)
{
Send(new DepositMoney(new TransactPayload
{
Id = accountId,
Amount = amount,
Type = TransactionType.Deposit
}));
}
public void Withdraw(int accountId, decimal amount)
{
Send(new WithdrawMoney(new TransactPayload
{
Id = accountId,
Amount = amount,
Type = TransactionType.Withdrawal
}));
}
public void Close(int accountId, string reason)
{
Send(new CloseAccount(new ClosurePayload
{
Id = accountId,
ClosureReason = reason
}));
}
}
}
Important: To instantiate an Aggregate, you need to use the Create<TAggregate>()
method on IAggregateFactory
. You need to inject the factory interface in your service and call the create method to get the desired aggregate (TAggregate).
public class AccountService : IAccountService
{
private IAggregateFactory aggregateFactory;
public AccountService (IAggregateFactory aggregateFactory)=> this.aggregateFactory = aggregateFactory;
public async Task<int> CreateAccountAsync(string accountHolderName, decimal initialBalance)
{
if (string.IsNullOrEmpty(accountHolderName))
throw new ArgumentException("Account create requires account holder name.", nameof(accountHolderName));
if (initialBalance <= 0)
throw new ArgumentException("Account create requires initial amount.", nameof(initialBalance));
var account = await aggregateFactory.Create<AccountAggregate>();
if (account == null)
throw new InvalidOperationException("Failed to create account aggregate");
var accountId = new Random().Next(); // Simulating a unique account ID generation
account.CreateAccount(accountId, accountHolderName, initialBalance);
return accountId;
}
}
Each operation on the root aggregate is performed by sending a command
. A command is an implementation of Command<TPayload>
base class with TPayload that contains the data required to perform the business operation. The command when handled could change the state of the aggregate entity. In the above aggregate, the business operations for create account, deposit and withdrawal of money, closure of account are performed by raising relevant commands with appropriate payloads.
public abstract class Command<TPayload> : ICommand
where TPayload : class, IPayload, new()
{
public Command(TPayload payload)
{
Metadata = new Metadata();
Name = GetType().Name;
Payload = payload;
}
public string Name { get; set; }
public Metadata Metadata { get; set; }
public TPayload Payload { get; set; }
}
Example: CreateAccount Command.
public class CreateAccount : Command<AccountPayload>
{
public CreateAccount(AccountPayload payload) : base(payload)
{
}
}
An aggregate can subscribe to domain events raised in the system. An Event is an Implementation of Event<TPayload>
base class. The payload is basically the encapsulated entity (instance of IEntity) affected by the event.
To subscribe to an event, the aggregate needs to implement ISubscribes<TEvent>
interface.
Example: AccountAggregate
below.
public class AccountAggregate : Aggregate<BankAccount>,
ISubscribes<AccountCreated>
{
public Task Handle(AccountCreated @event)
{
return Send(new ActivateAccount(new ActivationPayload
{
Id = @event.Payload.Id,
ActiveOn = DateTime.UtcNow,
}));
}
}
The sequence of commands raised by the aggregate are stored in the command store. The Aggregate could choose to replay the stream of commands.
Note: You need to provide the implementation of ICommandStore
interface to manage the stream of commends.
public class AccountAggregate : Aggregate<BankAccount>
{
public void Replay(int AggregateId)
{
base.Replay(AggregateId);
}
}