Er zijn methodes die bijna niets doen en toch moeilijk te testen zijn.

Niet omdat de businesslogica ingewikkeld is. Niet omdat het algoritme complex is. Maar omdat de methode tegelijkertijd data ophaalt, beslissingen neemt, de huidige tijd gebruikt, iets opslaat, een event publiceert en een logregel schrijft.

Bijvoorbeeld:

public async Task CompleteTrip(Guid tripId)
{
    var trip = await dbContext.Trips.FindAsync(tripId);
    if (trip == null)
        throw new InvalidOperationException("Trip not found.");

    if (trip.Status != TripStatus.Active)
        throw new InvalidOperationException("Trip is not active.");

    trip.EndedAt = DateTime.UtcNow;
    trip.Status = TripStatus.Completed;

    if (trip.Distance > 100)
        trip.RequiresReview = true;

    await dbContext.SaveChangesAsync();

    await eventBus.Publish(
        new TripCompleted(trip.Id, trip.EndedAt, trip.Distance));

    logger.LogInformation("Trip {TripId} completed", trip.Id);
}

Op het eerste gezicht is hier weinig mis mee. De code is leesbaar, de methode is niet bijzonder lang, en de businessregel — trips langer dan honderd kilometer moeten worden gecontroleerd — is eenvoudig.

Toch is deze code moeilijker te begrijpen en te testen dan nodig.

Er zit trouwens nog een tweede probleem in het model: Trip draagt een Status naast een EndedAt en een RequiresReview die pas iets betekenen zodra de trip afgerond is — de vorm die ik eerder uit elkaar trok in illegale toestanden onrepresenteerbaar maken. Deze post laat het model met rust en kijkt alleen naar de methode.

Ik gaf deze refactor laatst uit handen aan Claude Code. Het principe hieronder is oud — de verrassing zat in hoe het werk zich tussen ons verdeelde.


Het probleem herkennen

De methode doet twee fundamenteel verschillende soorten werk.

Aan de ene kant neemt de code beslissingen:

if (trip.Status != TripStatus.Active)
    throw new InvalidOperationException();

if (trip.Distance > 100)
    trip.RequiresReview = true;

Dit is domeinlogica. Gegeven dezelfde invoer verwachten we altijd dezelfde uitkomst.

Aan de andere kant communiceert de methode met de buitenwereld:

await dbContext.Trips.FindAsync(tripId);
DateTime.UtcNow;
await dbContext.SaveChangesAsync();
await eventBus.Publish(...);
logger.LogInformation(...);

Database, tijd, messaging, logging — allemaal effecten.

En zodra beslissingen en effecten door elkaar heen lopen, ontstaat er een probleem. Om één eenvoudige businessregel te testen hebben we ineens een database nodig, of mocks van een database. We moeten de klok beheersen. We moeten controleren of een event gepubliceerd is. We moeten misschien zelfs controleren of SaveChangesAsync() precies één keer is aangeroepen.

De test gaat niet meer alleen over de businessregel. De test gaat over de hele wereld rondom die businessregel.


Functional Core, Imperative Shell

Het principe Functional Core, Imperative Shell stelt voor om deze twee verantwoordelijkheden expliciet van elkaar te scheiden.

De functional core bevat de beslissingen. De imperative shell regelt de interactie met de buitenwereld.

De core probeert zo veel mogelijk te bestaan uit pure functies:

Input → Decision → Output

De shell doet het andere werk:

Load → Call Core → Persist → Publish

De shell is dus niet verdwenen. Databasecalls verdwijnen niet. Messaging verdwijnt niet. Logging verdwijnt niet. De buitenwereld blijft noodzakelijk.

Maar de buitenwereld bepaalt niet langer hoe onze businesslogica eruitziet.


Wat is een pure functie?

Een pure functie heeft twee belangrijke eigenschappen. Ten eerste geeft dezelfde invoer altijd dezelfde uitvoer. Ten tweede heeft de functie geen waarneembare side effects.

Bijvoorbeeld:

public static bool RequiresReview(decimal distance)
{
    return distance > 100;
}

