Laat EF Core je domein niet modelleren
Ik ga meteen met de deur in huis vallen:
De belangrijkste reden om automatisch voor EF Core te kiezen, is door AI een stuk minder overtuigend geworden.
Jarenlang was het argument simpel.
Met EF Core schrijf je minder SQL, minder mappingcode en minder repetitieve persistencecode. Je bouwt dus sneller dan met ADO.NET of een micro-ORM zoals Dapper.
Dat argument was terecht.
Alleen is de prijs van die expliciete code intussen drastisch gedaald. Een coding agent kan vandaag in veel gevallen een query, een persistence model, de mapping naar je domein en de bijbehorende tests genereren.
De prijs van impliciet gedrag is niet op dezelfde manier gedaald.
Change tracking, onverwachte queries, complexe mappings en domeinobjecten die zich moeten plooien naar hun opslagmechanisme moeten nog altijd door een mens begrepen worden.
Daarom denk ik dat we een oude keuze opnieuw moeten bekijken:
Als boilerplate goedkoop wordt, waarom laten we onze architectuur dan nog bepalen door het vermijden van boilerplate?
Dit artikel is geïnspireerd door Zoran Horvats video Coding Style vs EF Core & Dapper: Pick a Side, waarin hij laat zien hoe de keuze voor persistence doorwerkt in de manier waarop we ons domein modelleren.
Wat EF Core ons geeft
EF Core lost echte problemen op.
Het vertaalt LINQ naar SQL, houdt wijzigingen aan objecten bij, beheert relaties, maakt migrations en biedt één geïntegreerd model voor lezen en schrijven. Voor veel CRUD-applicaties is dat bijzonder productief.
Maar die productiviteit is niet gratis.
EF Core werkt het natuurlijkst wanneer een object gedurende zijn levensduur dezelfde instantie blijft en zijn properties wijzigen. De change tracker bewaart immers een entiteit, onthoudt haar oorspronkelijke waarden en detecteert welke properties aangepast zijn.
Dat is een prima model voor persistence.
Het is niet noodzakelijk een goed model voor je domein.
Wanneer je domeinmodel een EF-model wordt
Een domeinmodel hoort geldige business states en geldige transities uit te drukken.
Neem een overschrijving die goedkeuring door twee verschillende medewerkers vereist. Ze kan pending, gedeeltelijk goedgekeurd, volledig goedgekeurd, afgewezen, vervallen of uitgevoerd zijn.
In een mutable model komen al die toestanden vaak in één entiteit terecht:
public class Transfer
{
public Approval Approval { get; private set; }
public bool IsExpired { get; private set; }
public bool IsExecuted => _executedAt is not null;
public bool CanExecute =>
Approval.Approvers.Length == 2 &&
!IsExpired &&
!IsExecuted;
}
De mogelijke toestand van Transfer zit nu verspreid over properties, nullable velden en afgeleide booleans. Of een operatie geldig is, ontdekken we tijdens runtime via CanExecute, een nullable interface of een exception.
Dat lijkt onschuldig, tot twee stukjes logica elk een ander deel van de toestand bekijken.
Stel dat de twee approvers aanwezig blijven nadat de approval wordt afgewezen. Wanneer CanExecute alleen de approvers telt en de afwijzing niet controleert, kan een afgewezen transfer nog altijd worden uitgevoerd.
Dat is geen domme fout. Het is precies het soort fout dat ontstaat wanneer ongeldige combinaties technisch voorstelbaar zijn.
Maak ongeldige toestanden UNREPRESENTABLE
Een immutable model kan dezelfde workflow anders uitdrukken:
public class PendingTransfer : Transfer
{
public Transfer WithApprover(EmployeeId employeeId) =>
WithApproval(Approval.TryApprove(employeeId));
}
public class ApprovedTransfer : Transfer
{
public ExecutedTransfer Execute(TransferTimestamp at) =>
new(Id, Core, Approval, at);
}
public class RejectedTransfer : Transfer;
public class ExecutedTransfer : Transfer;
Een actie muteert hier niet dezelfde entiteit. Ze levert een nieuwe, geldige toestand op.
Een RejectedTransfer heeft geen Execute-methode. Een PendingTransfer kan niet per ongeluk beweren dat hij al uitgevoerd is. De beschikbare operaties volgen uit het type, niet uit een reeks controles die iedere caller correct moet interpreteren.
De compiler neemt een deel van het denkwerk over.
Dat model is niet automatisch perfect. De precieze types en grenzen blijven ontwerpkeuzes. Maar het drukt de businessregels wel veel directer uit dan één object waarin iedere denkbare combinatie van flags kan ontstaan.
En net daar begint de wrijving met een ORM.
Nee, EF Core verplicht je niet tot publieke setters
Hier is nuance belangrijk.
EF Core kan werken met parameterized constructors, private setters en backing fields. Een EF-entiteit hoeft dus geen zak properties met publieke setters te zijn. Wie beweert dat immutable modellen simpelweg onmogelijk zijn met EF Core, maakt het te zwart-wit.
Ook immutable value objects zijn geen fundamenteel probleem: EF Core documenteert immutable records en record structs expliciet als mogelijke complex types.
Maar immutable objecten kunnen materialiseren is niet hetzelfde als immutable domeintransities kunnen tracken.
De change tracker botst met immutability
De EF Core change tracker kan out of the box niet overweg met een immutable aggregate waarbij iedere transitie een nieuwe instantie oplevert. Al helemaal niet wanneer die nieuwe toestand een ander CLR-type heeft.
Een DbContext kan maar één entity-instantie per primary key tracken. Stel dat hij een PendingTransfer met een bepaalde Id volgt en de domeinlogica daarna een nieuwe ApprovedTransfer met diezelfde Id teruggeeft. EF Core ziet dan geen gewijzigde transfer. Het ziet een tweede object met een key die al wordt getrackt.
EF Core begrijpt evenmin dat de overgang van PendingTransfer naar ApprovedTransfer één businesstransitie is. Het CLR-type van een getrackte instantie verandert niet. Een nieuwe instantie van een ander subtype attachen, wordt niet automatisch vertaald naar de juiste update van de bestaande rij of discriminator.
Je moet dat zelf oplossen: waarden overzetten naar de getrackte entiteit, instanties expliciet detachen en attachen, een delete/insert uitvoeren, custom persistencecode schrijven of een apart persistence model gebruiken.
Constructors, backing fields, converters, complex types en inheritance mapping helpen bij het materialiseren van objecten. Ze lossen dit fundamentele conflict met change tracking niet op.
De vraag is alleen:
Waarom moet je domeinmodel zoveel weten over de voorkeuren van je persistence tool?
Als een goede businessrepresentatie moeilijk te persisteren is, zie ik teams zelden hun ORM vervangen. Ze maken het domeinmodel meestal wat vlakker, wat mutabeler en wat minder precies.
Een private setter hier. Een extra status-enum daar. Nog een Can...-property om de mogelijke combinaties onder controle te houden.
Tot het domeinmodel eigenlijk een relationeel opslagmodel met methodes is geworden.
Domain model en persistence model zijn niet hetzelfde
De uitweg is conceptueel eenvoudig:
Database
↕ SQL + Dapper
Persistence model
↕ expliciete mapping
Domain model
Je persistence model heeft maar één taak: data betrouwbaar van en naar de database brengen. Het mag records, nullable kolommen en database-identifiers bevatten.
Je domeinmodel heeft een andere taak: betekenis, invarianten en toegestane transities uitdrukken.
Die twee modellen hoeven niet dezelfde classes te zijn.
Je database is geen onderdeel van je domein
Die scheiding geldt onafhankelijk van de persistencetechnologie. Of je nu EF Core, Dapper, een relationele database of een documentdatabase gebruikt: je domeinmodel zou hetzelfde moeten blijven. Als je SQL Server morgen door MongoDB vervangt, veranderen je persistence model en je mapping waarschijnlijk grondig. Transfer, zijn invarianten en zijn toegestane transities zouden daar niets van mogen merken.
Dat geldt ook voor identiteit. Een technische row_id die alleen bestaat omdat een tabel een primary key nodig heeft, is geen domeinconcept. Zo’n identifier hoort thuis in het persistence model en mag niet doorlekken in domeinobjecten, commands of events. Heeft de business zelf een identiteit nodig, zoals een TransferId of een ordernummer, dan hoort die uiteraard wel in het domein. Soms kan dezelfde onderliggende waarde beide rollen vervullen, maar dat moet een bewuste mapping zijn en geen gevolg van wat de database toevallig vereist.
Een DDD-aggregate heeft bovendien van nature iets weg van een document: een aggregate root vormt samen met de objecten erbinnen een consistentiegrens die als geheel wordt geladen en opgeslagen. Daardoor kan een documentdatabase een heel natuurlijke persistence match zijn. Het aggregate hoeft dan niet uit verschillende rijen en tabellen te worden opgebouwd. Dat maakt een documentdatabase niet automatisch de beste keuze, maar het verklaart wel waarom aggregates en documenten vaak goed op elkaar aansluiten.
Dat betekent inderdaad extra code:
internal sealed record TransferRow(
Guid Id,
string Status,
Guid FromAccountId,
Guid ToAccountId,
decimal Amount,
string Currency,
DateTime ExpiresAt,
DateTime? ExecutedAt);
internal static Transfer ToDomain(TransferRow row) =>
row.Status switch
{
"pending" => MapPending(row),
"approved" => MapApproved(row),
"rejected" => MapRejected(row),
"executed" => MapExecuted(row),
_ => throw new DataException($"Unknown transfer status '{row.Status}'.")
};
Vroeger was die mappingcode een serieus productiviteitsargument tegen deze scheiding.
Vandaag laat ik een agent de eerste versie genereren. Ik laat hem tests maken voor iedere status, iedere nullable kolom en iedere onbekende waarde. Daarna review ik de beslissingen die ertoe doen.
Dat is geen hypothetische workflow meer. Microsoft documenteert intussen zelf hoe GitHub Copilot typed Dapper-methoden met geparameteriseerde SQL kan genereren. Dat bewijst niet dat Dapper productiever is dan EF Core, maar wel dat veel van het vroegere handwerk vandaag delegeerbaar is.
De boilerplate blijft bestaan, maar ze is niet langer het dure deel van het werk.
Waarom expliciete code goed werkt met coding agents
Een coding agent kan alleen goed redeneren over wat zichtbaar is.
Expliciete SQL toont welke kolommen worden gelezen, welke joins plaatsvinden en waar een transactie begint. Een expliciete mapper toont hoe database state een domeintype wordt. Een aparte command toont welke wijziging naar de database gaat.
Bij een full-blown ORM zit een groter deel van dat gedrag achter conventies, configuratie en runtime state.
Dat betekent niet dat agents geen EF Core begrijpen. Natuurlijk wel. Maar ook voor een agent geldt: lokale, expliciete code is eenvoudiger te inspecteren dan gedrag dat ontstaat uit de combinatie van LINQ-translatie, mappings, tracking state en provider-specifieke regels.
AI maakt Dapper dus niet magisch beter dan EF Core.
AI verandert de economische afweging:
- expliciete SQL en mapping worden goedkoper om te produceren;
- impliciete complexiteit blijft duur om te begrijpen en te debuggen;
- een precies domeinmodel wordt waardevoller, omdat het zowel mensen als agents betere grenzen geeft.
De productiviteitswinst van EF Core verdwijnt niet. Ze is alleen niet langer voldoende om EF Core zonder nadenken de default te maken.
Zijn full-blown ORM’s dan achterhaald?
Als categorie? Nee.
Als automatische default voor vrijwel iedere .NET-applicatie? Volgens mij wel.
Een ORM is een bundel trade-offs. We krijgen veel infrastructuur en conventies in ruil voor minder expliciete code. Nu AI net die expliciete code goedkoper maakt, verschuift de balans. Niet voor elk project even ver, maar wel ver genoeg om de beslissing opnieuw te nemen.
Dat is mijn eigen architecturale conclusie, geen bewezen wetmatigheid. Er bestaat voor zover ik kon vinden nog geen onderzoek dat EF Core en Dapper vergelijkt terwijl moderne coding agents beide implementaties bouwen en onderhouden.
Betekent dit dat we overal Dapper moeten gebruiken?
Nee.
Dapper is een micro-ORM. Het voert de SQL uit die jij schrijft en mappt resultaten naar objecten. Het biedt geen change tracker, geen LINQ-provider en geen migrationsysteem zoals EF Core.
Dat is tegelijk zijn beperking en zijn aantrekkingskracht.
EF Core blijft een sterke keuze wanneer:
- je applicatie hoofdzakelijk CRUD is;
- je objectmodel dicht bij je relationele model ligt;
- change tracking je echt werk uit handen neemt;
- dynamisch samengestelde queries belangrijk zijn;
- het team de werking en valkuilen van EF Core goed kent.
Ik zou de keuze opnieuw onderzoeken wanneer:
- je een rijk domein met scherpe invarianten modelleert;
- belangrijke toestanden beter als afzonderlijke types worden uitgedrukt;
- je voor writes precies wilt zien welke SQL wordt uitgevoerd;
- je bijna overal
AsNoTrackinggebruikt en updates handmatig stuurt; - EF-configuratie je domeinontwerp begint te dicteren;
- je persistence-constructies uitlegt als businessconcepten.
En er is geen regel die zegt dat één applicatie maar één aanpak mag gebruiken. EF Core voor eenvoudige beheerfunctionaliteit en Dapper voor gerichte reads of rijke aggregates kan een volkomen redelijke combinatie zijn.
De vraag die we te lang niet stelden
We hebben ORM’s lang beoordeeld op hoeveel code ze ons besparen.
In het tijdperk van coding agents vind ik een andere vraag belangrijker:
Welke aanpak laat ons het domein het eerlijkst modelleren en het systeem het eenvoudigst begrijpen?
Soms is het antwoord nog altijd EF Core.
Maar “we ontwikkelen er sneller mee” is niet langer het einde van de discussie. Niet wanneer een agent de ontbrekende SQL, mapping en tests kan schrijven. En al helemaal niet wanneer die snelheid betaald wordt met een zwakker domeinmodel dat jarenlang door mensen én agents begrepen moet worden.
Kies EF Core omdat de eigenschappen van EF Core bij je probleem passen.
Niet omdat een full-blown ORM ooit de vanzelfsprekende keuze was.
AI-ondersteuning gebruikt voor research, taal en structuur; inhoud en standpunt zijn auteurseigen.