When DDD clicked

When DDD clicked

in

Domain-Driven Design was one of those things I struggled to get to grips with for a long, long time.

When I joined Billie in 2019, it was a hot topic, and I immediately tried to get more familiar with it. I watched hours worth of conference talks and read the books, but despite the words making sense to me, the actual idea was still passing me by. I couldn’t see the value in it yet.

I thought I was learning by doing too. Our projects had some entities, and followed some sort of layered architecture, whether it was Clean, Onion, or something else with a strong opinion about folders. The idea was the same: we had a domain layer that was supposed to sit at the centre, depend on nothing, be well tested, and contain the business logic.

Only, it didn’t.

Looking back, I realise you can build something that looks a lot like DDD while keeping the important logic outside the model. I did that more than once. I had picked up enough of the terminology to feel like I was getting somewhere, and I could nod along in discussions, but that turned out to be different from actually understanding it.

I understood DDD in theory, but couldn’t see its value in practice.


When it clicked

The moment it started making sense was when we began re-writing parts of our limit system.

First, some context: Billie is a B2B lending platform but we limit how much we lend.

Imagine a buyer can finance 500 EUR with us in total, but is only allowed to spend 100 EUR at a particular merchant. To decide whether an incoming order should be allowed, we had to consider both together.

The old shape of the code might have looked something like this:

// Somewhere in the "Application" layer
function checkLimit($buyerId, $merchantId, $requestedAmount) {
    $buyerLimit = $this->buyerLimitRepository->getLimitByBuyerId($buyerId);
    $buyerMerchantLimit = $this->buyerMerchantLimitRepository
        ->getLimitByBuyerAndMerchantId($buyerId, $merchantId);

    if ($buyerLimit->availableLimit < $requestedAmount) {
        return false;
    }

    if ($buyerMerchantLimit->availableLimit < $requestedAmount) {
        return false;
    }

    return true;
}

This felt fine, and in all honesty, it isn’t terrible. But where is the value in DDD here?

The repositories are returning entities, so on paper it looks like DDD, but they are not really doing anything. The important business decision is still being made in the application layer. We are loading bits of state, checking them one by one, and stitching the answer together.

Compare that to a more DDD version:

// Somewhere in the "Application" layer - same layer, just much thinner now
function checkLimit($buyerId, $merchantId, $requestedAmount) {
    $buyer = $this->buyerRepository->getBuyer($buyerId);

    return $buyer->checkLimit($requestedAmount, $merchantId);
}

Now the repository is returning an aggregate. The buyer arrives with its own limit, its merchant-specific limits, and the rest of the state needed for that operation - everything is loaded as one unit.

That was the bit that did the heavy lifting for me.

The win was not just that the application layer got cleaner, although it did. The win was that the application layer no longer had to know which pieces of data mattered, how many queries were involved, or how to combine those rules into one decision. It loaded one meaningful thing from the repository and asked it a domain question.

That was the first time DDD stopped feeling like architecture vocabulary and started feeling genuinely useful.


The missing piece

I knew about aggregates. I’d read about them and heard people talk about them often enough, but it never clicked. I couldn’t properly distinguish an aggregate from an entity. There were too many terms flying around: aggregates, aggregate roots, entities, value objects. The aggregate bit always passed me by.

It turns out, this was the missing piece that tied it all together for me.

In the example above, it wasn’t enough that we moved checkLimit() into a domain object. On its own, that would just be moving code around.

The more important part was that the repository was returning a fully formed aggregate, with the state needed for the operation already loaded. That was the thing I never fully appreciated.

I can also see why my brain probably glazed over this point for so long. Why am I loading from multiple database tables when I don’t know if I’ll need it? Maybe I’m wasting queries. Why am I loading 20 merchant-specific limits when I might only check one? That can feel wasteful, and I still think that concern is fair. But it’s a trade-off, and software engineering is mostly a long series of trade-offs.

My previous understanding of repositories was basically “a way to load things from storage”, and they kind of are. But in practice, the useful part was that they could return the whole consistency boundary for the operation.