Deze functie gebruikt geen database, leest geen configuratie, vraagt niet om de huidige tijd, schrijft niets weg en publiceert geen events. Daardoor is de functie triviaal te testen:

RequiresReview(50).Should().BeFalse();
RequiresReview(150).Should().BeTrue();

Maar in een echt domeinmodel willen we meestal meer doen dan één boolean berekenen. We willen een beslissing nemen en het resultaat daarvan modelleren.


De functional core

Laten we de businesslogica uit onze oorspronkelijke methode halen. Het Result<T>-type hier is een eenvoudige success-of-failure-wrapper — die uit CSharpFunctionalExtensions, of je eigen versie; het principe hangt daar niet van af.

public static Result<TripCompletion> Complete(Trip trip, DateTime completedAt)
{
    if (trip.Status != TripStatus.Active)
        return Result.Failure<TripCompletion>("Trip is not active.");

    var completedTrip = trip.Complete(
        completedAt,
        requiresReview: trip.Distance > 100);

    var domainEvent = new TripCompleted(
        completedTrip.Id,
        completedAt,
        completedTrip.Distance);

    return Result.Success(new TripCompletion(completedTrip, domainEvent));
}

Deze functie weet niets van Entity Framework, niets van Azure Service Bus, niets van logging. Zelfs de huidige tijd wordt als invoer meegegeven.

Gegeven dezelfde Trip en dezelfde completedAt krijgen we altijd hetzelfde resultaat. Dat is onze functional core.

Let op wát hij teruggeeft. Geen boolean die zegt dat de trip afgerond mag worden, maar de afgeronde trip en het event dat erbij hoort — zodat de shell weinig anders kan dan de beslissing uitvoeren. Dat is dezelfde zet als het bewijs teruggeven in plaats van het antwoord, een laag hoger.


De imperative shell

De applicatielaag wordt nu verantwoordelijk voor de orchestration.

public async Task<Result> CompleteTrip(Guid tripId)
{
    var trip = await repository.Get(tripId);
    if (trip == null)
        return Result.Failure("Trip not found.");

    var completedAt = clock.UtcNow;

    var result = TripCompletionService.Complete(trip, completedAt);
    if (result.IsFailure)
        return Result.Failure(result.Error);

    await repository.Save(result.Value.Trip);
    await eventBus.Publish(result.Value.Event);

    logger.LogInformation("Trip {TripId} completed", trip.Id);

    return Result.Success();
}

De shell doet nog steeds veel, en dat is niet per definitie een probleem. Hij orchestreert: hij haalt data op, verzamelt input uit de buitenwereld, roept de core aan, interpreteert het resultaat en voert vervolgens de noodzakelijke effecten uit.

Het belangrijke verschil is dat hij zelf nauwelijks beslissingen neemt.


Claude Code de splitsing laten doen

Ik heb CompleteTrip niet met de hand herschreven. Het interessante aan deze refactor is hoe weinig ervan een mens nodig heeft — zodra je de twee verantwoordelijkheden benoemd hebt, is de vorm mechanisch.

Ik zette Claude Code op de oorspronkelijke handler en gaf het de regel, niet de oplossing:

Deze handler mengt beslissingen met effecten. Haal de beslissingen eruit naar een pure statische Complete-methode die de trip en de huidige tijd als parameters krijgt en een Result<TripCompletion> teruggeeft — geen EF, geen klok, geen logging, geen message bus. Laat het laden, opslaan, publiceren en loggen in de handler staan. Schrijf daarna een unittest voor de core-methode zonder mocks.

Wat terugkwam was vrijwel exact de core/shell-splitsing hierboven. Logisch: beslissingen van effecten scheiden is pattern-matching, en pattern-matching is precies waar deze tools goed in zijn. DateTime.UtcNow naar een parameter verplaatsen, gegooide excepties omzetten naar Result.Failure, de retourwaarde terug door de handler rijgen — allemaal mechanisch, allemaal het soort edit dat met de hand vervelend is en foutgevoelig zodra je het over een stuk of tien handlers doet.

Wat Claude Code niet besliste — en niet zou moeten — is welke logica een beslissing is. Dat Distance > 100 een businessregel is en SaveChangesAsync een effect, is hier duidelijk; in een echte handler is die grens vager, en dat oordeel blijft bij jou. Ik vertelde waar de grens lag. Het verzette het werk.

