Skip to main content

Command Palette

Search for a command to run...

Implementing Domain-Driven Design in .NET with Loan Management

Updated
6 min read

Recently, I encountered an interesting problem: how to design a loan management system that is maintainable, scalable, and accurately reflects business rules. After years of working with different architectures, I decided on Domain-Driven Design (DDD), an approach that has proven especially effective for complex domains.

WPF and React, and similar frameworks have been part of my journey as a developer, but now I'm immersed in the world of .NET and clean architecture principles. Many solutions I find on the web are often limited to simplistic examples that hardly reflect the challenges of real enterprise applications.

After more than a decade developing applications with different technologies, I'm surprised to see how many production implementations lack adequate separation of concerns, consisting of just a handful of folders pretending to simulate an architecture.

I've seen projects with hundreds of entities and operations all mixed together, making maintenance a nightmare. That's why I decided to implement a solution using DDD that truly separates responsibilities and maintains domain integrity.

The Problem to Solve

How can we design a loan management system that is scalable, maintainable, and accurately reflects the business rules of the financial domain?

A loan seems simple on the surface, but quickly becomes complex when we consider all the necessary validations, state management, and the need to maintain data integrity throughout the entire lifecycle of the loan.

The DDD Approach

Domain-Driven Design shines precisely in these complex scenarios. It allows us to model the domain in a way that reflects the ubiquitous language of the business and encapsulate logic in appropriate places. Let's see how I've applied several DDD patterns in my implementation.

Aggregates and Entities

In DDD, an aggregate is a set of related objects that we treat as a unit. In our case, the loan (Loan) is clearly a root aggregate that contains entities such as PersonalInformation and BankInformation.

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();

    // Private constructor to force the use of the Create method
    private Loan(LoanId Id, int LoanAmount, int LoanTerm, int LoanPurpose, 
                LoanStatus loanStatus, PersonalInformation personalInformation, 
                BankInformation bankInformation)
    {
        this.Id = Id;
        this.LoanAmount = LoanAmount;
        this.LoanTerm = LoanTerm;
        this.LoanPurpose = LoanPurpose;
        PersonalInformation = personalInformation;
        BankInformation = bankInformation;
        LoanStatus = loanStatus;
    }
}

Notice how I've designed the Loan class with private setters and a private constructor. This is a fundamental principle: encapsulation. We can only create a loan through the Create method (Factory pattern):

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();
}

Value Objects

Value Objects are another crucial component of DDD. They are immutable and identified by the value of their properties, not by an identity. In my implementation, LoanId is a perfect example:

internal record LoanId(Guid Value)
{
    public static implicit operator Guid(LoanId loanId) => loanId.Value;
    public static implicit operator LoanId(Guid value) => new(value);
}

I'm using modern C# here with records, which are ideal for Value Objects due to their immutable nature.

Domain Rules and Validations

One of the most important parts of DDD is ensuring that business rules are in the right place: within the domain. I've incorporated validations directly into the entities:

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));
    }

    // More validations...

    var personalValidationResult = PersonalInformation.Validate();
    if (!personalValidationResult.IsSuccess)
    {
        return personalValidationResult.Map();
    }

    var bankValidationResult = BankInformation.Validate();
    if (!bankValidationResult.IsSuccess)
    {
        return bankValidationResult.Map();
    }

    return Result.Success();
}

Domain Events

Domain events are fundamental to correctly implementing DDD, especially in complex systems. They allow decoupling certain actions and facilitate implementing patterns like CQRS.

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)
{
}

And in the loan entity, we use these events when important changes occur:

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();
}

Nested Entities

The loan contains entities such as PersonalInformation and BankInformation. These entities also have their own encapsulated validations:

internal class PersonalInformation
{
    public string FullName { get; private set; }
    public string Email { get; private set; }
    public DateOnly DateOfBirth { get; private set; }

    private PersonalInformation(string fullName, string email, DateOnly dateOfBirth)
    {
        FullName = fullName;
        Email = email;
        DateOfBirth = dateOfBirth;
    }

    public static Result<PersonalInformation> Create(string fullName, string email, DateOnly dateOfBirth)
    {
        var personalInformation = new PersonalInformation(fullName, email, dateOfBirth);
        return personalInformation.Validate();
    }

    public Result Validate()
    {
        if (string.IsNullOrEmpty(FullName))
        {
            return Result.Invalid(new ValidationError(nameof(FullName), string.Empty, 
                                DomainErrors.PersonalInformation.FULL_NAME_REQUIRED, 
                                ValidationSeverity.Error));
        }

        // More validations...

        return Result.Success();
    }
}

Advantages of this Approach

  1. Robust encapsulation: Entities control their internal states

  2. Protected business rules: Validation occurs within the domain

  3. Ubiquitous language: The code reflects business terms

  4. Maintainability: Changes in the domain are localized

  5. Testability: It's easy to test entities and their behaviors

Important Points to Highlight

1. Private Constructors and Factory Methods

Note that all constructors are private. This forces us to use the Create methods that include validation. It's impossible to create an invalid object.

2. Error Handling with Result

Instead of throwing exceptions, I'm using the Result pattern (from the Ardalis.Result library) which offers a more explicit and predictable way of handling errors:

public Result<PersonalInformation> Create(string fullName, string email, DateOnly dateOfBirth)
{
    var personalInformation = new PersonalInformation(fullName, email, dateOfBirth);
    return personalInformation.Validate();
}

3. Controlled State Transitions

Loan state changes are controlled by specific methods, not by public setters:

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();
}

Conclusion

Implementing DDD is not simply adopting a set of technical patterns; it's a mindset shift. It's about modeling software to reflect the real business domain, not the other way around.

In my experience, although this approach may initially seem more complex and verbose compared to typical CRUD examples found online, it pays enormous dividends when the application grows in complexity, especially for enterprise applications with numerous intertwined business rules.

The real benefits include:

  • Clear separation of concerns

  • Testable business logic independent of infrastructure

  • Code that reflects the business language

  • Validations contained in the right place

  • Easy to extend without breaking domain integrity

As developers, it's easy to fall into the trap of thinking only in terms of technology and frameworks. DDD reminds us that our main goal is to solve business problems, and that code should reflect the domain we're modeling.

The next time you face a complex problem, consider whether DDD might be the answer. It's not a silver bullet, but for domains rich in business rules like financial loans, few architectures offer such a clear and maintainable approach.

More from this blog

R

Rick Dev

13 posts