In this case, it meant loading the buyer with its own limit, merchant-specific limits, and any other state required to make business decisions for that buyer.

Once that was in place, the flow became very simple:

  • load the aggregate
  • ask it to do something
  • save it back as one unit (if necessary)

That made everything click.

At that point I cared a lot less about the vocabulary, the layers, or which particular flavour of architecture we were using. Onion, layered, clean, ports and adapters, and all the rest of it are all trying to do a similar thing: separate concerns, protect the domain and keep external dependencies pointing the right way.

The buyer arrived carrying the information it needed, and it knew how to protect itself.


What does “protect itself” actually mean?

Taking the same buyer aggregate, imagine we now want to act on a successful check and reserve some of that limit. I said above that the buyer knew how to protect itself, but what does that actually mean?

This is another part of the value for me. The object representing the business concept is not just carrying data around, it is also enforcing the rules that must stay true.

In this case, let’s say we wanted to reserve some of that limit. We might have something like this:

// Somewhere on the "Buyer" aggregate
function reserve($requestedAmount) {
    if ($requestedAmount > $this->limitAmount) {
        throw new InsufficientFundsException();
    }

    $this->limitAmount = $this->limitAmount - $requestedAmount;

    return true;
}

With this in the domain model, the rule cannot be bypassed by accident. You simply must have enough limit to reserve the amount. You cannot forget to do the check in some application service, because the check lives with the business concept itself.

That is a much stronger position than having some application service load a few records and hopefully remember to check everything in the right order.

I didn’t have the word “invariant” in my head at the time, but that’s all it really is - the conditions that have to stay true for the data to be valid. Once I had a name for it, a lot of other things clicked into place too.

That, as far as I’m concerned, is where the value really starts to show up.


What I think DDD is actually good at

This is the point where I have to be careful not to oversell it.

Because I don’t think DDD is automatically the right approach for every system. I think it starts to pay off when the domain has real rules, real language, and real consequences if those rules are applied inconsistently.

In other words, when the important part of the system is not just storing and retrieving data, but protecting business behaviour.

That was the difference for me.

Before, I mostly saw DDD as a collection of patterns and terms. Aggregates. Repositories. Value objects. Domain services. Application services. All of that. I could repeat the words, but I couldn’t really feel why they mattered.

Once I saw a model carrying the state for an operation and enforcing the rules around it, the value became much easier to see. Not because the code looked fancier, but because the design made it harder to do the wrong thing.

I think that’s where DDD starts to earn its place: when you have a domain with real invariants, real decisions, and real cost if those decisions are implemented inconsistently.

If the model is just there to hold data, then a lot of this is honestly ceremony. But if the model needs to defend the rules of the business, that’s when the extra effort starts to make sense.

Sometimes a simple MVC application is absolutely fine. Sometimes having your Doctrine entities act as the core of the system is good enough. Sometimes the domain just isn’t complicated enough to justify all of this extra thought.

Because DDD does come with overhead. You have to think about boundaries. You have to decide what belongs inside the aggregate and what doesn’t. You have to think about transactions, consistency, and persistence. Even fairly mundane questions start showing up, like “what is actually dirty here?” and “what exactly am I saving back?”

That is all real overhead. And if the system is mostly straightforward CRUD, or the business rules are light enough that they can be understood in one glance, then a lot of that cost may not be worth paying. I have definitely forced DDD-shaped thinking into places where it didn’t belong. It did not make the code better.

Simpler really can be better. KISS exists for a reason!


In the end

DDD clicked for me when it stopped being a collection of terms and started solving a real modelling problem.

The missing piece was not the vocabulary. It was understanding that the model needed to arrive with the state required for the operation, and be able to enforce the rules that had to stay true.

Once I saw that, the rest of it started making a lot more sense. Not just aggregates, but the general idea behind all these architectural styles that try to protect the domain and keep external concerns at a distance.

I still don’t think DDD is the answer to everything. But when the domain has real rules, real invariants, and real cost if you get them wrong, I finally understand why people reach for it.

It turns out I didn’t need more DDD words. I just needed a problem that made the words mean something.