Dat is de rolverdeling waar ik op deze blog steeds op terugkom: jij houdt het ontwerp vast, de AI doet de refactor.


Push decisions inward, push effects outward

Een handige manier om Functional Core, Imperative Shell te herkennen is deze regel:

Push decisions inward. Push effects outward.

Businessbeslissingen bewegen richting de core. Side effects bewegen richting de rand van het systeem.

Als je in een application handler code ziet als:

if (customer.IsPremium && order.Total > 1000)
{
    discount = 0.10m;
}

dan is dat waarschijnlijk een beslissing die naar binnen kan.

Als je in je domeinmodel code ziet die rechtstreeks:

await database.SaveChangesAsync();

of:

await messageBus.Publish(...);

uitvoert, dan zit een effect waarschijnlijk te ver naar binnen.


Waarom dit testen eenvoudiger maakt

Het grootste voordeel wordt zichtbaar in tests. De oorspronkelijke methode vereist infrastructuur of mocks. De functional core niet.

[Fact]
public void Long_trip_requires_review()
{
    var trip = Trip.Active(distance: 150);

    var result = TripCompletionService.Complete(
        trip,
        new DateTime(2026, 7, 1));

    result.Value.Trip.RequiresReview.Should().BeTrue();
}

Geen database. Geen dependency injection container. Geen mocks. Geen testserver. Geen message bus. Alleen input en output.

Integratietests blijven nodig. Je wilt nog steeds weten of je repository werkt en of events daadwerkelijk gepubliceerd worden. Maar het aantal combinaties dat je via integratietests moet afdekken wordt veel kleiner. De complexe combinatoriek zit in de functional core — en die kun je snel en deterministisch testen.


Dit is geen pleidooi voor functioneel programmeren

De naam kan misleidend zijn. Je hoeft geen Haskell te gebruiken. Je hoeft niet al je classes te vervangen door functies. En je hoeft je domeinmodel niet immutable te maken.

Het principe gaat over architectuur — over het herkennen van twee fundamenteel verschillende verantwoordelijkheden: beslissen en uitvoeren.

Objectgeoriënteerde domeinmodellen kunnen prima onderdeel zijn van een functional core, zolang de domeinlogica onafhankelijk blijft van infrastructuur en externe effecten. Een aggregate dat een command verwerkt, zijn toestand verandert en domain events produceert, past uitstekend binnen dit model.


De interessante consequentie

Wanneer je dit principe consequent toepast, verandert de vorm van je applicatie. De buitenste laag wordt dunner in intelligentie. De binnenste laag wordt rijker in beslissingen.

Je handlers worden orchestration code. Je domeinmodel en domeinservices worden de plek waar beslissingen genomen worden. En infrastructuur wordt weer wat infrastructuur hoort te zijn: een mechanisme om de buitenwereld te lezen en te veranderen.


Tot slot

Veel code is niet moeilijk omdat de businesslogica ingewikkeld is. Code wordt moeilijk omdat beslissingen en effecten met elkaar verweven raken.

Functional Core, Imperative Shell geeft een eenvoudige ontwerpregel om die twee uit elkaar te trekken. Laat de shell de wereld binnenhalen. Laat de core beslissen wat die wereld betekent. Laat de shell vervolgens uitvoeren wat er besloten is.

Of nog korter:

Effects at the edges. Decisions at the core.

Probeer het op je eigen code. Open de rommeligste handler in je huidige project — die ene waar je tegen opziet om te testen. Markeer elke regel die een database, de klok, een message bus of een logger raakt, en elke regel die een beslissing neemt. Als die twee door elkaar lopen, heb je je kandidaat. Trek de beslissingen naar één statische methode die kale invoer krijgt en een resultaat teruggeeft, laat de effecten in de handler staan, en schrijf een test voor die methode zonder mocks. Als die test ineens saai is om te schrijven, heb je de kern van deze post gevoeld — en saaie tests zijn precies de tests die je aan Claude Code kunt geven zodra de methode puur is.