Implementing Event-Domain-Driven Design in a .NET Loan Management System
Domain-Driven Design (DDD) isn't just a set of theoretical patterns—it's a practical approach to developing complex software systems. As part of my work on a loan management system, I've put these principles into practice using the .NET ecosystem. Let me walk you through how I've applied DDD concepts to create a maintainable, scalable solution that accurately reflects the business domain.
DDD in Action: The Loan Domain Model
Looking at the code I've implemented, you can see how the loan domain takes shape through clearly defined aggregates, entities, value objects, and domain events.
Aggregates and Entities
The Loan class serves as our aggregate root, containing all the essential properties and behavior related to a loan:
internal class Loan
{
public LoanId Id { get; private set; }
public int LoanAmount { get; private set; }
public int LoanTerm { get; private set; }
public int LoanPurpose { get; private set; }
public BankInformation BankInformation { get; private set; }
public LoanStatus LoanStatus { get; private set; } = LoanStatus.Pending;
public PersonalInformation PersonalInformation { get; private set; }
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
}
Notice the use of private setters and a private constructor. This encapsulation is crucial—it prevents external code from modifying the loan's state directly, preserving domain integrity.
The only way to create a loan is through the Create factory method:
public static Result<Loan> Create(LoanId Id, int LoanAmount, int LoanTerm,
int LoanPurpose, LoanStatus loanStatus,
PersonalInformation personalInformation,
BankInformation bankInformation)
{
var loan = new Loan(Id, LoanAmount, LoanTerm, LoanPurpose, loanStatus,
personalInformation, bankInformation);
return loan.Validate();
}
This pattern ensures that every loan instance is valid upon creation, enforcing invariants at the domain level.
Value Objects
Value objects represent descriptive aspects of the domain with no conceptual identity. In my implementation, LoanId is a perfect example of a value object:
internal record LoanId(Guid Value)
{
public static implicit operator Guid(LoanId loanId) => loanId.Value;
public static implicit operator LoanId(Guid value) => new(value);
}
I've leveraged C# records for value objects since they provide immutability and value-based equality out of the box. The implicit conversion operators add a nice touch, making the code more fluent when working with these types.
Rich Domain Model with Behavior
A key aspect of DDD is that domain objects encapsulate not just data but behavior. My Loan class includes methods that represent business operations:
public Result Approve()
{
if (LoanStatus != LoanStatus.Pending)
{
return Result.Invalid(new ValidationError(nameof(LoanStatus), string.Empty,
DomainErrors.Loan.LOAN_STATUS_INVALID, ValidationSeverity.Error));
}
LoanStatus = LoanStatus.Approved;
_domainEvents.Add(new LoanApprovedEvent(Id));
return Result.Success();
}
public Result Cancel()
{
if (LoanStatus != LoanStatus.Pending)
{
return Result.Invalid(new ValidationError(nameof(LoanStatus), string.Empty,
DomainErrors.Loan.LOAN_STATUS_INVALID, ValidationSeverity.Error));
}
LoanStatus = LoanStatus.Canceled;
_domainEvents.Add(new LoanCanceledEvent(Id));
return Result.Success();
}
These methods enforce business rules (a loan can only be approved if it's pending) and raise appropriate domain events, keeping the domain model self-contained and expressive.
Domain Events
Domain events play a crucial role in DDD, allowing different parts of the system to react to changes without tight coupling. Here's how I've implemented domain events:
internal abstract class LoanDomainEvent(LoanId loanId) : IDomainEvent
{
public Guid Id { get; } = Guid.NewGuid();
public DateTime OccurredOn { get; } = DateTime.UtcNow;
public string EventType => GetType().Name;
public LoanId LoanId { get; } = loanId;
}
internal class LoanApprovedEvent(LoanId loanId) : LoanDomainEvent(loanId)
{
}
internal class LoanCanceledEvent(LoanId loanId) : LoanDomainEvent(loanId)
{
}
When significant state changes occur within the domain, the relevant events are raised, allowing other components (like notification services) to act accordingly without introducing direct dependencies.
Validation as Part of the Domain
In DDD, validation belongs within the domain. Each entity and aggregate includes its own validation logic:
public Result Validate()
{
if (Id == default)
{
return Result.Invalid(new ValidationError(nameof(Id), string.Empty,
DomainErrors.Loan.LOAN_NOT_FOUND, ValidationSeverity.Error));
}
if (LoanAmount <= 0)
{
return Result.Invalid(new ValidationError(nameof(LoanAmount), string.Empty,
DomainErrors.Loan.LOAN_AMOUNT_INVALID, ValidationSeverity.Error));
}
// Additional validations...
var personalValidationResult = PersonalInformation.Validate();
if (!personalValidationResult.IsSuccess)
{
return personalValidationResult.Map();
}
var bankValidationResult = BankInformation.Validate();
if (!bankValidationResult.IsSuccess)
{
return bankValidationResult.Map();
}
return Result.Success();
}
This approach ensures that business rules are encapsulated within their respective domain objects and consistently applied.
The Application Layer: Command Handlers
The application layer orchestrates the domain objects to fulfill use cases. Here's how I've implemented a command handler for submitting a loan:
internal class SubmitLoanCommandHandler(ILoanRepository loanRepository) :
CommandHandler<SubmitLoanCommand, Result<SubmitLoanCommandResponse>>
{
public override async Task<Result<SubmitLoanCommandResponse>> ExecuteAsync(
SubmitLoanCommand command, CancellationToken ct = default)
{
// 1. Create and validate domain entities
var personalInformationResult = PersonalInformation.Create(
command.PersonalInformation.FullName,
command.PersonalInformation.Email,
command.PersonalInformation.DateOfBirth
);
// ... additional validation logic
// 2. Create the aggregate root
var loanResult = Loan.Create(
Id: new LoanId(Guid.NewGuid()),
LoanAmount: command.LoanAmount,
LoanTerm: command.LoanTerm,
LoanPurpose: command.LoanPurpose,
loanStatus: LoanStatus.Pending,
personalInformation: personalInformation,
bankInformation: bankInformation);
// ... persistence logic
// 3. Publish domain events
foreach (var @event in loan.DomainEvents)
{
var eventNotification = new LoanNotification(JsonSerializer.Serialize(@event));
await eventNotification.PublishAsync(cancellation: ct);
}
return new SubmitLoanCommandResponse(loan.Id.Value.ToString());
}
}
The command handler follows a clear workflow:
Create and validate domain entities
Create the aggregate root
Persist changes
Publish domain events
This separation ensures that application logic coordinates domain operations without mixing concerns.
Domain Events and Notifications
An important aspect of my implementation is how domain events connect to the notification system:
internal class LoanNotificationHandler : IEventHandler<LoanNotification>
{
public Task HandleAsync(LoanNotification notification, CancellationToken cancellationToken)
{
// Handle the loan notification here
// For example, send an email or push notification
// Could apply outbox pattern here to send the notification to a queue or service bus
return Task.CompletedTask;
}
}
This decoupling allows the domain to focus on business rules while infrastructure components handle cross-cutting concerns like notifications.
Data Transfer Objects (DTOs) and Contracts
To separate the domain model from external contracts, I've created DTOs specifically for API communication:
public record PersonalInformationDto(string FullName, string Email, DateOnly DateOfBirth);
public record BankInformationDto(string BankName, string AccountType, string AccountNumber);
public class SubmitLoanCommand : ICommand<Result<SubmitLoanCommandResponse>>
{
public int LoanAmount { get; set; }
public int LoanTerm { get; set; }
public int LoanPurpose { get; set; }
public required BankInformationDto BankInformation { get; set; }
public required PersonalInformationDto PersonalInformation { get; set; }
}
These DTOs define the contract between the API and clients without exposing the internal domain model.
Key Benefits of this DDD Implementation
Implementing DDD in this loan management system has provided several advantages:
Clear boundaries: Each domain concept has a well-defined responsibility
Encapsulated business rules: Validation occurs within the domain, not scattered across the application
Rich domain model: Entities contain both data and behavior, reflecting real-world concepts
Explicit state transitions: Status changes are controlled by dedicated methods that enforce business rules
Decoupled components: Domain events allow different parts of the system to communicate without tight coupling
Practical Considerations
While implementing DDD, I encountered several practical considerations:
Result Pattern for Error Handling
Instead of throwing exceptions, I've used the Result pattern (from Ardalis.Result) for more explicit error handling:
public Result Validate()
{
if (string.IsNullOrEmpty(FullName))
{
return Result.Invalid(new ValidationError(nameof(FullName), string.Empty,
DomainErrors.PersonalInformation.FULL_NAME_REQUIRED,
ValidationSeverity.Error));
}
// Additional validations...
return Result.Success();
}
This approach makes error paths explicit and provides a consistent way to handle validation failures.
Domain Events for Integration
Domain events provide a clean way to integrate with external systems without polluting the domain model:
// In the domain model
LoanStatus = LoanStatus.Approved;
_domainEvents.Add(new LoanApprovedEvent(Id));
// In the application layer
foreach (var @event in loan.DomainEvents)
{
var eventNotification = new LoanNotification(JsonSerializer.Serialize(@event));
await eventNotification.PublishAsync(cancellation: ct);
}
This approach keeps the domain model focused on business logic while allowing for integration with external systems.
Conclusion
Domain-Driven Design isn't just an academic exercise—it's a practical approach to building complex systems that reflect real business domains. By implementing DDD in this loan management system, I've created a codebase that is maintainable, testable, and aligned with business terminology.
The result is a system where:
Business rules are clearly expressed in code
Changes to the domain are localized and don't ripple throughout the system
The code structure reflects the language of the domain experts
Integration with external systems is clean and decoupled

