Skip to main content

Command Palette

Search for a command to run...

Implementing Event-Domain-Driven Design in a .NET Loan Management System

Updated
6 min read

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:

  1. Create and validate domain entities

  2. Create the aggregate root

  3. Persist changes

  4. 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:

  1. Clear boundaries: Each domain concept has a well-defined responsibility

  2. Encapsulated business rules: Validation occurs within the domain, not scattered across the application

  3. Rich domain model: Entities contain both data and behavior, reflecting real-world concepts

  4. Explicit state transitions: Status changes are controlled by dedicated methods that enforce business rules

  5. 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

More from this blog

R

Rick Dev

13 posts