Heart for people
Mind for tech

Illegal states unrepresentable maken

Een krachtig patroon met een verborgen prijs

Nahuel Manterola

Engineering

Terminal with error

Written by

Nahuel Manterola
Software Developer

Dit artikel is oorspronkelijk geschreven in het Engels.

Bij Baseflow zijn we een diverse groep met uitgesproken meningen. Er zijn weinig onderwerpen waar we geen discussie over hebben. Dit is er eentje dat al een tijdje in mijn hoofd rondspookt: hoe strict maak je je datamodellen, en wanneer werkt dat tegen je?

Het concept uitgelegd

De term making illegal states unrepresentable komt van Yaron Minsky, die het beschreef aan de hand van OCaml. Geen toeval: OCaml was een grote inspiratiebron voor het typesysteem van Rust. De kern is simpel. Je modelleert je data zo dat ongeldige combinaties niet kunnen bestaan. De compiler dwingt het af, niet jij op runtime. Ik gebruik Rust voor de voorbeelden, omdat de algebraïsche datatypes van Rust dit concept op een natuurlijke manier laten zien. Maar het idee zelf is niet taalspecifiek. Je kunt het toepassen in elke omgeving waar je domeinlogica modelleert. Een basiskennis van Rust's rich enums is handig voor het volgen van de voorbeelden.

Waar dit patroon uitblinkt

Minsky's klassieke voorbeeld is het modelleren van een TCP-verbinding:

enum ConnectionState {
    Connecting,
    Connected,
    Disconnected,
}

struct ConnectionInfo {
    state: ConnectionState,
    server: IpAddr,
    last_ping_time: Option<SystemTime>,
    last_ping_id: Option<i32>,
    session_id: Option<String>,
    when_initiated: Option<SystemTime>,
    when_disconnected: Option<SystemTime>,
}

Dit model heeft bugs in wachting. Je kunt state: Connected hebben zonder session_id. Je kunt last_ping_time hebben zonder last_ping_id. Elke plek in de code die dit model gebruikt, moet handmatig controleren of de data klopt. Dat is foutgevoelig en vermoeiend.

De betere versie:

enum ConnectionState {
    Connecting {
        when_initiated: SystemTime,
    },
    Connected {
        session_id: String,
        last_ping: Option<(SystemTime, i32)>,
    },
    Disconnected {
        when_disconnected: SystemTime,
    },
}

struct ConnectionInfo {
    server: IpAddr,
    state: ConnectionState,
}

Nu zijn de benodigde velden per state expliciet gedefinieerd. De compiler laat je niet in een Connected-state komen zonder session_id. Niemand hoeft dat achteraf nog te controleren. Dit is het patroon in zijn krachtigste vorm.

De verborgen prijs van strictheid

Dit voorbeeld is een overduidelijke verbetering. Maar ik wil twee redenen benoemen waarom het hier zo goed werkt:

  • Het domein verandert vrijwel nooit. TCP-states zijn een protocol, geen bedrijfsregel.
  • Het typesysteem sluit perfect aan op de structuur van het domein.

Die twee factoren zijn niet altijd aanwezig. En als ze ontbreken, kan hetzelfde patroon tegen je werken.

Strenge types zijn gebaseerd op aannames over het domein. Zolang die aannames kloppen, reap je de voordelen: structuur, voorspelbaarheid, minder checks. Maar als een aanname sneuvelt door nieuwe requirements, heb je technische schuld. Het is onze taak als engineers om die balans te bewaken: de veiligheid van strict typing tegenover de flexibiliteit die de echte wereld vereist.

Een voorbeeld dichterbij huis

Neem een applicatie voor de Nederlandse markt. Iedereen heeft een achternaam, dus je modelleert:

struct UserName {
    first_name: String,
    last_name: String, // Required!
}

Praktisch. Je kunt nu overal in de code zorgeloos schrijven:

let email_header = format!("Dear Mx {},", user.last_name); // Look mom, no checks!
let initials = format!(
	"{}{}", 
	user.first_name.chars().next().unwrap(), // You'll handle this gracefully in production, right?
	user.last_name.chars().next().unwrap()
);

Dan gaat de app internationaal. Plots heb je gebruikers zonder wettelijke achternaam, en tal van andere naamconventies die je niet had voorzien. Omdat je last_name als verplicht hebt vastgelegd, heb je code geschreven die die aanname overal stilzwijgend aanneemt. Een database-migratie, aanpassingen aan API-contracts, en een zoektocht door de hele codebase wachten je op.

Bij een enkel veld is dat nog te overzien. Bij grote objecten met meerdere van dit soort aannames kan een wijziging in bedrijfsregels een aardverschuiving worden.

Wat dit betekent in de praktijk

Strict typing is een krachtig gereedschap. Maar het is ook cement voor je huidige begrip van het domein.

Ken je de domeinregels als absolute waarheden, zoals bij TCP-states? Giet dan dat cement. Strict types geven je garanties die je nergens anders zo goedkoop krijgt.

Ontdek je het domein nog, of werk je in een omgeving waar bedrijfsregels regelmatig veranderen? Dan kan datzelfde cement je gevangenzetten. Laat dan ruimte. Niet voor slordigheid, maar voor de requirements van morgen.

Stel jezelf bij elk nieuw domein de vraag: zijn mijn aannames harde specificaties, of zijn het de bedrijfsregels van vandaag? Het antwoord bepaalt hoeveel vrijheid je jezelf gunt.

Nieuwsgierig hoe we deze principes toepassen in productie? Bekijk onze engineering aanpak.