# Domain-driven design

De taal van je klant in je code: ubiquitous language, bounded contexts, domeinmappen, value objects, states en events in Laravel. En wanneer het te veel is.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, architecture, design-patterns

Slides: <https://slides.mctantwerp.be/nl/the-agency/07-domain-driven-design>

<!-- slide 2 -->

## Agenda

1. Wat DDD is
2. Ubiquitous language
3. Bounded contexts
4. Domeinen in Laravel
5. Value objects en DTOs
6. States en events
7. Wanneer het te veel is

> **Toelichting:** De eerste drie hoofdstukken gaan over denken en praten, met weinig code. Die lonen in elk project. Hoofdstuk 4 tot 6 tonen hoe het eruitziet in Laravel, en hoofdstuk 7 helpt je kiezen wat je daarvan echt gebruikt.

<!-- slide 3 -->

## Na deze les kan je

- De woorden van je klant vertalen naar één naam per begrip in je code, en die lijst bijhouden
- De bounded contexts in een klantproject aanwijzen
- Een Laravel-project per domein organiseren, en weten wat Laravel dan niet meer vanzelf vindt
- Een value object met een cast schrijven, en het verschil met een DTO uitleggen
- Een status als enum met toegestane overgangen bouwen, en een domain event sturen
- Beslissen of DDD past bij een project van twaalf weken

> **Toelichting:** Niet elk van deze vaardigheden gebruik je in elk project. De woordenlijst en de enum met overgangen wel: die kosten weinig en voorkomen bugs die je anders pas bij de klant ziet.

<!-- slide 4 -->

## 1. Wat DDD is

> **Toelichting:** DDD hoor je veel in vacatures en op conferenties, vaak zonder dat iemand zegt wat ermee bedoeld wordt. Dit hoofdstuk legt uit waar het vandaan komt, en welk deel ervan voor jou telt.

<!-- slide 5 -->

**Jij kent Laravel. Janssens kent keukens.**

Jij weet hoe een queue werkt. Janssens weet dat je een keuken pas plaatst als het voorschot betaald is, en dat een renovatie 6% btw krijgt.

> **Toelichting:** Je kan niet alle regels van een klant zelf bedenken. Het voorschot van 30% of de 6% btw zijn geen technische keuzes, maar afspraken in de zaak van de klant. Vraag ernaar voor je code schrijft: elke regel die je zelf verzint, is een gok.

<!-- slide 6 -->

### Domain-driven design

