Je kent die methode wel. Die ene waarvan de XML-doc zegt: “alleen aanroepen nadat je die andere hebt aangeroepen.” Hij compileert. De tests zijn groen. Hij draait al jaren probleemloos in productie.

Er is niets mis mee. En toch is die ene zin documentatie stilletjes de oorzaak van vijf aparte problemen, verspreid door de codebase.

In mijn geval was het een feature-support-check: “ondersteunt deze hardware deze feature?” Correcte code, dat is hij altijd geweest. Maar ik bleef diezelfde guard-clause overal terugzien, dus vorige week zette ik Claude Code erop en vroeg waarom. Het antwoord bleek een ontwerpkeuze van jaren geleden — en een betere die de hele tijd voor het oprapen lag.


De opzet

Stel je een platform voor dat een vloot fysieke apparaten beheert. Verschillende hardware ondersteunt verschillende features. Voordat het platform een commando stuurt — zeg herstelmodus inschakelen — moet het weten of dat apparaat dat überhaupt kan.

Er is dus een check die precies dat beantwoordt:

public FeatureSupportResult CanSupport(CapabilityProvider provider) =>
    !provider.Supports<RecoveryModeCapability>()
        ? FeatureSupportResult.NotSupported(
              "Device does not support recovery mode", nameof(RecoveryModeCapability))
        : FeatureSupportResult.Supported();

En wanneer het platform daadwerkelijk wil handelen, bouwt een tweede methode het commando:

public DeviceCommand GetCommand(CapabilityProvider provider, RecoveryModeOptions options)
{
    var capability = provider.GetCapability<RecoveryModeCapability>()
        ?? throw new InvalidOperationException("CanSupport must be called first");
    return capability.GetCommand(options);
}

Twee methodes. CanSupport stelt de vraag; GetCommand voert de actie uit. Ze worden verbonden door een ongeschreven regel: je moet vragen voordat je handelt. Die regel staat in een exception-bericht en een doc-comment. Hij staat nergens waar de compiler hem kan zien.

Dat is de scheur waar al het andere uit lekt.


Wat Claude Code vond

Ik gaf Claude Code de simpelst mogelijke prompt:

“Dit CanSupport / GetCommand-paar is gedupliceerd over de requirements-klassen. Volg hoe het resultaat door de codebase wordt gebruikt en vertel me wat er stinkt.”

Het kwam terug met vijf symptomen — en het nuttige was dat ze allemaal naar één oorzaak wezen.

1. Een type dat toestanden toelaat die het domein niet heeft. FeatureSupportResult draagt een bool, een reason-string en een lijst met ontbrekende capabilities. Maar de reason en de lijst zijn alleen betekenisvol wanneer IsSupported false is. Niets weerhoudt je ervan om Supported() mét een reason te maken, of NotSupported("") met een lege. Dat is hét schoolvoorbeeld van een type dat een discriminated union wil zijn — hetzelfde modelleerprobleem dat ik op een order-model uit elkaar pluis in illegale toestanden onrepresenteerbaar maken.

2. Een timing-regel afgedwongen door een exception, overal herhaald. De ?? throw new InvalidOperationException("CanSupport must be called first")-guard stond gekopieerd in zo’n twintig requirements-klassen. De throw bestaat omdat het ontwerp niet kan bewijzen dat de check heeft plaatsgevonden.

3. Een bewijsobject dat niets bewijst. Iemand had een FeatureSupportingDevice.Create(...) geschreven — duidelijk bedoeld als een smart constructor waarvan het bestaan support bewijst. Maar hij riep de check aan en gooide het resultaat weg, dus een handler verderop moest diezelfde check alsnog opnieuw uitvoeren.

4. Dezelfde vraag drie keer gesteld. Eén request evalueerde hardware-support in de API-handler, nog eens in een event-handler en nog een keer binnen GetCommand. Geen performanceprobleem — een coherentie-probleem. Drie aanroeppunten kunnen het oneens zijn.

5. Tien identieke guards en een stringly-typed reason. Elke handler converteerde het resultaat met de hand naar de Result<T>-railway van de codebase, en de Reason was vrije Engelse tekst — niet testbaar behalve met een substring-assertie, en niet in staat om “deze hardware mist de capability” te onderscheiden van “ik herken deze hardware helemaal niet.”

Leg ze naast elkaar en ze vallen samen in één zin:

De check produceert data waar hij capability zou moeten produceren.

