# Domain-Driven Design in .NET: A Practical Introduction

I used to think Domain-Driven Design was academic, something you read about but never actually applied outside a conference talk. That changed on a loan management portal where the business rules around approvals were genuinely complicated, different member categories, different thresholds, exceptions that had exceptions. Trying to express all of that as a service class full of `if` statements and a bunch of boolean flags on an anemic model became unmanageable fast. DDD, applied practically rather than dogmatically, is what got that codebase back under control.

**The core shift: models that know their own rules**

Most codebases I've inherited have anemic domain models, classes that are really just data bags, with all the actual logic living in a separate service layer.

csharp

```csharp
public class LoanApplication
{
    public decimal Amount { get; set; }
    public LoanStatus Status { get; set; }
}
```

DDD pushes validation into the model itself, so it's impossible to represent an invalid state in the first place:

csharp

```csharp
public class LoanApplication
{
    public decimal Amount { get; private set; }
    public LoanStatus Status { get; private set; }

    public void Approve(Guid approverId)
    {
        if (Status != LoanStatus.Pending)
            throw new DomainException("Only pending applications can be approved.");
        Status = LoanStatus.Approved;
    }
}
```

The private setter matters more than it looks. Nobody outside this class can silently flip the status without going through `Approve()` and its validation. The rule lives in exactly one place instead of being re-implemented, inconsistently, everywhere the status gets changed.

**Ubiquitous language**

One underrated part of DDD is insisting code, conversation, and documentation all use the same vocabulary. On the loan portal, stakeholders said "disbursement," not "payout." Once the code matched, translation errors between what was asked for and what got built dropped, because there was no mental mapping step in between.

**Bounded contexts**

A "member" in the loan approval context has a credit history. A "member" in the notifications context just has an email address. Forcing one shared class to serve both either bloats it with irrelevant fields or creates hidden coupling. Bounded contexts give each part of the system its own accurate model, with an explicit translation layer where they need to talk.

**Aggregates**

An aggregate is a cluster of objects that must stay consistent as a unit, with one entity as the entry point for change. A `LoanApplication` and its `ApprovalHistory` entries formed one aggregate: you can't add a history entry without going through the application itself, which is what keeps the two from drifting out of sync.

csharp

```csharp
public class LoanApplication
{
    private readonly List<ApprovalHistoryEntry> _history = new();
    public IReadOnlyList<ApprovalHistoryEntry> History => _history;

    public void Approve(Guid approverId)
    {
        Status = LoanStatus.Approved;
        _history.Add(new ApprovalHistoryEntry(approverId, DateTime.UtcNow));
    }
}
```

Getting aggregate boundaries right is genuinely hard, and I've drawn them wrong more than once. Too large, you get contention. Too small, you lose the consistency guarantee that was the whole point.

**Where I don't apply this**

A simple CRUD screen for product categories doesn't need rich domain modeling. DDD earns its complexity where business rules are genuinely complex and getting them wrong has real consequences, not uniformly across a codebase.

**The real payoff**

The loan portal's approval logic became something you could read and trust, because the rules lived in one place, in the business's own language, impossible to bypass accidentally. That's the value proposition: not a purity test, a way of making complex business logic legible and safe to change.