- Een boek van Eric Evans uit 2003, samengevat in de gratis [DDD Reference](https://www.domainlanguage.com/ddd/). Geen package, geen framework, geen mappenstructuur.
- Het domein is de zaak van je klant. Bij Janssens: opmeten, offertes, plaatsingen en facturen.
- De code volgt hoe de klant over zijn zaak praat, niet hoe de database eruitziet.
- Je bouwt dat model samen met een domain expert: iemand bij de klant die het werk elke dag doet. Bij Janssens is dat niet de zaakvoerder maar Sofie van de administratie, want zij kent de uitzonderingen.
- De moeilijkste bugs in een klantproject zijn geen PHP-bugs, maar regels van de klant die je verkeerd begrepen hebt.

> **Toelichting:** Het boek van Evans is dik en abstract, en de meeste developers lezen het nooit helemaal. Implementing Domain-Driven Design van Vaughn Vernon is praktischer. Voor Laravel is Laravel Beyond CRUD de meest directe vertaling.

<!-- slide 7 -->

### Strategisch en tactisch

#### Strategisch

- Welke woorden gebruiken we, en wat betekenen ze? Ubiquitous language.
- Waar houdt een betekenis op? Bounded contexts.
- Loont in elk project, ook in een project van twaalf weken.

#### Tactisch

- Patronen in de code: entities, value objects, states, domain events, aggregates.
- Een gereedschapskist: je neemt wat helpt.
- Veel teams doen het omgekeerd: ze kopiëren de mappen en slaan de gesprekken met de klant over.

> **Toelichting:** Wie DDD opzoekt, vindt vooral het tactische deel: entities, repositories, aggregates. Dat deel loopt ook het makkelijkst verkeerd, omdat je patronen kopieert zonder het gesprek met de klant. Begin daarom altijd bij de taal en de grenzen.

<!-- slide 8 -->

### Een keuken bij Janssens

- **Week 1** Aanvraag: Via het formulier op de site
- **Week 2** Opmeting: Iemand gaat ter plaatse meten
- **Week 3** Offerte: Lijnen, btw, geldig 30 dagen
- **Week 4** Voorschot: 30% bij goedkeuring
- **Week 10** Plaatsing: Twee monteurs, twee dagen
- **Week 11** Eindfactuur: Min het voorschot

Zo loopt één keuken, van aanvraag tot eindfactuur. Bedenk bij elke stap wat de software moet doen. Je ziet drie soorten werk opduiken: verkopen, plannen en factureren. Dat worden straks de contexts.

> **Toelichting:** De tijdlijn is vereenvoudigd. In werkelijkheid zijn er uitzonderingen: een klant die na de opmeting afhaakt, een plaatsing die verschuift, een voorschot dat te laat betaald wordt. Die uitzonderingen hoor je alleen als je ernaar vraagt.

<!-- slide 9 -->

## 2. Ubiquitous language

> **Toelichting:** Veel bugs in klantprojecten komen uit een misverstand over een woord. De klant zegt klant en bedoelt iets anders dan jij. Dit hoofdstuk gaat over hoe je zulke misverstanden vroeg vangt.

<!-- slide 10 -->

### Wat de klant zegt

| Janssens zegt | De eerste versie | In de taal van de klant |
| --- | --- | --- |
| een offerte | `Document, type = 1` | `Quote` |
| de opmeting | `Appointment, type = 'measure'` | `SiteSurvey` |
| het voorschot | `Invoice, is_partial = true` | `InvoiceType::Deposit` |
| de plaatsing | `Event` | `Installation` |
| goedgekeurd | `status = 3` | `QuoteStatus::Accepted` |

De middelste kolom krijg je als je de database ontwerpt voor je met de klant praat: een tabel `documents` met een type-kolom, en na een maand weet niemand nog wat type 3 is.

> **Toelichting:** Een type-kolom is niet altijd fout. Soms hebben dingen echt bijna dezelfde velden. Heeft elk type eigen regels, zoals een voorschotfactuur die anders geboekt wordt dan een eindfactuur, dan verdient het een eigen naam in de code.

<!-- slide 11 -->

### Eén taal voor iedereen

**Ubiquitous language**: Eén set woorden die de klant, jij, de designer en de code allemaal gebruiken. Zegt Sofie opmeting, dan heet de klasse SiteSurvey en de knop Opmeting inplannen.

**Domain expert**: Iemand bij de klant die het werk elke dag doet. Die beslist wat een woord betekent, niet de developer.

Ubiquitous betekent overal aanwezig: in gesprekken, in tickets, in de code. Meer bij [Martin Fowler](https://martinfowler.com/bliki/UbiquitousLanguage.html).

> **Toelichting:** Een ubiquitous language is geen woordenboek dat je één keer maakt. Ze verandert als je meer over de zaak leert. Zet de woordenlijst in de repo, zodat een wijziging in de taal ook een commit is die je team ziet.

<!-- slide 12 -->

### Nederlands of Engels

- Code schrijf je in het Engels, Janssens spreekt Nederlands. Dus vertaal je, één keer, en schrijf je de vertaling op.
- Een woordenlijst in de README: opmeting is SiteSurvey, voorschot is Deposit. De klant kan ze lezen en verbeteren.
- Nooit twee woorden voor één ding. Stel: een app met User, Member, Customer en Account, allemaal voor dezelfde persoon. Elke nieuwe developer vraagt wat het verschil is, en er is er geen.
- Verandert de taal van de klant, dan hernoem je de code mee. Dat is een gewone refactor.

> **Toelichting:** Sommige teams houden domeinwoorden in het Nederlands in de code, zoals Offerte en Opmeting. Dat kan, zolang het consequent is. Het nadeel: je mengt Nederlands met Laravel-woorden als store en update, en een collega die geen Nederlands spreekt, leest het niet.

<!-- slide 13 -->

### Dezelfde regel, twee keer

#### In de taal van de database

```php
$document->update(['status' => 3]);

Invoice::create([
    'document_id' => $document->id,
    'is_partial' => true,
]);
```

Om te weten wat hier gebeurt, moet je de code lezen en weten wat 3 betekent.

#### In de taal van de klant

```php
$acceptQuote->execute($quote);
```

Hier lees je het gewoon: de offerte wordt goedgekeurd. Sofie kan meelezen als je haar je scherm toont.

> **Toelichting:** Rechts zitten de details in AcceptQuoteAction, waar ze horen. Wie wil weten wat goedkeuren precies doet, opent één bestand met een duidelijke naam. Wie het linkse wil begrijpen, moet zoeken waar status 3 nog gebruikt wordt.

<!-- slide 14 -->

### De klant keurt een offerte goed. Hoe heet de action?

- A. `UpdateQuoteStatusAction`
- B. `AcceptQuoteAction`
- C. `QuoteService::handle()`
- D. `SetStatusToThreeAction`

**Antwoord:** B. `AcceptQuoteAction`

Zo zegt de klant het: de offerte is goedgekeurd. UpdateQuoteStatus klinkt technisch correct, en daarom is het verleidelijk, maar het zegt wat er in de database gebeurt, niet wat er in de zaak gebeurt. Het past ook even goed op weigeren en laten vervallen, dus je weet niet welke regels erin zitten.

> **Toelichting:** Een goede test voor een naam: lees hem hardop voor aan de klant. Offerte goedkeuren begrijpt Sofie meteen, offertestatus updaten niet. Namen met Service, Manager of Handler zeggen bijna nooit wat er gebeurt.

<!-- slide 15 -->

## 3. Bounded contexts

> **Toelichting:** Bounded contexts klinken als iets voor grote bedrijven, maar het probleem bestaat in elk project: één woord met twee betekenissen. Dit hoofdstuk toont hoe je die grenzen vindt, en wat je ermee doet in één Laravel-app.

<!-- slide 16 -->

### Eén woord, drie betekenissen

- **Klant bij verkoop**: Naam, telefoon, wat voor keuken hij wil, hoe hij ons gevonden heeft.
- **Klant bij planning**: Adres, verdieping, lift of niet, waar de camionette kan parkeren.
- **Klant bij facturatie**: Btw-nummer, facturatieadres, betaaltermijn, openstaande bedragen.

Dezelfde persoon, drie modellen. Steek je die in één tabel `customers`, dan krijg je veertig kolommen waarvan de helft altijd leeg is, en regels van drie afdelingen in één model.

> **Toelichting:** Een aanwijzing dat je twee contexts hebt: twee afdelingen bij de klant die hetzelfde woord gebruiken maar andere vragen stellen. Verkoop vraagt wat de klant wil, facturatie wanneer hij betaalt. Vraag in je eigen project wie welke gegevens nodig heeft.

<!-- slide 17 -->

### Bounded context

- Een grens waarbinnen elk woord één betekenis heeft. Binnen Facturatie betekent klant altijd iemand met een btw-nummer en een betaaltermijn.
- Elke context heeft zijn eigen model en zijn eigen regels.
- Contexts praten met elkaar via events of een duidelijke action, niet door in elkaars tabellen te schrijven.
- Bij grote bedrijven wordt een context soms een aparte applicatie. Voor een Agency-project is een context gewoon een map in één Laravel-app. Microservices voor een schrijnwerkerij zijn een slecht idee.
- Meer over deze grens bij [Martin Fowler](https://martinfowler.com/bliki/BoundedContext.html).

> **Toelichting:** In één Laravel-app mag je gerust één tabel customers delen tussen contexts. De grens zit dan in de code: welke context welke velden aanpast en welke regels toepast. Zo hou je de voordelen zonder aparte databases.

<!-- slide 18 -->

### De contexts van Janssens

| Context | Wat erin zit | Praat met de rest via |
| --- | --- | --- |
| Offertes | Quote, QuoteLine, prijzen en btw | stuurt `QuoteAccepted` |
| Planning | SiteSurvey, Installation, monteurs | luistert naar `QuoteAccepted` |
| Facturatie | Invoice, voorschot, betalingen | luistert naar `QuoteAccepted` |

> [!NOTE]
> Relaties over de grens mogen in Eloquent. De regels niet: Facturatie rekent geen offertetotaal opnieuw uit, het vraagt het aan de offerte.

> **Toelichting:** QuoteAccepted is de enige verbinding tussen de drie contexts in dit voorbeeld. Dat is een goed teken: hoe minder verbindingen, hoe minder een wijziging in de ene context de andere breekt. Maak voor je eigen project dezelfde tabel en tel de verbindingen.

<!-- slide 19 -->

## 4. Domeinen in Laravel

> **Toelichting:** Vanaf hier wordt het concreet in code. Laravel verwacht een bepaalde structuur, dus afwijken heeft gevolgen. Dit hoofdstuk toont de keuzes en wat je dan zelf moet instellen.

<!-- slide 20 -->

### Per type of per domein

Wissel tussen de twee en kijk waar de oranje bestanden van Offertes terechtkomen.

Dezelfde bestanden, per type:

- app/Models: Quote, QuoteLine, SiteSurvey, Installation, Invoice
- app/Actions: SendQuoteAction, AcceptQuoteAction, PlanInstallationAction, CreateDepositInvoiceAction
- app/Enums: QuoteStatus, InvoiceType
- app/Events: QuoteAccepted

En per domein:

- app/Domain/Quotes: Models/Quote, Models/QuoteLine, Actions/SendQuoteAction, Actions/AcceptQuoteAction, Enums/QuoteStatus, Events/QuoteAccepted
- app/Domain/Planning: Models/SiteSurvey, Models/Installation, Actions/PlanInstallationAction
- app/Domain/Billing: Models/Invoice, Actions/CreateDepositInvoiceAction, Enums/InvoiceType

> **Toelichting:** Let op de actions: per type staan ze in één map en zie je niet welke bij welk domein hoort. Per domein zie je dat Planning er één heeft en Offertes er twee. In een echt project met veertig actions is dat het verschil tussen zoeken en vinden.

<!-- slide 21 -->

### Waarom per domein

- Per type, zoals een verse Laravel-app het doet, open je vier mappen om te weten wat er met offertes gebeurt.
- Per domein staat alles van offertes bij elkaar. Je ziet meteen wat bij facturatie hoort, en wat niet.
- Werkt een teamgenoot aan planning en jij aan offertes, dan zitten jullie in verschillende mappen. Minder merge conflicts.
- De prijs: je wijkt af van wat Laravel verwacht, dus een paar dingen moet je zelf instellen. Dat zie je zo meteen.

> **Toelichting:** Je kan ook half overschakelen: de modellen in app/Models laten en alleen actions per onderwerp groeperen, zoals app/Actions/Quotes. Veel teams doen dat, en het vraagt geen extra configuratie. Dat stelde de les over architectuur ook voor.

<!-- slide 22 -->

### Twee lagen

- **Domain**: Wat de zaak doet. Modellen, actions, data objects, enums, events, per domein.
- **Application**: Hoe je erbij komt. Controllers, Form Requests, API resources, artisan commands.

De application layer mag het domein gebruiken, niet omgekeerd. Een action in `Domain/Quotes` weet niet dat er een controller bestaat. Het idee komt uit [Laravel Beyond CRUD](https://laravel-beyond-crud.com/) van Spatie.

- **[Application architecture](/nl/the-agency/06-architectuur)**: Actions, Form Requests en dunne controllers
- **[Laravel Beyond CRUD](https://laravel-beyond-crud.com/)**: Brent Roose en Freek Van der Herten

> **Toelichting:** In Laravel Beyond CRUD staat de application layer in een eigen map, met een deel per soort gebruiker, zoals een admin en een klantenportaal. Voor een Agency-project volstaat app/Http van Laravel als application layer.

<!-- slide 23 -->

### Waar de map komt

#### app/Domain

```php
namespace App\Domain\Quotes\Actions;
```

Valt onder de namespace App, dus Composer vindt alles zonder extra configuratie. Voor een Agency-project raad ik dit aan: niemand in je team moet iets aanpassen.

#### src/Domain

*composer.json*

```json
"psr-4": {
    "App\\": "app/",
    "Domain\\": "src/Domain/"
}
```

Zo doet Beyond CRUD het: een eigen namespace, netter, maar je moet de autoload aanpassen en `composer dump-autoload` draaien.

> **Toelichting:** Na een wijziging aan composer.json moet iedereen in het team composer dump-autoload draaien, en je deploy ook. Vergeten geeft een Class not found die maar op sommige machines optreedt. Daarom app/Domain voor een project van twaalf weken.

<!-- slide 24 -->

### Wat er in een domeinmap zit

| Map | Wat erin zit | In app/Domain/Quotes |
| --- | --- | --- |
| `Actions` | Eén use case per klasse | SendQuoteAction, AcceptQuoteAction |
| `Data` | DTOs | QuoteData, QuoteLineData |
| `Models` | Eloquent | Quote, QuoteLine |
| `Enums` | States en vaste lijsten | QuoteStatus, VatRate |
| `Events` | Wat er gebeurd is | QuoteAccepted |
| `Exceptions` | Wat niet mag | InvalidQuoteTransition |

> **Toelichting:** Niet elk domein heeft elke map nodig. Maak een map pas als er iets in moet. Een lege map Exceptions omdat ze in een tutorial stond, maakt de structuur alleen langer.

<!-- slide 25 -->

### Factories in een domeinmap

*app/Domain/Quotes/Models/Quote.php*

```php
#[UseFactory(QuoteFactory::class)]
class Quote extends Model
{
    use HasFactory;
}
```

`Quote::factory()` zoekt de factory op basis van de namespace van het model. Staat het model niet meer in `app/Models`, dan zoekt Laravel in een namespace die niet bestaat.

Het `UseFactory`-attribuut wijst de factory aan. De factories zelf laat je gewoon in `database/factories`. Zie de [docs over factories](https://laravel.com/docs/13.x/eloquent-factories).

> **Toelichting:** make:model zet een model in app/Models. Voor een domeinmap verhuis je het bestand daarna en pas je de namespace aan. Vergeet dan ook de use-statements in je controllers, seeders en tests niet.

<!-- slide 26 -->

### Listeners en policies

*bootstrap/app.php*

```php
->withEvents(discover: [
    __DIR__.'/../app/Domain/*/Listeners',
])
```

Listeners zoekt Laravel standaard in `app/Listeners`. Met de wildcard vindt hij ze in elk domein. Zie [events](https://laravel.com/docs/13.x/events) in de docs.

Policies vindt Laravel wel zelf, zolang ze in een map `Policies` staan op of boven de map met je modellen: `app/Domain/Quotes/Policies` werkt. Zie [policy discovery](https://laravel.com/docs/13.x/authorization#policy-discovery).

> **Toelichting:** Met php artisan event:list zie je welke listeners Laravel gevonden heeft. Staat je listener er niet in, dan klopt het pad in withEvents niet, of heeft de handle-methode geen getypeerd event als parameter.

<!-- slide 27 -->

### Je verhuist Quote naar app/Domain/Quotes/Models. Wat moet je zeker aanpassen?

- A. De migrations, want de tabel heet nu anders
- B. De factory koppelen met #\[UseFactory\]
- C. Elke relatie opnieuw definiëren
- D. Niets, Laravel vindt alles vanzelf

**Antwoord:** B. De factory koppelen met #\[UseFactory\]

Quote::factory() zoekt een factory in een namespace die niet bestaat, tot je hem aanwijst. De migrations lijken logisch omdat er iets verhuist, maar de tabelnaam komt van de klassenaam Quote, niet van de map: de tabel blijft quotes. Relaties verwijzen naar klassen, dus die werken zodra je de use-statements aanpast.

> **Toelichting:** Gebruik je polymorphic relations, check dan ook je morph map: zonder morph map staat de volledige klassenaam in de database, en een nieuwe namespace breekt bestaande rijen. Zie de les over Eloquent modellen.

<!-- slide 28 -->

## 5. Value objects en DTOs

> **Toelichting:** Value objects zijn het tactische patroon dat het snelst iets oplevert in een Laravel-project. Geld, btw-nummers en adressen zitten in bijna elk klantproject, en ze hebben allemaal regels.

<!-- slide 29 -->

### Entity en value object

#### Entity

- Heeft een identiteit: offerte 2026-014.
- Blijft dezelfde als de gegevens veranderen.
- Bij ons: een Eloquent model met een id.
- Twee klanten die Jan Peeters heten, zijn niet dezelfde klant.

#### Value object

- Heeft geen id, alleen een waarde: € 1.250,00.
- Onveranderlijk. Een ander bedrag is een nieuw object.
- Controleert zichzelf bij het aanmaken.
- Twee biljetten van twintig euro zijn inwisselbaar. Dat is een value object.

> **Toelichting:** De test voor een value object: als je twee exemplaren met dezelfde waarde verwisselt, merkt iemand het? Bij twee biljetten van twintig euro niet, bij twee klanten wel. Merkt niemand het, dan is het een value object.

<!-- slide 30 -->

### Money

*app/Domain/Billing/ValueObjects/Money.php*

```php
final readonly class Money
{
    public function __construct(
        public int $cents,
    ) {}

    public function add(Money $other): self
    {
        return new self($this->cents + $other->cents);
    }
}
```

Bedragen in cent als integer, nooit als float: in PHP is `0.1 + 0.2` geen 0.3. `readonly` maakt het onveranderlijk, dus `add()` geeft een nieuw object terug.

> **Toelichting:** Packages als brick/money en moneyphp/money doen dit volledig, met meerdere munten en afrondingsregels. Voor één munt en een paar bewerkingen volstaat een eigen klasse.

<!-- slide 31 -->

### De btw als enum

*app/Domain/Quotes/Enums/VatRate.php*

```php
enum VatRate: int
{
    case Renovation = 6;
    case Standard = 21;

    public function apply(Money $net): Money
    {
        return new Money((int) round($net->cents * (100 + $this->value) / 100));
    }
}
```

Er bestaan maar twee tarieven in deze app, dus een btw van 19% kan niet meer per ongeluk. De berekening staat op één plek. Werk je met meerdere munten, kijk dan naar een package als brick/money.

> **Toelichting:** De Belgische btw kent meer tarieven dan deze twee, en voorwaarden voor renovatie. Voor Janssens volstaan er twee. Voeg je later een case toe, dan geeft elke match zonder die case een UnhandledMatchError, en vind je snel waar je nog iets moet aanpassen.

<!-- slide 32 -->

### Een btw-nummer

*app/Domain/Billing/ValueObjects/VatNumber.php*

```php
final readonly class VatNumber
{
    public function __construct(
        public string $value,
    ) {
        if (! preg_match('/^BE[01]\d{9}$/', $value)) {
            throw new InvalidArgumentException("{$value} is geen Belgisch btw-nummer.");
        }
    }
}
```

- Een VatNumber heeft één waarde, de string.
- Klopt het formaat niet, dan bestaat het object niet, en hoeft niemand het nog te checken.

> **Toelichting:** Dit controleert alleen het formaat. Of het nummer echt bestaat en actief is, check je via VIES, de Europese databank voor btw-nummers. Dat is een externe call, dus hoort die niet in de constructor maar in een service of een validatieregel.

<!-- slide 33 -->

### Een cast

*app/Domain/Billing/Casts/AsMoney.php*

```php
class AsMoney implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money((int) $value);
    }
    public function set(Model $model, string $key, mixed $value, array $attributes): int
    {
        return $value->cents;
    }
}
```

Met `'total' => AsMoney::class` in `casts()` krijg je op het model een Money, en blijft het in de database een integer. Zie [custom casts](https://laravel.com/docs/13.x/eloquent-mutators#custom-casts).

> **Toelichting:** Een value object kan ook zelf Castable implementeren, met een static castUsing(). Dan schrijf je in casts() 'total' => Money::class. Beide werken; de aparte cast-klasse is makkelijker te lezen als je het voor het eerst ziet.

<!-- slide 34 -->

### DTOs met laravel-data

*app/Domain/Quotes/Data/QuoteData.php*

```php
class QuoteData extends Data
{
    public function __construct(
        public string $email,
        public VatRate $vatRate,
        #[DataCollectionOf(QuoteLineData::class)]
        public array $lines,
    ) {}
}

$data = QuoteData::from($request);
```

[laravel-data](https://spatie.be/docs/laravel-data) maakt de DTO uit een request, een array of een model. De enum en de geneste lijnen zet het zelf om.

> **Toelichting:** laravel-data kan ook valideren: gebruik je QuoteData als parameter van een controller, dan valideert het package de request op basis van de types. Dat overlapt met Form Requests. Kies per project één aanpak, niet allebei door elkaar.

<!-- slide 35 -->

### DTO of value object

|   | DTO | Value object |
| --- | --- | --- |
| Waarvoor | Data van de ene laag naar de andere brengen | Een begrip uit het domein |
| Regels | Geen, hooguit validatie van input | Ja, in de constructor en de methodes |
| Leeft | Even, tussen request en action | Overal, ook op het model via een cast |
| Bij Janssens | `QuoteData` | `Money, VatNumber` |

> **Toelichting:** Een DTO kan value objects bevatten: QuoteData met een VatRate en een lijst bedragen als Money. Zo komt de data al gecontroleerd binnen in de action.

<!-- slide 36 -->

### Een adres van een werf: straat, nummer, postcode, gemeente. Wat is het?

- A. Een entity
- B. Een value object
- C. Een DTO
- D. Een aggregate root

**Antwoord:** B. Een value object

Twee werven op hetzelfde adres hebben hetzelfde adres: het heeft geen eigen identiteit, en een adres dat verandert is een nieuw adres. Een DTO is verleidelijk omdat het ook gewoon een paar velden zijn, maar een adres is een begrip uit het domein dat op het model blijft, niet iets dat even data tussen twee lagen draagt. Laravel gebruikt dit voorbeeld in de docs over custom casts.

> **Toelichting:** Een adres kan wel deel zijn van een entity, zoals een klant. Het adres zelf blijft een value object. Verhuist de klant, dan krijgt hij een nieuw adres; het oude adres verandert niet.

<!-- slide 37 -->

## 6. States en events

> **Toelichting:** Statussen zijn in bijna elk klantproject de plek waar bugs zitten: een offerte die verstuurd en vervallen tegelijk is, of een factuur die betaald wordt na een annulering. Dit hoofdstuk maakt de regels expliciet in de code.

<!-- slide 38 -->

### De status als enum

- *QuoteStatus.php*

  ```php
  enum QuoteStatus: string
  {
      case Draft = 'draft';
      case Sent = 'sent';
      case Accepted = 'accepted';
      case Rejected = 'rejected';
      case Expired = 'expired';
  }
  ```
- *Quote.php*

  ```php
  protected function casts(): array
  {
      return [
          'status' => QuoteStatus::class,
      ];
  }
  ```

Een backed enum met strings: in de database lees je `draft`, niet 1. Met de cast is `$quote->status` een QuoteStatus. Zie [enum casting](https://laravel.com/docs/13.x/eloquent-mutators#enum-casting).

> **Toelichting:** Een enum kan ook methodes krijgen voor de UI, zoals label() die Verstuurd teruggeeft voor Sent. Zo blijft de vertaling van een status bij de status zelf.

<!-- slide 39 -->

### Overgangen in de enum

*app/Domain/Quotes/Enums/QuoteStatus.php*

```php
public function canTransitionTo(self $status): bool
{
    $allowed = match ($this) {
        self::Draft, self::Expired => [self::Sent],
        self::Sent => [self::Accepted, self::Rejected, self::Expired],
        self::Accepted, self::Rejected => [],
    };

    return in_array($status, $allowed, true);
}
```

- Per status de lijst van statussen waar je naartoe mag. Een vervallen offerte mag opnieuw verstuurd worden.
- Staat de nieuwe status in die lijst? Goedgekeurd en geweigerd hebben een lege lijst: daar kom je niet meer uit.

> **Toelichting:** Het derde argument true van in_array vergelijkt strikt. Bij enums is dat veilig: elke case bestaat maar één keer, dus dezelfde case is ook identiek. En match gooit een UnhandledMatchError als je een nieuwe case vergeet, zo mis je er geen.

<!-- slide 40 -->

### Welke overgang mag

Klik de acties in een volgorde die jij kiest. Kijk welke een exception geven, en probeer Goedkeuren vanuit Concept.

Statusdiagram van een offerte, start in Concept. Elke andere overgang gooit een exception.

- Versturen: van Concept of Vervallen naar Verstuurd
- Goedkeuren: van Verstuurd naar Goedgekeurd
- Weigeren: van Verstuurd naar Geweigerd
- Laten vervallen: van Verstuurd naar Vervallen

> **Toelichting:** Een statusdiagram tekenen samen met de klant is een van de nuttigste dingen die je in week één kan doen. Klanten zien meteen welke overgang ontbreekt, zoals een geweigerde offerte die toch opnieuw verstuurd moet kunnen worden. Teken het voor een status in je eigen project.

<!-- slide 41 -->

### De overgang bewaken

*app/Domain/Quotes/Models/Quote.php*

```php
public function transitionTo(QuoteStatus $status): void
{
    if (! $this->status->canTransitionTo($status)) {
        throw InvalidQuoteTransition::from($this->status, $status);
    }

    $this->update(['status' => $status]);
}
```

- Mag de overgang niet, dan een exception. Een verkeerde klik in de UI zet geen geweigerde offerte op goedgekeurd.
- Mag ze wel, dan pas de update. Nergens anders in de app staat nog een update van status.

Heeft elke status eigen gedrag, dan is [laravel-model-states](https://spatie.be/docs/laravel-model-states) de volgende stap.

> **Toelichting:** InvalidQuoteTransition::from() is een named constructor op een eigen exception, zodat de boodschap overal hetzelfde is. Vang de exception op met withExceptions in bootstrap/app.php, en toon de gebruiker een nette melding in plaats van een 500.

<!-- slide 42 -->

### Domain events

- *AcceptQuoteAction.php*

  ```php
  public function execute(Quote $quote): Quote
  {
      $quote->transitionTo(QuoteStatus::Accepted);

      QuoteAccepted::dispatch($quote);

      return $quote;
  }
  ```
- *QuoteAccepted.php*

  ```php
  class QuoteAccepted implements
      ShouldDispatchAfterCommit
  {
      use Dispatchable;
      use SerializesModels;

      public function __construct(
          public Quote $quote,
      ) {}
  }
  ```

Een domain event zegt wat er gebeurd is, dus verleden tijd: QuoteAccepted. `ShouldDispatchAfterCommit` laat het pas vertrekken als de transaction gelukt is.

> **Toelichting:** Zonder ShouldQueue op de listener draait alles in dezelfde request, na elkaar. Met ShouldQueue gaat elke listener naar de queue en wacht de gebruiker er niet op.

<!-- slide 43 -->

### Andere contexts luisteren

*app/Domain/Billing/Listeners/CreateDepositInvoiceListener.php*

```php
class CreateDepositInvoiceListener
{
    public function __construct(
        private CreateDepositInvoiceAction $createDepositInvoice,
    ) {}

    public function handle(QuoteAccepted $event): void
    {
        $this->createDepositInvoice->execute($event->quote);
    }
}
```

Facturatie luistert, Planning krijgt een eigen listener. Offertes weet van geen van beide, en een vierde context verandert niets aan de offerte.

> **Toelichting:** De listener doet zelf niets: hij roept een action aan. Zo kan de boekhouding een voorschotfactuur ook met de hand maken via dezelfde action. Listeners dun houden werkt om dezelfde reden als controllers dun houden.

<!-- slide 44 -->

### Rechtstreeks of via een event

#### Rechtstreeks

- In de les over architectuur riep AcceptQuoteAction zelf CreateDepositInvoiceAction aan.
- Simpel, en je leest in één bestand wat er gebeurt.
- Maar Offertes moet weten dat Facturatie bestaat. Elke nieuwe reactie betekent de action aanpassen.

#### Via een event

- De action zegt alleen wat er gebeurd is.
- Elke context beslist zelf wat hij ermee doet.
- Een listener kan op de queue, zie de [les over queues](/nl/the-agency/13-queues). Wel moeilijker te volgen: je ziet niet meer in één bestand wat er allemaal gebeurt.

> **Toelichting:** Events maken code losser, maar ook moeilijker te volgen. php artisan event:list helpt, en goede namen ook. In een klein project met één reactie is de rechtstreekse aanroep vaak de betere keuze.

<!-- slide 45 -->

### Aggregates, light

*app/Domain/Quotes/Models/Quote.php*

```php
public function addLine(string $description, Money $price): QuoteLine
{
    if ($this->status !== QuoteStatus::Draft) {
        throw new QuoteIsLocked($this);
    }
    $line = $this->lines()->create(['description' => $description, 'price' => $price]);
    $this->recalculateTotal();
    return $line;
}
```

- Een verstuurde offerte is vergrendeld: de klant heeft ze al gezien.
- De lijn wordt aangemaakt via de offerte, niet los.
- Het totaal klopt altijd. De afspraak: nergens staat `QuoteLine::create()`.

> **Toelichting:** QuoteIsLocked is een eigen exception. Dezelfde regel kan ook in een policy, zodat de knop in de UI verdwijnt. Doe allebei: de policy voor de UI, de check in het model voor elke andere weg naar de database.

<!-- slide 46 -->

### Wat een aggregate is

Een aggregate is een groep objecten die je als één geheel verandert, zoals een offerte met haar lijnen. De offerte is de root: alles gaat via haar, zodat haar regels altijd gelden.

Het volledige verhaal in het boek is streng, met regels over transacties en over welke objecten elkaar mogen kennen. Wat je ervan meeneemt in een Laravel-project: kies per groep één toegang, en schrijf die afspraak op. Meer bij [Martin Fowler](https://martinfowler.com/bliki/DDD_Aggregate.html).

> **Toelichting:** In Eloquent kan je een aggregate niet afdwingen: QuoteLine::create() blijft technisch mogelijk. Het blijft een afspraak. Een architectuurtest of een code review die erop let, helpt ze te bewaken.

<!-- slide 47 -->

### Na goedkeuring moet Planning een opmeting inplannen. Hoe weet Planning dat?

- A. `AcceptQuoteAction` schrijft rechtstreeks in de tabel `site_surveys`
- B. Een listener in Planning luistert naar `QuoteAccepted`
- C. Een cronjob die elke minuut kijkt welke offertes goedgekeurd zijn
- D. De controller roept na de goedkeuring ook Planning aan

**Antwoord:** B. Een listener in Planning luistert naar `QuoteAccepted`

Offertes zegt wat er gebeurd is, Planning beslist zelf wat het ermee doet. Rechtstreeks in site_surveys schrijven is verleidelijk omdat het één regel is, maar dan zitten de regels van Planning in Offertes. En de controller is HTTP: via een import of een API zou de opmeting dan nooit ingepland worden.

> **Toelichting:** Een cronjob werkt technisch, maar voegt tot een minuut vertraging toe en moet bijhouden welke offertes hij al gezien heeft. Scheduling is goed voor taken op een tijdstip, niet om te reageren op iets dat net gebeurd is. Zie de les over scheduling.

<!-- slide 48 -->

## 7. Wanneer het te veel is

> **Toelichting:** Voor je Agency-project is dit het belangrijkste hoofdstuk. DDD kan veel tijd kosten zonder dat de klant er iets van merkt. Hier leer je kiezen wat je meeneemt.

<!-- slide 49 -->

**Twaalf weken, vier studenten, één klant.**

Bouw geen architectuur voor een bedrijf met tweehonderd developers als je er vier bent en de deadline in december ligt.

> **Toelichting:** Architectuur is een investering: je betaalt nu in tijd, en krijgt het later terug in minder bugs en makkelijker onderhoud. In twaalf weken is later kort. Kies de onderdelen die snel terugverdienen.

<!-- slide 50 -->

### DDD of niet

| Ga voor domeinmappen als | Blijf bij de Laravel-structuur als |
| --- | --- |
| De klant regels heeft die je niet zelf zou verzinnen | Het vooral formulieren en lijsten zijn |
| Je drie of meer duidelijke contexts ziet | Alles over één ding gaat |
| Het project na de twaalf weken verder leeft | Het een prototype of een campagnesite is |
| Meerdere teams in dezelfde code werken | Je met twee aan de backend werkt |

Eerlijk: de meeste Agency-projecten zijn een formulier, een lijst en een dashboard. Stel: een team tekent drie weken mappen en contexts, en heeft in week tien nog geen demo. Dat project faalt, hoe mooi de structuur ook is.

> **Toelichting:** Je kan ook later overschakelen. Begin met de Laravel-structuur en actions per onderwerp. Leeft het project na de twaalf weken verder, dan is verhuizen naar domeinmappen een refactor van een paar uur.

<!-- slide 51 -->

### Wat je altijd meeneemt

#### Altijd

- De woorden van de klant, en een woordenlijst in de README.
- Actions met een naam uit het domein.
- Een enum voor elke status.
- Bedragen in cent, of als Money.

#### Alleen als het domein erom vraagt

- Een map per domein.
- Domain events tussen contexts.
- Value objects voor alles wat regels heeft.
- Aggregates met een vaste toegang.

> **Toelichting:** De linkerkolom kost bijna niets: een README, goede namen, een enum. De rechterkolom kost tijd bij elke nieuwe feature. Neem uit rechts alleen wat een concreet probleem in je project oplost.

<!-- slide 52 -->

### Jullie bouwen in twaalf weken een reservatietool voor een jeugdhuis: zalen, datums, een bevestigingsmail. Wat doe je?

- A. Volledige DDD: domeinmappen, events en aggregates
- B. De Laravel-structuur, met actions, enums en de woorden van de klant
- C. Eerst microservices per context
- D. Geen structuur, het is maar twaalf weken

**Antwoord:** B. De Laravel-structuur, met actions, enums en de woorden van de klant

Eén context, weinig regels. De taal van de klant en een paar actions geven het meeste voordeel voor de minste tijd. Volledige DDD is verleidelijk omdat het professioneel oogt, maar je betaalt de opzet zonder regels die hem terugverdienen. En geen structuur is de andere valkuil: ook een klein project krijgt een tweede developer of een tweede fase.

> **Toelichting:** Het jeugdhuis heeft misschien toch één regel die een enum met overgangen verdient: een reservatie die bevestigd, geannuleerd of verlopen is. Dat is een klein stukje tactische DDD dat ook in een klein project snel loont.

<!-- slide 53 -->

### Opdracht: Breng het domein van je klant in kaart

Voor je eigen Agency-project, met je team.

1. Schrijf een woordenlijst in de README: minstens tien woorden van de klant, met de Engelse naam in de code (slide 10 tot 13)
2. Duid de contexts aan en zet bij elk model in welke context het hoort (slide 16 tot 18)
3. Maak van één status een enum met canTransitionTo, en laat het model een ongeldige overgang weigeren (slide 38 tot 41)
4. Klaar als je klant of coach de woordenlijst leest zonder vragen, en een Pest-test bewijst dat een ongeldige overgang een exception gooit
5. Vast? Enum casting en custom casts staan in de Eloquent docs, de begrippen in de DDD Reference van Eric Evans

> **Toelichting:** Begin met de mails en notities van je klant: onderstreep elk zelfstandig naamwoord dat over hun zaak gaat. Dat is je eerste woordenlijst. Twijfel je over een woord, stuur de klant één vraag: wat bedoelen jullie precies met dit woord?

## Begrippen

- **Domain-driven design**: Een manier van software bouwen uit het boek van Eric Evans (2003): de code volgt de zaak van de klant en de woorden die hij gebruikt. Geen package en geen mappenstructuur. (slide 6)
- **Domein**: De zaak van je klant, waar de software over gaat. Bij Janssens: keukens opmeten, offertes maken, plaatsen en factureren. (slide 6)
- **Strategisch DDD**: Het deel van DDD over taal en grenzen: ubiquitous language en bounded contexts. Loont in bijna elk project, ook een klein. (slide 7)
- **Tactisch DDD**: Het deel van DDD over patronen in de code: entities, value objects, aggregates, domain events. Neem wat helpt, laat de rest liggen. (slide 7)
- **Bounded context**: Een grens waarbinnen een woord één betekenis heeft. Klant betekent iets anders in Verkoop dan in Facturatie. In één Laravel-app is een context een map met zijn eigen modellen en regels. (slide 17)
- **Domeinmap**: Een map per domein, zoals app/Domain/Quotes, met daarin de modellen, actions, enums en events van dat domein samen, in plaats van verspreid over app/Models en app/Actions. (slide 20)
- **Application layer**: Alles wat een domein bereikbaar maakt: controllers, Form Requests, resources, commands. Kent het domein, het domein kent hem niet. (slide 22)
- **Entity**: Een object met een identiteit die blijft als zijn gegevens veranderen. Offerte 2026-014 blijft dezelfde offerte, ook als het totaal wijzigt. Bij ons meestal een Eloquent model. (slide 29)
- **Value object**: Een klein onveranderlijk object dat gelijk is op waarde, zoals een bedrag of een btw-nummer. Controleert zichzelf in de constructor, dus een ongeldig btw-nummer bestaat gewoon niet. (slide 29)
- **Custom cast**: Een klasse met get() en set() die een kolom omzet naar een value object en terug. Met de cast in casts() krijg je op het model een Money in plaats van een integer. (slide 33)
- **laravel-data**: Een package van Spatie voor data objects. Maakt een DTO uit een request of array met ::from(), met validatie en geneste objecten erbij. (slide 34)
- **State**: De toestand waarin een model zit, zoals Concept of Verstuurd, met vaste regels over welke overgang mag. In Laravel meestal een backed enum met een cast. (slide 38)
- **State machine**: Een model met een beperkt aantal states en vaste overgangen ertussen. Een overgang die niet mag, gooit een exception in plaats van stil de database aan te passen. (slide 40)
- **Domain event**: Een event dat zegt wat er in het domein gebeurd is, in de verleden tijd: QuoteAccepted. Andere contexts luisteren ernaar zonder dat de offerte van hen weet. (slide 42)
- **ShouldDispatchAfterCommit**: Een interface op een event: het event vertrekt pas als de database transaction gecommit is. Rolt de transaction terug, dan vertrekt het niet. (slide 42)
- **Aggregate**: Een groep objecten die je als één geheel verandert, zoals een offerte met haar lijnen. Alles gaat via de root, de offerte, zodat het totaal altijd klopt. (slide 45)
- **Aggregate root**: Het object waarlangs je een aggregate aanspreekt. Niemand maakt een QuoteLine aan buiten Quote::addLine() om. (slide 45)
- **Laravel Beyond CRUD**: Een boek en cursus van Spatie over grote Laravel-projecten, met domeinmappen, actions, data objects en states. De bekendste vertaling van DDD naar Laravel. (slide 22)

## Links en bronnen

### In de presentatie

- [Domain-Driven Design Reference](https://www.domainlanguage.com/ddd/) (slide 6): Eric Evans, de bron van de termen
- [Ubiquitous Language](https://martinfowler.com/bliki/UbiquitousLanguage.html) (slide 11): Martin Fowler
- [Bounded Context](https://martinfowler.com/bliki/BoundedContext.html) (slide 17): Martin Fowler, met Customer en Product als voorbeeld
- [Laravel Beyond CRUD](https://laravel-beyond-crud.com/) (slide 22): Brent Roose en Freek Van der Herten, domeinmappen in Laravel
- [Factories](https://laravel.com/docs/13.x/eloquent-factories) (slide 25): Het UseFactory-attribuut
- [Events](https://laravel.com/docs/13.x/events) (slide 26): ShouldDispatchAfterCommit en withEvents(discover:)
- [Policy discovery](https://laravel.com/docs/13.x/authorization#policy-discovery) (slide 26): Een Policies-map op of boven de map met modellen
- [Custom casts](https://laravel.com/docs/13.x/eloquent-mutators#custom-casts) (slide 33): Value objects casten met CastsAttributes
- [laravel-data](https://spatie.be/docs/laravel-data) (slide 34): Data objects van Spatie, met DataCollectionOf
- [Enum casting](https://laravel.com/docs/13.x/eloquent-mutators#enum-casting) (slide 38): Een backed enum in casts()
- [laravel-model-states](https://spatie.be/docs/laravel-model-states) (slide 41): States als klassen, als een enum niet meer volstaat
- [DDD Aggregate](https://martinfowler.com/bliki/DDD_Aggregate.html) (slide 46): Een cluster objecten die je als één geheel behandelt

### Bronnen

- [Value Object](https://martinfowler.com/bliki/ValueObject.html): Gelijk op waarde, onveranderlijk
- [PHP enumerations](https://www.php.net/manual/en/language.enumerations.php): Backed enums met methodes