Een bool kun je negeren, vergeten, opnieuw berekenen en door elke consument opnieuw laten interpreteren. Dat zijn allemaal dingen die je met een stuk data kunt doen. Geen daarvan is iets wat je kunt doen met een bewijs dat je in je hand houdt.


Het idee: geef het ding terug, niet toestemming om ernaar te vragen

Dit is “parse, don’t validate” toegepast op hardware-capabilities.

CanSupport is een validator: hij checkt en vergeet dan. De vervanger — noem hem Resolve — is een parser: hij checkt en bewaart dan het resultaat van de check in het type dat hij teruggeeft. In het succesgeval geeft hij precies datgene terug waar het privilege uit bestaat: een kant-en-klare command factory.

Dit is het type waar ik op uitkwam, met Claude Code als klankbord:

public abstract record FeatureSupport<TOptions> where TOptions : IOptions
{
    private FeatureSupport() { }   // closed: only Granted and Denied can exist

    public sealed record Granted(Func<TOptions, DeviceCommand> CreateCommand)
        : FeatureSupport<TOptions>;

    public sealed record Denied(SupportDenialReason Reason)
        : FeatureSupport<TOptions>;

    public TResult Match<TResult>(
        Func<Granted, TResult> whenGranted,
        Func<Denied, TResult> whenDenied) =>
        this switch
        {
            Granted g => whenGranted(g),
            Denied d  => whenDenied(d),
            _ => throw new UnreachableException()
        };
}

Twee keuzes zijn het verdedigen waard:

  • Er is geen IsSupported-property. Match is de enige manier om de waarde te consumeren. Zodra je een boolean blootstelt, is elke consument vrij om precies de blindheid te herscheppen die je net had weggehaald. En de twee verplichte parameters van Match zijn je exhaustiveness-check — je kunt de waarde niet consumeren zonder beide gevallen af te handelen.
  • Het bewijs is een factory, niet de capability zelf. Granted draagt Func<TOptions, DeviceCommand>, niet de onderliggende hardware-capability. Het is het smalst mogelijke privilege: “je mag dit ene commando bouwen, en niets anders.”

De reason wordt ook data, zodat de twee werkelijk verschillende fouten niet langer een string delen:

public abstract record SupportDenialReason
{
    private SupportDenialReason() { }

    public sealed record MissingCapabilities(IReadOnlyList<string> CapabilityNames)
        : SupportDenialReason;

    public sealed record UnknownHardware(byte HardwareId)
        : SupportDenialReason;
}

Wat de refactor verwijdert

Dit is het voor en na van de check zelf:

// Before — two methods, one latent InvalidOperationException
public FeatureSupportResult CanSupport(CapabilityProvider provider) => /* ... */;

public DeviceCommand GetCommand(CapabilityProvider provider, RecoveryModeOptions options)
{
    var capability = provider.GetCapability<RecoveryModeCapability>()
        ?? throw new InvalidOperationException("CanSupport must be called first");
    return capability.GetCommand(options);
}

// After — one method, the throw is gone
public FeatureSupport<RecoveryModeOptions> Resolve(CapabilityProvider provider) =>
    provider.GetCapability<RecoveryModeCapability>() is { } capability
        ? new FeatureSupport<RecoveryModeOptions>.Granted(capability.GetCommand)
        : new FeatureSupport<RecoveryModeOptions>.Denied(
              new SupportDenialReason.MissingCapabilities([nameof(RecoveryModeCapability)]));

Let op wat er met de timing-regel gebeurt. De capability wordt één keer opgezocht; als hij er is, is zijn GetCommand-methode de factory die we teruggeven. Er is geen later moment waarop hij afwezig zou kunnen zijn, omdat we hem nooit een tweede keer opzoeken. De exception had niets meer te bewaken, dus hij is weg — niet beter afgehandeld, weg.

En de tien gekopieerde handler-guards vallen samen op de bestaande result-railway:

// Before (×10, copy-pasted)
var result = capabilityService.CheckSupport(feature, hardwareId);
if (!result.IsSupported)
    return Failure.Create<TResponse>(
        DomainErrors.FeatureNotSupported(request.DeviceId, result.Reason));
await featureService.SetFeature(device, options);

// After
return await capabilityService.ResolveSupport(feature, hardwareId)
    .ToResult(request.DeviceId)
    .BindAsync(_ => featureService.SetFeature(device, options));

Die ene ResolveSupport aan de rand is ook waar deze refactor functional core, imperative shell raakt: de shell vraagt de buitenwereld één keer, en alles verder naar binnen werkt met het bewijs dat hij meekreeg.

De hele tabel met defecten wordt niet gerepareerd — het grootste deel wordt onmogelijk om te schrijven:

Defect eerderWaarom het verdwijnt
Twintig "must be called first"-throwsGeen tweede aanroep om in de verkeerde volgorde te zetten; de factory bestaat alleen binnen Granted.
Illegale toestanden (supported-met-reason)Niet representeerbaar. Granted heeft geen reason-veld.
Smart constructor die zijn eigen check weggooitOnmogelijk — hij kan het object niet maken zonder een Granted om vast te leggen.
Drievoudige evaluatie per requestEén evaluatie aan de grens; het bewijs reist mee.
“Onbekende hardware” vermomd als “niet ondersteund”Een apart UnknownHardware-geval dat de API anders kan rapporteren.

Waar Claude Code zijn geld waard was

Het ontwerp was het makkelijke deel — een middag heen en weer. De migratie is wat een verandering als deze meestal om zeep helpt: hij raakt de requirements-klassen, de configurators, de handlers, de event-flow en elke test eromheen. Dat is de reden dat een verandering die zó overduidelijk goed is een jaar op de backlog blijft liggen.

Ik deed het in fases en liet Claude Code het mechanische werk doen:

  1. Introduceer de nieuwe union naast het oude type, met een tijdelijke adapter zodat ze naast elkaar bestaan. Claude Code schreef de adapter.
  2. Converteer de requirements-klassen. Dit is puur pattern-matching over ~20 vrijwel identieke bestanden — precies de “mechanische vertaling” waar AI het beste in is. Ik reviewde de diffs; ik typte ze niet.
  3. Verplaats de handlers naar de railway. Eén ToResult-extension, en dan elke guard vinden en herschrijven.
  4. Draag het bewijs door de event-flow — de risicovolste fase. Ik liet Claude Code characterization tests schrijven voordat ik eraan begon, zodat ik meteen zou weten als gedrag afweek.
  5. Verwijder het oude type zodra een grep bewees dat niets er nog naar verwees.

Elke fase was onafhankelijk te mergen en groen voordat de volgende begon. Het deel waar ik altijd tegen opzie — de saaie, foutgevoelige sweep over tientallen bestanden — is het deel dat ik niet meer met de hand doe. Het is precies de verschuiving waar ik eerder over schreef in van code schrijven naar software-architectuur: ik houd het ontwerp vast, de machine doet de fan-out.


Wanneer je dit niet moet doen

Het zou oneerlijk zijn om te doen alsof elke bool deze behandeling nodig heeft. Er is één pad in dezelfde codebase waar al deze ceremonie overkill zou zijn: een alleen-lezen admin-scherm dat gewoon “ondersteund: ja/nee” toont en nooit een commando bouwt.

public bool IsSupported<TOptions>(IFeature<TOptions> feature, byte hardwareId) =>
    ResolveSupport(feature, hardwareId) is FeatureSupport<TOptions>.Granted;

De boolean is daar prima, omdat hij meteen wordt gebruikt om iets te tonen en niemand iets toekent. De erfzonde was nooit de boolean zelf — het was een boolean gebruiken als de poort voor een privilege dat ergens heel anders werd uitgedeeld.

De eerlijke trigger is niet “unions zijn elegant.” Het zijn de symptomen: een privilege dat alleen door een timing-comment wordt bewaakt, een bewijsobject dat zijn bewijs weggooit, een check die meer dan eens wordt geëvalueerd omdat zijn antwoord niet kan reizen. Heb je die, dan verdient de structuur zijn plek. Heb je een bool die één keer wordt gelezen en niets bewaakt, laat hem dan met rust.


Zoek de comment die je waarschuwt

Ga op zoek naar een methode met een comment als // call X before Y — elke codebase heeft er een. Open Claude Code in dat project:

claude

En dan:

“De doc van deze methode zegt dat hij na [andere methode] moet worden aangeroepen. Volg elke aanroeper. Waar zou iemand de check kunnen overslaan? Stel dan een ontwerp voor waarbij X het ding teruggeeft dat Y nodig heeft, zodat er geen manier is om Y te bereiken zonder door X te gaan.”

Lees wat er terugkomt. De remedie is bijna altijd dezelfde: geef geen toestemming om te vragen terug — geef het antwoord, al zo gevormd dat het enige wat je ermee kunt doen het juiste is.