Algebraische Datentypen in .NET Framework / .NET verwenden — Zustände und Ergebnisse mit Typen entwerfen

· · .NET, .NET Framework, C#, F#, Algebraische Datentypen, Diskriminierte Vereinigung, Domänenmodellierung, Nutzung von Altbeständen

1. Das Wichtigste zuerst

Schreibt man .NET-Fachanwendungen, begegnet man häufig Rückgabewerten und Zuständen wie diesem.

public class CreateUserResult
{
    public bool IsSuccess { get; set; }
    public User User { get; set; }
    public string ErrorCode { get; set; }
    public string ErrorMessage { get; set; }
}

Auf den ersten Blick wirkt das verständlich, doch in diesen Typ passen viele Zustände hinein, die es eigentlich nicht geben sollte.

Zum Beispiel lassen sich solche Werte konstruieren.

  • IsSuccess == true, aber User == null
  • IsSuccess == true, aber ErrorCode ist gesetzt
  • IsSuccess == false, aber User ist gesetzt
  • ErrorCode == "DuplicateEmail", aber ErrorMessage == null
  • Ein neuer Fehlercode wurde hinzugefügt, aber die aufrufende Seite wurde nicht entsprechend aktualisiert

Solche Typen sind anfangs praktisch, belasten aber mit wachsendem Umfang Leser und Wartende zunehmend.

Der Denkansatz, den man hier heranziehen will, sind algebraische Datentypen.

Der Name „algebraischer Datentyp“ klingt etwas sperrig, aber praktisch lässt er sich so verstehen:

Die Tatsache, dass „dieser Wert von vornherein eine feste Menge möglicher Formen hat“, wird durch den Typ ausgedrückt – nicht durch Kommentare oder Namenskonventionen.

Zum Beispiel lässt sich das Ergebnis einer Benutzererstellung als genau einer der folgenden Fälle ausdrücken.

CreateUserResult =
  Created(User)
  oder DuplicateEmail(email)
  oder WeakPassword(reason)
  oder SystemFailure(message)

Bei Erfolg gibt es ein User. Bei doppelter E-Mail-Adresse gibt es ein email. Bei schwachem Passwort gibt es einen reason. Bei Systemfehler gibt es eine message.

Jeder Fall trägt nur die Daten, die er braucht. Erfolg und Fehlschlag können nie gleichzeitig gelten. Auch der Zustand „Erfolg, aber ohne User“ lässt sich nicht konstruieren.

In .NET lässt sich dieser Gedanke umsetzen: in F# mit diskriminierten Vereinigungen, in C# mit sealed-Klassenhierarchien, record-Hierarchien, Bibliotheken wie OneOf oder den kommenden C#-Union-Typen.

Dieser Artikel ordnet, wie sich algebraische Datentypen sowohl in .NET Framework als auch im aktuellen .NET einsetzen lassen, und welche praktischen Vorteile und Fallstricke es gibt.

Der gesamte in diesem Artikel gezeigte Code ist als bau- und lauffähiges Beispielpaket auf GitHub veröffentlicht (eine Bibliothek, Demos zu jedem Implementierungsmuster sowie Unit-Tests, die die Vollständigkeit von Match, Zustandsübergänge und DTO-Konvertierung prüfen).

dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)

Wie dieser Artikel zu lesen ist

Der Artikel ist lang; Sie müssen ihn nicht von vorne nach hinten lesen. Springen Sie je nach Ziel direkt zum passenden Kapitel.

Ziel Zu lesende Kapitel
Erst einmal verstehen, was ein ADT ist und wozu er dient 1–3
Implementierungsarten in C# / F# vergleichen und auswählen 4–10
Option, Result, Zustandsübergänge und API-Grenzen praktisch einsetzen 11–14
Die Vorteile der Einführung dem Team oder Vorgesetzten erklären 15–19
In ein bestehendes System einschließlich .NET Framework einführen 20–21
Unsicher bei der Abgrenzung zu enum, bool und Vererbung 22–25
Vorab die Fallstricke im Design kennenlernen 26–29
Konkrete Schritte zum Umbau von bestehendem Code 30–31

Vorausgesetztes Wissen

Die Codebeispiele ab Kapitel 6 verwenden Pattern Matching und den switch-Ausdruck von C#. Die Begriffe dafür fassen wir vorab zusammen.

Begriff Bedeutung Schreibweise (Beispiel)
switch-Ausdruck Eine Schreibweise, bei der die Verzweigung selbst einen Wert liefert. Anders als das switch-Statement wird die rechte Seite jedes Zweigs zum Ergebniswert result switch { ... }
Typmuster Verzweigt danach, ob der Wert diesen Typ hat, und bindet ihn an eine Variable Created x => ...
Eigenschaftsmuster Betrachtet zusätzlich zum Typ auch Eigenschaftswerte. Mit var lässt sich der Inhalt extrahieren Created { User: var user } => ...
Verwerfungsmuster (Discard) Die Verzweigung für den Fall, dass kein Muster passt _ => throw ...
when-Klausel Fügt einem Muster eine zusätzliche Bedingung hinzu OutOfStock x when x.Available == 0 => ...

Die Primärquellen sind Pattern matching overview und switch expression von Microsoft Learn.

Kapitel 4 und 5 zeigen bewusst eine Schreibweise, die auch mit älterem C# funktioniert, und verwenden daher as und if statt des switch-Ausdrucks. Sie sind auch ohne Kenntnisse im Pattern Matching verständlich.

2. Was ist ein algebraischer Datentyp?

Algebraische Datentypen werden im Englischen Algebraic Data Type genannt und meist mit ADT abgekürzt.

Grob gesagt ist ein ADT die Kombination zweier Arten von Typen.

  • Produkttyp: ein Typ, der sowohl A als auch B hält
  • Summentyp: ein Typ, der entweder A oder B ist

.NET-Klassen, -Structs und -Records werden meist als „Produkttyp“ verwendet.

public sealed class Address
{
    public string PostalCode { get; }
    public string Prefecture { get; }
    public string City { get; }
    public string Street { get; }

    public Address(string postalCode, string prefecture, string city, string street)
    {
        PostalCode = postalCode;
        Prefecture = prefecture;
        City = city;
        Street = street;
    }
}

Semantisch bedeutet das:

Address = PostalCode und Prefecture und City und Street

Ein Summentyp dagegen ist „genau einer von mehreren“.

PaymentResult =
  Succeeded(receiptNo)
  oder InsufficientFunds(shortage)
  oder Rejected(reason)
  oder NetworkFailure(message)

Semantisch bedeutet das:

PaymentResult = Succeeded oder InsufficientFunds oder Rejected oder NetworkFailure

Dieses „oder“ als Typ auszudrücken, ist der Teil algebraischer Datentypen, der in der Praxis am häufigsten genutzt wird.

In F# lässt sich das natürlich als Sprachfeature schreiben.

type PaymentResult =
    | Succeeded of receiptNo: string
    | InsufficientFunds of shortage: decimal
    | Rejected of reason: string
    | NetworkFailure of message: string

C# hatte lange Zeit kein Standardfeature, das F#s diskriminierten Vereinigungen entspricht. Deshalb wurden sie in C# über Klassenhierarchien und Bibliotheken abgebildet.

Der Grundgedanke selbst lässt sich aber auch in C# gut nutzen.

Wichtig ist nicht die konkrete Syntax, sondern genau dieser eine Punkt:

„Ungültige Zustände von vornherein unkonstruierbar machen.“

3. Warum bool und enum allein nicht ausreichen

Bei kleinen Vorgängen wirken bool oder enum mitunter ausreichend.

Zum Beispiel ein Rückgabewert wie dieser.

public enum PaymentStatus
{
    Succeeded,
    InsufficientFunds,
    Rejected,
    NetworkFailure
}

public sealed class PaymentResponse
{
    public PaymentStatus Status { get; set; }
    public string ReceiptNo { get; set; }
    public decimal? Shortage { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

In dieser Form ist die Beziehung zwischen Status und den einzelnen Eigenschaften jedoch nicht im Typ ausgedrückt.

ReceiptNo wird nur bei Status == Succeeded gebraucht. Shortage wird nur bei Status == InsufficientFunds gebraucht. Reason wird nur bei Status == Rejected gebraucht. Message wird nur bei Status == NetworkFailure gebraucht.

Diese Regel liegt außerhalb des Codes.

Sie hängt von Kommentaren, Spezifikationsdokumenten, Tests, stillschweigenden Vereinbarungen und dem Gedächtnis der Implementierenden ab.

Als Folge häuft sich defensiver Code wie dieser.

if (response.Status == PaymentStatus.Succeeded)
{
    if (string.IsNullOrEmpty(response.ReceiptNo))
    {
        throw new InvalidOperationException("ReceiptNo is required.");
    }

    return response.ReceiptNo;
}

Solcher defensiver Code hat seine Berechtigung, aber vieles davon ließe sich von vornherein durch „Typdesign“ verhindern.

Als algebraischer Datentyp ausgedrückt, trägt jeder Fall nur die Daten, die er braucht.

Succeeded trägt receiptNo
InsufficientFunds trägt shortage
Rejected trägt reason
NetworkFailure trägt message

Bei diesem Design lässt sich kein Succeeded-Wert ohne receiptNo konstruieren.

Das heißt: Statt sich im Nachhinein mit Zustandsprüfungen abzumühen, macht man ungültige Zustände von Anfang an unkonstruierbar.

Übersicht der Implementierungsarten (Kapitel 4–10)

Von hier bis Kapitel 10 geht es im Detail darum, wie sich dieser Summentyp in .NET implementieren lässt. Zunächst die Übersicht.

Implementierungsart Kapitel Zielumgebung Schreibaufwand Bibliotheksabhängigkeit Erkennung fehlender Fälle
class-Hierarchie (private-Konstruktor + verschachtelte sealed-Klassen + Match) 4, 5 Funktioniert sowohl unter .NET Framework als auch im aktuellen .NET Hoch. Klasse, Factory und Match werden für jeden Fall von Hand geschrieben Keine Da sich die Parameter von Match mit jedem neuen Fall vermehren, entsteht bei der Aufrufseite ein Kompilierfehler
record-Hierarchie 6 record ist ein Feature ab C# 9. Dieser Artikel setzt .NET 5 oder neuer voraus Gering. Ein Fall lässt sich in einer Zeile schreiben Keine Allein mit dem switch-Ausdruck ist die Prüfung schwach; mit einer eigenen Match-Methode lässt sie sich erzwingen
F#s diskriminierte Vereinigung 7 F#-Projekt. Sowohl für .NET Framework als auch für aktuelles .NET nutzbar Minimal. Die Typdefinition ist bereits die Fallliste Keine (Sprachfeature) Der Compiler prüft die Vollständigkeit von match und warnt bei Lücken
OneOf 8 Breites Spektrum an Zielen einschließlich .NET Framework und .NET Standard Gering. Es muss keine eigene Basisklasse angelegt werden Ja (NuGet-Paket) Da Match einen Delegaten für jeden Fall verlangt, entsteht bei zusätzlichen Typargumenten ein Kompilierfehler auf der Aufrufseite
Source-Generator-basierte Bibliotheken 9 Vor allem aktuelles .NET. .NET-Framework-Unterstützung muss pro Bibliothek geprüft werden Gering. Nur ein Attribut hinzufügen Ja (Paket + Build-Umgebung) Manche warnen in Kombination mit einem Analyzer vor fehlender Fallbehandlung
C# 15 Union-Typ 10 Preview-Feature. Nicht für Produktionscode gedacht Minimal Keine (Sprachfeature) Noch offen, da die Spezifikation nicht final ist

Als Ausgangspunkt für die Wahl gilt:

  • Für die sofortige Einführung in ein bestehendes System einschließlich .NET Framework: die class-Hierarchie aus Kapitel 4/5
  • Wenn nur aktuelles .NET Zielumgebung ist: die record-Hierarchie aus Kapitel 6
  • Für einen einzelnen, lokal begrenzten Rückgabewert: OneOf aus Kapitel 8

4. Eine auch unter .NET Framework nutzbare Implementierung: eine sealed-Klassenhierarchie

Am leichtesten einzuführen ist in bestehenden Systemen einschließlich .NET Framework eine abstrakte Basisklasse + verschachtelte sealed-Klassen + eine Match-Methode.

Das funktioniert auch mit älteren C#-Versionen gut und erfordert keine besonderen Laufzeitfeatures.

Als Beispiel bilden wir das Ergebnis einer Benutzererstellung ab.

public abstract class CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed class Created : CreateUserResult
    {
        internal Created(User user)
        {
            if (user == null) throw new ArgumentNullException(nameof(user));
            User = user;
        }

        public User User { get; }
    }

    public sealed class DuplicateEmail : CreateUserResult
    {
        internal DuplicateEmail(string email)
        {
            if (email == null) throw new ArgumentNullException(nameof(email));
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class WeakPassword : CreateUserResult
    {
        internal WeakPassword(string reason)
        {
            if (reason == null) throw new ArgumentNullException(nameof(reason));
            Reason = reason;
        }

        public string Reason { get; }
    }

    public sealed class SystemFailure : CreateUserResult
    {
        internal SystemFailure(string message)
        {
            if (message == null) throw new ArgumentNullException(nameof(message));
            Message = message;
        }

        public string Message { get; }
    }

    public static CreateUserResult Ok(User user)
        => new Created(user);

    public static CreateUserResult EmailAlreadyUsed(string email)
        => new DuplicateEmail(email);

    public static CreateUserResult PasswordIsWeak(string reason)
        => new WeakPassword(reason);

    public static CreateUserResult Failed(string message)
        => new SystemFailure(message);

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        if (created == null) throw new ArgumentNullException(nameof(created));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
        if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));

        var c = this as Created;
        if (c != null) return created(c);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var w = this as WeakPassword;
        if (w != null) return weakPassword(w);

        var f = this as SystemFailure;
        if (f != null) return systemFailure(f);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

Die Verwendung sieht so aus.

CreateUserResult result = service.CreateUser(command);

string message = result.Match(
    created => "Benutzer erstellt: " + created.User.Id,
    duplicate => "Diese E-Mail-Adresse wird bereits verwendet: " + duplicate.Email,
    weak => "Das Passwort ist zu schwach: " + weak.Reason,
    failure => "Benutzererstellung fehlgeschlagen: " + failure.Message);

Der Vorteil dieser Form ist, dass sie sowohl unter .NET Framework als auch im aktuellen .NET funktioniert.

Created, DuplicateEmail, WeakPassword und SystemFailure sind alle CreateUserResult, tragen aber jeweils unterschiedliche Daten.

Nur Created trägt ein User. Nur DuplicateEmail trägt eine Email. Nur WeakPassword trägt einen Reason. Nur SystemFailure trägt eine Message.

Ein Wert, der Erfolg und Fehlschlag gleichzeitig ausdrückt, lässt sich nicht konstruieren.

Nutzt die Aufrufseite außerdem Match, lässt sich erzwingen, dass alle Fälle behandelt werden.

Nehmen wir an, es wird ein neuer Fall TemporaryBlocked hinzugefügt.

public sealed class TemporaryBlocked : CreateUserResult
{
    internal TemporaryBlocked(DateTimeOffset until)
    {
        Until = until;
    }

    public DateTimeOffset Until { get; }
}

Dann wird dem Match-Methodenparameter auch Func<TemporaryBlocked, T> hinzugefügt.

Dadurch wird jeder bestehende Aufruf von result.Match(...) zu einem Kompilierfehler. Das ist ein guter Fehler: Er lässt sich zur Kompilierzeit erkennen, dass „ein neuer Fall hinzugekommen ist, die Aufrufseite aber nicht darauf reagiert hat“.

5. Mit einem private-Konstruktor eine geschlossene Menge bilden

Wichtig ist beim Ausdrücken eines Summentyps in C#, die Menge der Fälle möglichst geschlossen zu halten.

Ist der Konstruktor der Basisklasse protected, bleibt Raum für externe Vererbung.

public abstract class PaymentResult
{
    protected PaymentResult()
    {
    }
}

In dieser Form lässt sich in einem anderen Assembly oder an anderer Stelle ein solcher Typ erzeugen.

public sealed class UnknownPaymentResult : PaymentResult
{
}

Dadurch bleibt die Fallmenge von PaymentResult nicht geschlossen.

Man wollte eigentlich sagen: „Dieser Typ ist entweder Succeeded, InsufficientFunds, Rejected oder NetworkFailure“, aber es kommt ein weiterer Fall hinzu.

Eine auch unter .NET Framework praktikable Gegenmaßnahme besteht darin, den Konstruktor der Basisklasse private zu machen und die Falltypen als verschachtelte Typen der Basisklasse zu definieren.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    public sealed class Succeeded : PaymentResult
    {
        internal Succeeded(string receiptNo)
        {
            ReceiptNo = receiptNo;
        }

        public string ReceiptNo { get; }
    }

    public sealed class InsufficientFunds : PaymentResult
    {
        internal InsufficientFunds(decimal shortage)
        {
            Shortage = shortage;
        }

        public decimal Shortage { get; }
    }

    public static PaymentResult Success(string receiptNo)
        => new Succeeded(receiptNo);

    public static PaymentResult Insufficient(decimal shortage)
        => new InsufficientFunds(shortage);
}

Verschachtelte Typen können auf die private-Member des umschließenden Typs zugreifen. Deshalb können nur die verschachtelten Falltypen von PaymentResult erben.

Mit diesem Muster lässt sich auch in C# etwas erreichen, das einer „geschlossenen Fallmenge“ nahekommt.

Der C#-Compiler führt allerdings keine so vollständige Vollständigkeitsprüfung durch wie F#.

Wird dieses Muster in C# verwendet, empfiehlt es sich daher, switch möglichst nicht über den Code zu verstreuen, sondern die Verarbeitung in einer Match-Methode zu bündeln.

6. Mit aktuellem .NET lässt sich das über eine record-Hierarchie knapp schreiben

Kann .NET 5 oder neuer vorausgesetzt werden, lassen sich datenorientierte Falltypen mit dem record von C# deutlich kürzer schreiben.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;
}

Auf der Verwendungsseite lassen sich Pattern Matching und der switch-Ausdruck einsetzen.

static string ToMessage(CreateUserResult result)
{
    return result switch
    {
        CreateUserResult.Created { User: var user }
            => $"Benutzer erstellt: {user.Id}",

        CreateUserResult.DuplicateEmail { Email: var email }
            => $"Diese E-Mail-Adresse wird bereits verwendet: {email}",

        CreateUserResult.WeakPassword { Reason: var reason }
            => $"Das Passwort ist zu schwach: {reason}",

        CreateUserResult.SystemFailure { Message: var message }
            => $"Benutzererstellung fehlgeschlagen: {message}",

        _ => throw new InvalidOperationException("Unbekanntes Ergebnis.")
    };
}

Diese Schreibweise ist typisch für C# und gut lesbar.

Es gibt aber auch Dinge zu beachten.

Eine record-Hierarchie ist praktisch, um Boilerplate-Code für Wertevergleich und Anzeige zu reduzieren. Es ist aber sicherer, sie nicht als ebenso starken Mechanismus zum Schließen der Fallmenge zu betrachten wie die im vorigen Kapitel gezeigte Kombination aus „gewöhnlicher class + private-Konstruktor + verschachtelten sealed-Fällen“.

Besonders bei nicht-sealed record class kommen record-spezifische generierte Member wie der Kopierkonstruktor ins Spiel. Für Fälle, in denen „auf keinen Fall von außen abgeleitet werden soll“ oder „die Fallmenge strikt geschlossen bleiben soll“, ist es solider, die class-Hierarchie aus dem vorigen Kapitel, F#s diskriminierte Vereinigung oder eine bewährte Union-/Source-Generator-Bibliothek zu wählen.

Fügt man diesem switch-Ausdruck außerdem ein _ hinzu, sieht es so aus, als könnten unbekannte abgeleitete Typen entgegengenommen werden. Bei einem Design, das die Fallmenge geschlossen behandelt, ist _ jedoch eigentlich ein Zweig, der „nicht erreicht werden sollte“.

In den älteren stabilen C#-Versionen lässt sich keine so strikte Vollständigkeitsprüfung erwarten wie bei F#s diskriminierten Vereinigungen. Wird in C# daher eine record-Hierarchie verwendet, ist es sicherer, sich auf eines der beiden folgenden Vorgehen zu stützen.

  • Eine Match-Methode bereitstellen, die die Aufrufseite zwingt, alle Fälle zu behandeln
  • switch an einer Stelle bündeln, statt es über den Code zu verstreuen

Zum Beispiel lässt sich auch einer record-Hierarchie Match hinzufügen.

public abstract record CreateUserResult
{
    private CreateUserResult()
    {
    }

    public sealed record Created(User User) : CreateUserResult;
    public sealed record DuplicateEmail(string Email) : CreateUserResult;
    public sealed record WeakPassword(string Reason) : CreateUserResult;
    public sealed record SystemFailure(string Message) : CreateUserResult;

    public T Match<T>(
        Func<Created, T> created,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<WeakPassword, T> weakPassword,
        Func<SystemFailure, T> systemFailure)
    {
        return this switch
        {
            Created x => created(x),
            DuplicateEmail x => duplicateEmail(x),
            WeakPassword x => weakPassword(x),
            SystemFailure x => systemFailure(x),
            _ => throw new InvalidOperationException("Unbekanntes Ergebnis.")
        };
    }
}

So kann die Aufrufseite stets alle Fälle bewusst behandeln.

var message = result.Match(
    created => $"Erstellt: {created.User.Id}",
    duplicate => $"Duplikat: {duplicate.Email}",
    weak => $"Das Passwort ist schwach: {weak.Reason}",
    failure => $"Fehlgeschlagen: {failure.Message}");

Der Vorteil von record ist, dass Boilerplate-Code für Wertevergleich, Anzeige und Kopieren wegfällt. In einer gemeinsam genutzten Bibliothek, die auch .NET Framework unterstützen soll, ist es aber manchmal handhabbarer, statt record oder init-only-Eigenschaften zu erzwingen, eine gewöhnliche class zu schreiben.

Wichtiger als „neue Syntax zu verwenden“ ist es, „den auszudrückenden Zustand im Typ einzuschließen“.

7. F#s diskriminierte Vereinigung verwenden

Die Sprache, die algebraische Datentypen in .NET am natürlichsten handhabt, ist F#.

F# stellt diskriminierte Vereinigungen als Sprachfeature bereit.

type CreateUserResult =
    | Created of user: User
    | DuplicateEmail of email: string
    | WeakPassword of reason: string
    | SystemFailure of message: string

Auch die Verwendung ist natürlich.

let toMessage result =
    match result with
    | Created user -> $"Benutzer erstellt: {user.Id}"
    | DuplicateEmail email -> $"Diese E-Mail-Adresse wird bereits verwendet: {email}"
    | WeakPassword reason -> $"Das Passwort ist zu schwach: {reason}"
    | SystemFailure message -> $"Benutzererstellung fehlgeschlagen: {message}"

Der Vorteil von F# ist, dass Fallaufzählung und Pattern Matching in die Sprache integriert sind.

Wird ein Fall hinzugefügt, lässt sich fehlende Behandlung auf der match-Seite leicht finden. Auch ein Typ wie Option<'T>, der ausdrückt, ob ein Wert vorhanden ist oder nicht, lässt sich natürlich als diskriminierte Vereinigung nutzen.

let tryFindUser id : User option =
    // Wird der Benutzer gefunden, Some user, andernfalls None
    failwith "sample"

Indem statt null ein option zurückgegeben wird, zeigt sich „die Möglichkeit, dass nichts existiert“ bereits im Typ.

Da F#s diskriminierte Vereinigungen zu .NET-Typen kompiliert werden, lassen sie sich sowohl in F#-Projekten für .NET Framework als auch in solchen für aktuelles .NET verwenden.

Wird eine F#-diskriminierte Vereinigung jedoch direkt aus C# heraus angefasst, ist das mitunter nicht so natürlich wie innerhalb von F#.

Deshalb ist folgende Aufteilung praxisnah.

  • In der F#-internen Domänenlogik konsequent F#s diskriminierte Vereinigungen einsetzen
  • Für öffentliche APIs, die häufig aus C# aufgerufen werden, in ein DTO oder eine class-Hierarchie umwandeln, die sich auch in C# gut handhaben lässt
  • An den Grenzen auf eine andere, an JSON oder DB angepasste Darstellung abbilden

Gelingt die Trennung „innerhalb der Domäne starke Typen, an der Außengrenze handhabbare Typen“, bleibt das auch bei einer Mischung aus F# und C# gut nutzbar.

8. Eine Bibliothek wie OneOf verwenden

Will man in C# unkompliziert einen Summentyp ausdrücken, ist eine Bibliothek wie OneOf ebenfalls eine Option.

Zum Beispiel lässt sich ein Rückgabewert so ausdrücken.

using OneOf;

public sealed class DuplicateEmail
{
    public DuplicateEmail(string email)
    {
        Email = email;
    }

    public string Email { get; }
}

public sealed class WeakPassword
{
    public WeakPassword(string reason)
    {
        Reason = reason;
    }

    public string Reason { get; }
}

public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
    if (EmailExists(command.Email))
    {
        return new DuplicateEmail(command.Email);
    }

    if (!IsStrongPassword(command.Password))
    {
        return new WeakPassword("Verwenden Sie mindestens 12 Zeichen.");
    }

    return CreateUserCore(command);
}

Die Aufrufseite kann das mit Match verarbeiten.

var result = service.CreateUser(command);

var message = result.Match(
    user => $"Erstellt: {user.Id}",
    duplicate => $"Duplikat: {duplicate.Email}",
    weak => $"Das Passwort ist schwach: {weak.Reason}");

OneOf<User, DuplicateEmail, WeakPassword> bedeutet: „Dieser Wert ist genau einer von User, DuplicateEmail oder WeakPassword“.

Der Vorteil dieses Ansatzes ist, dass er sich als lokal begrenzter Rückgabewert nutzen lässt, ohne eine eigene Basisklasse anlegen zu müssen.

Besonders in der Anwendungsservice- oder Use-Case-Schicht eignet er sich, um Rückgabewerte wie diese auszudrücken.

Ergebnis der Benutzererstellung = User oder DuplicateEmail oder WeakPassword
Ergebnis des Produktabrufs = Product oder NotFound oder AccessDenied
Zahlungsergebnis = Receipt oder InsufficientFunds oder PaymentRejected

Es gibt aber auch Dinge zu beachten.

Wird ein Typ wie OneOf<A, B, C> unverändert in einer öffentlichen API ausgegeben, kann die Aussagekraft der Domänennamen verblassen.

Zum Beispiel sehen die folgenden zwei, betrachtet man nur die Typargumente, strukturell gleich aus.

OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)

In kleinem Rahmen ist das praktisch, aber soll die Domänenbedeutung klar bleiben, ist ein eigener Typ besser lesbar.

public abstract class GetUserResult
{
    // Found / NotFound / AccessDenied
}

Als Faustregel für die Wahl gilt:

  • Für einen lokal begrenzten Rückgabewert ist OneOf praktisch
  • Für ein in der Domäne wiederkehrendes Konzept einen eigenen Typ anlegen
  • Wenn die Stabilität der öffentlichen API wichtig ist, einen benannten Ergebnistyp verwenden

OneOf lässt sich zudem in einem breiten Spektrum an Zielumgebungen einschließlich .NET Framework und .NET Standard nutzen und ist daher auch für bestehende .NET-Framework-Bestände eine leicht einführbare Option.

9. Source-Generator-basierte Bibliotheken verwenden

Im aktuellen .NET gibt es auch Bibliotheken, die mit einem Source Generator Typen im Stil diskriminierter Vereinigungen erzeugen.

Manche erzeugen allein durch Hinzufügen eines Attributs Code für Switch, Map, Validierung und Serialisierungsanbindung.

Als Vorstellung etwa so.

[Union]
public partial record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record Failure(string Error) : Result<T>;
}

Solche Bibliotheken reduzieren den Boilerplate-Code für handgeschriebenes Match oder Switch. Manche warnen zudem in Kombination mit einem Analyzer vor fehlender Fallbehandlung.

Wird das in einem bestehenden System einschließlich .NET Framework eingesetzt, sollte Folgendes geprüft werden.

  • Unterstützt das Ziel-TFM .NET Framework?
  • Sind SDK, Visual Studio und MSBuild-Umgebung für den Source Generator vorhanden?
  • Liefert die CI-Umgebung dasselbe Generierungsergebnis?
  • Lässt sich der generierte Code debuggen?
  • Funktioniert die JSON-/DB-/OpenAPI-Anbindung an der Anwendungsgrenze wie erwartet?

Besonders in älteren .NET-Framework-Projekten lassen sich Pakete, die einen Source Generator voraussetzen, mitunter nicht unverändert verwenden.

Soll .NET Framework stark unterstützt werden, ist es sicherer, zunächst mit einer handgeschriebenen class-Hierarchie oder OneOf zu beginnen.

10. Zum Union-Typ in C# 15

Dieses Kapitel behandelt Inhalte im Vorschlags- und Preview-Stadium. Der Stand ist Juni 2026, und die im Folgenden gezeigte Syntax, die erzeugten Typen und der Umgang mit Pattern Matching können sich bis zur endgültigen Veröffentlichung noch ändern. Sie können auch wieder verworfen werden. Lesen Sie den Code dieses Kapitels nicht als „so lässt sich das heute schreiben“, sondern als „in diese Richtung wird diskutiert“. Grundlage sind der C#-Feature-Vorschlag (Unions - C# feature specifications) und der erläuternde Artikel im .NET Blog – beides Dokumente im Vorschlagsstadium.

Dieser Artikel wird dieses Kapitel überarbeiten, sobald der Union-Typ von C# 15 endgültig veröffentlicht ist und die Spezifikation feststeht. Bis dahin sollten Sie den Inhalt dieses Kapitels nicht als Grundlage für Designentscheidungen verwenden. Als Grundlage dienen können die stabilen Optionen aus den Kapiteln 4 bis 9.

Die Preview-Richtung erlaubt es zu deklarieren: „Dieser Typ ist genau einer der angegebenen Typen“.

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

Auf der Verwendungsseite werden die einzelnen Fälle per Pattern Matching behandelt.

static string Describe(Pet pet)
{
    return pet switch
    {
        Cat cat => $"Cat: {cat.Name}",
        Dog dog => $"Dog: {dog.Name}",
        Bird bird => $"Bird: {bird.Name}",
        Pet { Value: null } => "Unknown pet"
    };
}

Stabilisiert sich dieses Feature, lassen sich auch in C# „geschlossene Typmengen“ und „vollständiges Pattern Matching“ natürlicher handhaben.

Ist der zum Preview-Zeitpunkt erzeugte Typ ein struct, kann auch ein Wert wie default(Pet) übergeben werden, bei dem das interne Value null ist. Nimmt eine öffentliche Methode einen Union-Wert entgegen, muss auch ein solcher Standardwert defensiv behandelt werden.

Preview-Features sollten aber vor dem Einsatz in produktivem Code sorgfältig bewertet werden.

Sprachspezifikation, IDE-Unterstützung, Hilfstypen auf Laufzeitseite, Analyzer und Serializer-Anbindung können sich bis zur endgültigen Veröffentlichung noch ändern.

Für die Praxis ist daher aktuell folgende Einordnung realistisch.

  • Für neue Machbarkeitsstudien und technische Untersuchungen lohnt sich ein Test von C# union
  • Für langfristig gepflegten Produktionscode die stabilen Optionen wie F#-DU, class-/record-Hierarchie, OneOf oder Source-Generator-Bibliotheken verwenden
  • Rückgabewerte und Zustände schon jetzt als „genau einer von mehreren“ typisieren, damit ein späterer Umstieg auf C# union leichtfällt

Das heißt: Ohne auf C# union zu warten, lässt sich ADT-artiges Design schon heute umsetzen.

Wer Result, Option, Zustandstypen und Domänenereignistypen schon jetzt ordnet, erleichtert sich eher den künftigen Umstieg auf das Sprachfeature.

11. Der Option-Typ: „nicht vorhanden“ statt null ausdrücken

Das Paradebeispiel für einen algebraischen Datentyp ist Option<T>.

Option<T> drückt eines von beidem aus.

Some(value)
None

In C# wird „nicht vorhanden“ häufig mit null ausgedrückt, aber null hat das Problem, im Typ nicht sichtbar zu sein.

User user = repository.FindById(id);

// Ob user null ist, muss sich die Aufrufseite selbst merken
Console.WriteLine(user.Name);

Mit Option<User> zeigt sich „die Möglichkeit, nicht gefunden zu werden“ bereits im Typ.

Hier eine auch unter .NET Framework nutzbare, einfache Implementierung.

public abstract class Option<T>
{
    private Option()
    {
    }

    public sealed class Some : Option<T>
    {
        internal Some(T value)
        {
            Value = value;
        }

        public T Value { get; }
    }

    public sealed class None : Option<T>
    {
        internal None()
        {
        }
    }

    private static readonly None NoneValue = new None();

    public static Option<T> Of(T value)
    {
        if (object.Equals(value, null))
        {
            return NoneValue;
        }

        return new Some(value);
    }

    public static Option<T> Empty()
    {
        return NoneValue;
    }

    public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
    {
        if (some == null) throw new ArgumentNullException(nameof(some));
        if (none == null) throw new ArgumentNullException(nameof(none));

        var s = this as Some;
        if (s != null) return some(s.Value);

        return none();
    }
}

Die Verwendung sieht so aus.

Option<User> user = repository.FindById(id);

string displayName = user.Match(
    some: u => u.Name,
    none: () => "Gast");

null muss nicht vollständig verschwinden. Die vorhandenen .NET-APIs, Datenbanken und JSON liefern weiterhin null.

Innerhalb der Domänenlogik ist die Absicht mit Option<T> jedoch oft klarer als mit null.

Besonders für Methoden wie diese eignet sich Option<T>.

Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);

Entscheidend ist nicht nur, Try an den Methodennamen zu hängen, sondern auch, die Möglichkeit des „Nichtvorhandenseins“ im Rückgabetyp auszudrücken.

12. Der Result-Typ: erwartete Fehlschläge als Typ zurückgeben

Ein weiterer häufig genutzter Typ ist Result<TSuccess, TError>.

Er drückt eines von beidem aus.

Success(value)
Failure(error)

Ausnahmen eignen sich für unerwartete Fehlschläge oder solche, die man nicht dem normalen Kontrollfluss überlassen will. Geschäftlich häufig auftretende Fehlschläge sind dagegen oft besser als Typ lesbar.

Beim Login-Vorgang etwa sind diese Fehlschläge erwartbar.

  • Der Benutzer existiert nicht
  • Das Passwort ist falsch
  • Das Konto ist gesperrt
  • Multi-Faktor-Authentifizierung wird benötigt

Drückt man das ausschließlich mit Ausnahmen aus, muss die Aufrufseite geschäftliche Verzweigungen im catch unterbringen.

try
{
    var session = auth.Login(userName, password);
    return Ok(session);
}
catch (InvalidPasswordException)
{
    return Unauthorized();
}
catch (AccountLockedException)
{
    return Forbid();
}

Mit Ausnahmen funktioniert das, aber die geschäftliche Verzweigung geht leicht in der Ausnahmebehandlung unter.

ADT-artig ausgedrückt sieht das so aus.

public abstract class LoginResult
{
    private LoginResult()
    {
    }

    public sealed class Succeeded : LoginResult
    {
        internal Succeeded(Session session)
        {
            Session = session;
        }

        public Session Session { get; }
    }

    public sealed class InvalidPassword : LoginResult
    {
        internal InvalidPassword()
        {
        }
    }

    public sealed class AccountLocked : LoginResult
    {
        internal AccountLocked(DateTimeOffset until)
        {
            Until = until;
        }

        public DateTimeOffset Until { get; }
    }

    public sealed class MfaRequired : LoginResult
    {
        internal MfaRequired(string challengeId)
        {
            ChallengeId = challengeId;
        }

        public string ChallengeId { get; }
    }

    public static LoginResult Success(Session session)
        => new Succeeded(session);

    public static LoginResult WrongPassword()
        => new InvalidPassword();

    public static LoginResult Locked(DateTimeOffset until)
        => new AccountLocked(until);

    public static LoginResult RequireMfa(string challengeId)
        => new MfaRequired(challengeId);

    public T Match<T>(
        Func<Succeeded, T> succeeded,
        Func<InvalidPassword, T> invalidPassword,
        Func<AccountLocked, T> accountLocked,
        Func<MfaRequired, T> mfaRequired)
    {
        if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
        if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
        if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
        if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));

        var s = this as Succeeded;
        if (s != null) return succeeded(s);

        var i = this as InvalidPassword;
        if (i != null) return invalidPassword(i);

        var l = this as AccountLocked;
        if (l != null) return accountLocked(l);

        var m = this as MfaRequired;
        if (m != null) return mfaRequired(m);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

In dieser Form kann die Aufrufseite implementieren, während sie „die möglichen Ergebnisse des Login-Vorgangs“ vor Augen hat.

var result = auth.Login(userName, password);

return result.Match(
    succeeded => Ok(succeeded.Session),
    invalidPassword => Unauthorized(),
    accountLocked => StatusCode(423),
    mfaRequired => Accepted(new { mfaRequired.ChallengeId }));

Der Punkt ist nicht, Ausnahmen abzuschaffen.

Erwartete geschäftliche Verzweigungen als Result, unerwartete Störungen als Ausnahme – diese Rollenteilung ist das Ziel.

Schon das allein verbessert die Übersichtlichkeit der Anwendungsservice- und API-Schicht erheblich.

13. Zustandsübergänge als Typ ausdrücken

ADTs eignen sich nicht nur für Rückgabewerte, sondern auch, um Zustände auszudrücken.

Betrachten wir zum Beispiel den Zustand einer Bestellung.

public enum OrderStatus
{
    Draft,
    Submitted,
    Paid,
    Shipped,
    Cancelled
}

Mit enum allein lässt sich schwer ausdrücken, welche Daten jeder Zustand braucht.

  • Draft braucht den Ersteller
  • Submitted braucht das Übermittlungsdatum
  • Paid braucht die Zahlungsnummer
  • Shipped braucht die Sendungsnummer
  • Cancelled braucht den Stornierungsgrund

Versucht man, das mit OrderStatus und separaten Eigenschaften auszudrücken, wachsen wieder nullable Eigenschaften.

public sealed class Order
{
    public OrderStatus Status { get; set; }
    public DateTimeOffset? SubmittedAt { get; set; }
    public string PaymentNo { get; set; }
    public string TrackingNo { get; set; }
    public string CancelReason { get; set; }
}

Bei diesem Design lässt sich der Zustand Status == Draft, obwohl TrackingNo gesetzt ist, konstruieren.

ADT-artig ausgedrückt wird der Zustand selbst zum Typ.

public abstract class OrderState
{
    private OrderState()
    {
    }

    public sealed class Draft : OrderState
    {
        internal Draft(UserId createdBy)
        {
            CreatedBy = createdBy;
        }

        public UserId CreatedBy { get; }
    }

    public sealed class Submitted : OrderState
    {
        internal Submitted(DateTimeOffset submittedAt)
        {
            SubmittedAt = submittedAt;
        }

        public DateTimeOffset SubmittedAt { get; }
    }

    public sealed class Paid : OrderState
    {
        internal Paid(string paymentNo)
        {
            PaymentNo = paymentNo;
        }

        public string PaymentNo { get; }
    }

    public sealed class Shipped : OrderState
    {
        internal Shipped(string trackingNo)
        {
            TrackingNo = trackingNo;
        }

        public string TrackingNo { get; }
    }

    public sealed class Cancelled : OrderState
    {
        internal Cancelled(string reason)
        {
            Reason = reason;
        }

        public string Reason { get; }
    }
}

Die Bestellung trägt einen OrderState.

public sealed class Order
{
    public OrderId Id { get; }
    public OrderState State { get; private set; }

    public Order(OrderId id, UserId createdBy)
    {
        Id = id;
        State = new OrderState.Draft(createdBy);
    }
}

Außerdem werden die Zustandsübergänge in Methoden eingeschlossen.

public void Submit(IClock clock)
{
    if (!(State is OrderState.Draft))
    {
        throw new InvalidOperationException("Nur Bestellungen im Entwurfsstatus können übermittelt werden.");
    }

    State = new OrderState.Submitted(clock.Now);
}

public void MarkAsPaid(string paymentNo)
{
    if (!(State is OrderState.Submitted))
    {
        throw new InvalidOperationException("Nur übermittelte Bestellungen können als bezahlt markiert werden.");
    }

    State = new OrderState.Paid(paymentNo);
}

In dieser Form werden die Daten je Zustand und die Regeln der Zustandsübergänge besser lesbar.

Natürlich lässt sich beim Persistieren auch in OrderStatus plus Hilfsspalten aufteilen.

Auch dann kann intern in der Domäne weiterhin mit OrderState gearbeitet und an der DB-Grenze konvertiert werden.

Darstellung in der DB
  status = "Paid"
  payment_no = "PAY-001"

Interne Darstellung in der Domäne
  OrderState.Paid("PAY-001")

Das Domänenmodell muss nicht an das DB-Schema angepasst und dadurch geschwächt werden.

14. An der API-Grenze in ein DTO umwandeln

ADT-artige Typen sind innerhalb der Domäne sehr praktisch.

Bei JSON-APIs, DBs, Message Queues, OpenAPI und externen Anbindungen ist dagegen etwas Vorsicht geboten.

Nehmen wir an, dieses ADT wird unverändert als JSON ausgegeben.

public abstract record PaymentResult
{
    public sealed record Succeeded(string ReceiptNo) : PaymentResult;
    public sealed record Rejected(string Reason) : PaymentResult;
    public sealed record NetworkFailure(string Message) : PaymentResult;
}

Als JSON möchte man vielleicht diese Form.

{
  "type": "succeeded",
  "receiptNo": "R-001"
}

Bei einem Fehlschlag sieht das so aus.

{
  "type": "rejected",
  "reason": "card_expired"
}

Dieses type ist der Diskriminator auf der JSON-Seite.

Das Domänen-ADT und die JSON-Darstellung ähneln sich, sind aber nicht dasselbe.

Deshalb ist es an der Außengrenze sicherer, in ein DTO umzuwandeln.

public sealed class PaymentResultDto
{
    public string Type { get; set; }
    public string ReceiptNo { get; set; }
    public string Reason { get; set; }
    public string Message { get; set; }
}

In der Umwandlungslogik wird für jeden ADT-Fall ein DTO erzeugt.

public static PaymentResultDto ToDto(PaymentResult result)
{
    return result switch
    {
        PaymentResult.Succeeded x => new PaymentResultDto
        {
            Type = "succeeded",
            ReceiptNo = x.ReceiptNo
        },

        PaymentResult.Rejected x => new PaymentResultDto
        {
            Type = "rejected",
            Reason = x.Reason
        },

        PaymentResult.NetworkFailure x => new PaymentResultDto
        {
            Type = "network_failure",
            Message = x.Message
        },

        _ => throw new InvalidOperationException("Unbekanntes Zahlungsergebnis.")
    };
}

Natürlich gibt es auch die Möglichkeit, die polymorphe Serialisierung von System.Text.Json oder einen eigenen Konverter zu verwenden.

Bei einer API mit langfristiger Pflege ist es aber oft sicherer, die JSON-Form nicht eng an die interne Struktur des Domänentyps zu koppeln.

Empfehlenswert ist diese Trennung.

Innerhalb der Domäne
  PaymentResult.Succeeded
  PaymentResult.Rejected
  PaymentResult.NetworkFailure

An der API-Grenze
  PaymentResultDto
  type: "succeeded" | "rejected" | "network_failure"

Der Domänentyp bleibt auf die Darstellung des Geschäftsvorgangs konzentriert, die externe Darstellung wird über das DTO stabil gehalten.

Diese Trennung erleichtert es, die interne Domäne weiterzuentwickeln, ohne die API-Kompatibilität zu gefährden.

15. Vorteil 1: Ungültige Zustände werden schwerer konstruierbar

Der größte Vorteil eines ADT ist, dass sich ungültige Zustände schwerer konstruieren lassen.

Bei einem Typ wie diesem lassen sich zum Beispiel leicht ungültige Kombinationen erzeugen.

public sealed class Reservation
{
    public bool IsCancelled { get; set; }
    public DateTimeOffset? CancelledAt { get; set; }
    public string CancelReason { get; set; }
    public DateTimeOffset? ConfirmedAt { get; set; }
}

Mit diesem Typ lassen sich solche Zustände konstruieren.

  • Nicht storniert, aber CancelledAt ist gesetzt
  • Storniert, aber CancelReason fehlt
  • Storniert, aber ConfirmedAt ist gesetzt
  • Vor der Bestätigung, aber ein Bestätigungsdatum ist vorhanden

ADT-artig ausgedrückt lassen sich die je Zustand nötigen Daten trennen.

public abstract class ReservationState
{
    private ReservationState()
    {
    }

    public sealed class Requested : ReservationState
    {
        internal Requested(DateTimeOffset requestedAt)
        {
            RequestedAt = requestedAt;
        }

        public DateTimeOffset RequestedAt { get; }
    }

    public sealed class Confirmed : ReservationState
    {
        internal Confirmed(DateTimeOffset confirmedAt)
        {
            ConfirmedAt = confirmedAt;
        }

        public DateTimeOffset ConfirmedAt { get; }
    }

    public sealed class Cancelled : ReservationState
    {
        internal Cancelled(DateTimeOffset cancelledAt, string reason)
        {
            CancelledAt = cancelledAt;
            Reason = reason;
        }

        public DateTimeOffset CancelledAt { get; }
        public string Reason { get; }
    }
}

So trägt nur der stornierte Zustand ein Stornierungsdatum und einen Grund.

Ungültige Kombinationen werden nicht erst im Nachhinein geprüft, sondern bereits im Design reduziert.

Das zählt auch aus Testsicht viel.

Wächst die Kombination aus bool und nullable Eigenschaften, explodiert die Anzahl der Kombinationen. Mit einem ADT ordnen sich die zu testenden Fälle zu den „definierten Fällen“.

16. Vorteil 2: Die Aufrufseite wird auf fehlende Fallbehandlung aufmerksam

Ein ADT zeigt der Aufrufseite, „welche Fälle dieser Wert haben kann“.

Sieht man zum Beispiel den folgenden Rückgabewert, erkennt die Aufrufseite, dass sie Found, NotFound und Forbidden behandeln muss.

public abstract class GetDocumentResult
{
    private GetDocumentResult()
    {
    }

    public sealed class Found : GetDocumentResult
    {
        internal Found(Document document)
        {
            Document = document;
        }

        public Document Document { get; }
    }

    public sealed class NotFound : GetDocumentResult
    {
        internal NotFound(DocumentId id)
        {
            Id = id;
        }

        public DocumentId Id { get; }
    }

    public sealed class Forbidden : GetDocumentResult
    {
        internal Forbidden(UserId userId)
        {
            UserId = userId;
        }

        public UserId UserId { get; }
    }

    public static GetDocumentResult DocumentFound(Document document)
        => new Found(document);

    public static GetDocumentResult DocumentNotFound(DocumentId id)
        => new NotFound(id);

    public static GetDocumentResult AccessForbidden(UserId userId)
        => new Forbidden(userId);

    public T Match<T>(
        Func<Found, T> found,
        Func<NotFound, T> notFound,
        Func<Forbidden, T> forbidden)
    {
        if (found == null) throw new ArgumentNullException(nameof(found));
        if (notFound == null) throw new ArgumentNullException(nameof(notFound));
        if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));

        var f = this as Found;
        if (f != null) return found(f);

        var n = this as NotFound;
        if (n != null) return notFound(n);

        var d = this as Forbidden;
        if (d != null) return forbidden(d);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

Wird nur null zurückgegeben, ist unklar, ob „nicht vorhanden“, „keine Berechtigung“ oder „der Abruf ist fehlgeschlagen“ gemeint ist.

Bei bloßen Ausnahmen wird schwer erkennbar, welche Ausnahme geschäftlich zu erwarten ist.

Als GetDocumentResult ausgedrückt, wird die Methodensignatur selbst zur Spezifikation.

GetDocumentResult GetDocument(UserId userId, DocumentId documentId);

Diese Methode liefert nicht nur ein Dokument zurück.

Sie trägt einen API-Vertrag, der entweder „gefunden“, „nicht gefunden“ oder „keine Berechtigung“ zurückgibt.

Mit Match lässt sich fehlende Behandlung zusätzlich leichter bemerken.

return result.Match(
    found => Ok(found.Document),
    notFound => NotFound(),
    forbidden => Forbid());

Wird ein neuer Fall hinzugefügt und wachsen dadurch die Parameter von Match, lässt sich eine fehlende Aktualisierung der Aufrufseite leicht zur Kompilierzeit finden.

Das zahlt sich bei langfristiger Pflege sehr aus.

17. Vorteil 3: Domänenbegriffe bleiben im Code erhalten

Drückt man Zustände nur mit bool, int, string und null aus, verschwindet die geschäftliche Bedeutung aus dem Code.

return false;

Was bedeutet dieses false?

  • Nicht gefunden
  • Ungültige Eingabe
  • Keine Berechtigung
  • Externer Dienst war nicht erreichbar
  • Bereits verarbeitet

Ohne den Kontext zu kennen, versteht die Aufrufseite das nicht.

Mit einem ADT bleiben geschäftliche Begriffe als Typ erhalten.

return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);

Dieser Unterschied ist groß.

In Code-Reviews, Protokollen und Tests werden Domänenbegriffe sichtbar.

Zum Beispiel werden auch Testnamen natürlicher.

[Fact]
public void Erneutes_Einreichen_einer_bereits_eingereichten_Bestellung_liefert_AlreadySubmitted()
{
    var result = service.Submit(orderId);

    Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}

Das ist keine bloße Implementierungstechnik, sondern eine Methode, die geschäftliche Spezifikation im Code zu bewahren.

18. Vorteil 4: Weniger übermäßiger Einsatz von Ausnahmen

Die Ausnahmen von .NET sind mächtig.

Werden aber auch geschäftlich häufig auftretende Verzweigungen zu Ausnahmen gemacht, kann die Übersichtlichkeit der Verarbeitung leiden.

Betrachten wir zum Beispiel die Lagerbestandszuweisung.

Lagerbestandsmangel ist systemisch keine Anomalie. Es ist ein geschäftlich normal auftretendes Ergebnis.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<OutOfStock, T> outOfStock)
    {
        if (reserved == null) throw new ArgumentNullException(nameof(reserved));
        if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));

        var r = this as Reserved;
        if (r != null) return reserved(r);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

So ausgedrückt, wird Lagerbestandsmangel zu einem gewöhnlichen Ergebnis namens OutOfStock.

var result = stock.Reserve(sku, quantity);

return result.Match(
    reserved => Ok(reserved.ReservationId),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

Dinge wie eine abgerissene DB-Verbindung, eine defekte Konfigurationsdatei oder eine unerwartete Inkonsistenz dürfen dagegen Ausnahmen bleiben.

Als Entscheidungskriterium ist etwa diese Grenzziehung praxistauglich.

Was die Aufrufseite als normale Verzweigung behandeln soll
  => als Result / ADT zurückgeben

Was sich normale Verarbeitung nicht davon erholen kann
  => zur Ausnahme machen

Mit dieser Aufteilung lässt sich vermeiden, dass try-catch zum Ersatz für geschäftliche Verzweigungen wird.

19. Vorteil 5: Tests werden leichter zu schreiben

Mit einem ADT werden die zu testenden Fälle klar.

Nehmen wir zum Beispiel an, es gibt diesen Ergebnistyp.

SubmitOrderResult =
  Submitted(orderId)
  oder AlreadySubmitted(orderId)
  oder InvalidOrder(reason)
  oder CreditLimitExceeded(limit)

In diesem Fall teilen sich die Tests von selbst nach Fällen auf.

Bei einer gültigen Bestellung wird Submitted zurückgegeben
Bei bereits übermittelter Bestellung wird AlreadySubmitted zurückgegeben
Bei ungültiger Bestellung wird InvalidOrder zurückgegeben
Bei überschrittenem Kreditrahmen wird CreditLimitExceeded zurückgegeben

Drückt man den Zustand über eine Kombination nullable Eigenschaften aus, muss auch die Testseite verstehen, „welche Kombination gültig ist“.

Mit einem ADT wird der Fall selbst zum Testgesichtspunkt.

Auch Testdaten lassen sich leichter erstellen.

var result = SubmitOrderResult.CreditLimitExceeded(limit);

Mit dieser einen Zeile lässt sich ein Datensatz mit der Bedeutung „Kreditrahmen überschritten“ erzeugen.

Das ist eindeutiger, als Status, ErrorCode, Message und Limit zu kombinieren, um ein plausibel wirkendes Objekt zu bauen.

20. Einführungsstrategie für .NET Framework

Wird ADT-artiges Design in ein bestehendes .NET-Framework-System eingeführt, ist es besser, nicht abrupt große Änderungen vorzunehmen.

Empfehlenswert ist, zunächst bei Rückgabewerten anzufangen. Suchen Sie im bestehenden Code nach Dingen wie diesen.

  • bool TryXxx(...), aber der Fehlgrund wird ebenfalls gebraucht
  • null wird zurückgegeben, aber es gibt mehrere Gründe, warum nichts gefunden wurde
  • enum Status und nullable Hilfseigenschaften wachsen
  • Ausnahmen drücken geschäftliche Verzweigungen aus
  • Der String-Vergleich von ErrorCode breitet sich aus

An solchen Stellen zeigt eine Umstellung auf ADT ihre Wirkung besonders deutlich.

Als Nächstes wird ein eigener Ergebnistyp angelegt.

public abstract class RegisterMemberResult
{
    private RegisterMemberResult()
    {
    }

    public sealed class Registered : RegisterMemberResult
    {
        internal Registered(MemberId memberId)
        {
            MemberId = memberId;
        }

        public MemberId MemberId { get; }
    }

    public sealed class DuplicateEmail : RegisterMemberResult
    {
        internal DuplicateEmail(string email)
        {
            Email = email;
        }

        public string Email { get; }
    }

    public sealed class InvalidInvitationCode : RegisterMemberResult
    {
        internal InvalidInvitationCode(string code)
        {
            Code = code;
        }

        public string Code { get; }
    }

    public T Match<T>(
        Func<Registered, T> registered,
        Func<DuplicateEmail, T> duplicateEmail,
        Func<InvalidInvitationCode, T> invalidInvitationCode)
    {
        if (registered == null) throw new ArgumentNullException(nameof(registered));
        if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
        if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));

        var r = this as Registered;
        if (r != null) return registered(r);

        var d = this as DuplicateEmail;
        if (d != null) return duplicateEmail(d);

        var i = this as InvalidInvitationCode;
        if (i != null) return invalidInvitationCode(i);

        throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
    }
}

Und an der bestehenden API-Grenze wird sofort in das DTO oder alte Format umgewandelt.

var result = service.Register(command);

return result.Match(
    registered => new RegisterMemberResponse
    {
        Success = true,
        MemberId = registered.MemberId.Value
    },
    duplicate => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "DuplicateEmail",
        ErrorMessage = duplicate.Email + " wird bereits verwendet."
    },
    invalidCode => new RegisterMemberResponse
    {
        Success = false,
        ErrorCode = "InvalidInvitationCode",
        ErrorMessage = "Der Einladungscode ist ungültig."
    });

Ohne die externe Schnittstelle sofort zu ändern, lässt sich allein die interne Logik stärken.

Das ist in bestehenden Systemen sehr wichtig.

Umstände der externen API oder Bildschirme
  Bestehendes Antwortformat beibehalten

Interne Domänenlogik
  Sicher mit ADT-artigem Typ behandeln

Allein die Umwandlung an der Grenze reicht, um die interne Verzweigung deutlich zu ordnen.

21. Als gemeinsam genutzte Bibliothek mit .NET Standard

Für eine Bibliothek, die sowohl von .NET Framework als auch vom aktuellen .NET genutzt wird, gibt es die Option .NET Standard.

Besonders bei hohem Wert auf breite Kompatibilität ist .NET Standard 2.0 eine realistische Wahl.

Zum Beispiel legt man Domänenmodell und Ergebnistypen in eine Bibliothek mit dieser Struktur.

MyApp.Domain
  TargetFramework: netstandard2.0

MyApp.LegacyWeb
  TargetFramework: net472
  referenziert MyApp.Domain

MyApp.Api
  TargetFramework: net8.0
  referenziert MyApp.Domain

Mit dieser Struktur lässt sich derselbe Domänentyp leicht zwischen der alten .NET-Framework-Anwendung und der neuen .NET-Anwendung teilen.

Zielt man jedoch auf .NET Standard 2.0, sollte man sich nicht zu stark auf neue C#-/.NET-APIs verlassen.

Zum Beispiel ist es in einer gemeinsam genutzten Bibliothek oft ratsam, folgende Designs zu vermeiden.

  • Starke Abhängigkeit von record oder init
  • Direkte Nutzung von .NET-6-oder-neuer-APIs
  • Breite Veröffentlichung von Code, der einen Source Generator voraussetzt
  • ASP.NET-Core-spezifische Typen in der Domänenschicht

In einer gemeinsam genutzten Bibliothek bleibt es langfristig gut nutzbar, sich auf einfache class, Value Objects und ADT-artige Ergebnistypen zu konzentrieren.

public abstract class PaymentResult
{
    private PaymentResult()
    {
    }

    // Als gewöhnliche class ausgedrückt, gut nutzbar sowohl unter .NET Framework als auch unter .NET
}

In der neuen, nur für .NET gedachten Anwendungsschicht lassen sich record und switch expression nutzen.

Gemeinsame Domänenschicht
  Typen, die auch in alten Umgebungen lesbar sind

Neue Anwendungsschicht
  record / Pattern Matching / minimale APIs und Ähnliches nutzen

Diese Trennung erleichtert es, die Balance zwischen bestehenden Beständen und neuer Entwicklung zu halten.

22. Wie weit sollte man ADT einsetzen?

ADTs sind praktisch, aber es muss nicht alles zu einem ADT werden.

Geeignet ist alles, dessen Fallmenge geschäftlich nahezu geschlossen ist. Zum Beispiel diese Kandidaten.

  • Verarbeitungsergebnis
  • Eingabevalidierungsergebnis
  • Bestellstatus
  • Zahlungsergebnis
  • Authentifizierungsergebnis
  • Ergebnis eines externen Dienstaufrufs
  • Domänenereignis
  • Befehlsart
  • Bildschirmzustand

Umgekehrt gibt es auch Fälle, bei denen Vorsicht geboten ist.

  • Dinge, deren Fallmenge durch Plugins von außen wächst
  • Dinge, deren Fallmenge durch Benutzerdefinition wächst
  • Dinge, die im laufenden Betrieb als DB-Stammdaten wachsen
  • Framework-Anbindungstypen, die auf Vererbungserweiterung ausgelegt sind
  • Einfache CRUD-DTOs

Wächst die Fallmenge von außen, passt eine Schnittstelle oder eine gewöhnliche Vererbungshierarchie besser als ein geschlossenes ADT.

Wächst zum Beispiel das Ausgabeformat für Berichte durch Plugins, ist ein Design wie dieses naheliegend.

public interface IReportExporter
{
    string FormatName { get; }
    void Export(Report report, Stream output);
}

In diesem Fall wird eine externe Erweiterung schwierig, wenn man daraus einen geschlossenen Summentyp wie PdfExporter | ExcelExporter | CsvExporter macht.

ADT ist ein Design, das in einer „geschlossenen Welt“ stark ist.

Ist es geschäftlich wirklich geschlossen? Könnte es künftig von außen wachsen?

Das zu erkennen, ist entscheidend.

23. Abgrenzung zu enum

enum ist nicht schlecht.

enum eignet sich, wenn kein Fall zusätzliche Daten trägt und eine einfache Bezeichnung genügt. Zum Beispiel diese Dinge.

public enum Gender
{
    Unknown,
    Male,
    Female,
    Other
}

Oder etwas wie ein Log-Level.

public enum LogLevel
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical
}

Unterscheiden sich die je Fall nötigen Daten dagegen, kommt ein ADT-artiger Typ infrage.

PaymentStatus als enum
  Succeeded
  Rejected
  Failed

PaymentResult als ADT
  Succeeded(receiptNo)
  Rejected(reason)
  Failed(message)

Das Unterscheidungskriterium ist einfach.

Es reicht, die Fälle zu kennen
  => enum

Die Daten unterscheiden sich je Fall
  => ADT

Verhalten oder Einschränkungen unterscheiden sich je Fall
  => ADT oder class-Hierarchie

Fängt enum + eine Gruppe nullable Eigenschaften an zu wachsen, ist das ein Signal für die Umstellung auf ADT.

24. Abgrenzung zu bool

Auch bool ist nicht schlecht.

Reicht die Bedeutung wirklich mit ja/nein aus, genügt bool.

bool IsEnabled { get; }
bool IsDeleted { get; }

Gibt es aber mehrere Fehlgründe, wird bool schwach.

bool TryCreateUser(CreateUserCommand command);

Bei dieser Methode ist bei einem Fehlschlag der Grund nicht erkennbar.

Man kann das mit einem out-Parameter ergänzen.

bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);

Das wird aber zunehmend komplex.

In diesem Fall ist ein Ergebnistyp besser lesbar.

CreateUserResult CreateUser(CreateUserCommand command);

Auch die Aufrufseite kann dann nicht nur Erfolg/Fehlschlag, sondern auch die Art des Fehlschlags als Typ behandeln.

return result.Match(
    created => Ok(created.User),
    duplicate => Conflict(),
    weak => BadRequest(),
    failure => StatusCode(500));

Das Entscheidungskriterium ist dieses.

Wirklich nur zwei Optionen, keine Zusatzinformation nötig
  => bool

Zwei Optionen, aber Erfolgswert oder Fehlgrund werden gebraucht
  => Result

Drei oder mehr Optionen, oder die Daten unterscheiden sich je Fall
  => ADT

25. Der Unterschied zwischen Vererbung und ADT

Baut man in C# einen ADT-artigen Typ, sieht das äußerlich einer gewöhnlichen Vererbung ähnlich.

public abstract class PaymentResult
{
}

public sealed class Succeeded : PaymentResult
{
}

public sealed class Rejected : PaymentResult
{
}

Der Zweck ist aber etwas anders.

Gewöhnliche objektorientierte Vererbung wird häufig genutzt, um Verhalten austauschbar zu machen.

public abstract class Shape
{
    public abstract double Area();
}

public sealed class Circle : Shape
{
    public override double Area() => ...;
}

ADT-artige Vererbung dagegen dient dazu, „die möglichen Formen der Daten“ auszudrücken.

public abstract class PaymentResult
{
    public sealed class Succeeded : PaymentResult
    {
        public string ReceiptNo { get; }
    }

    public sealed class Rejected : PaymentResult
    {
        public string Reason { get; }
    }
}

Es geht nicht darum, welches richtig ist.

Soll die Verarbeitung in jedem Fall selbst liegen, eignet sich gewöhnlicher Polymorphismus.

public abstract class Notification
{
    public abstract void Send();
}

Soll die Aufrufseite alle Fälle im Blick verzweigen, eignet sich ADT + Pattern Matching / Match.

return notification.Match(
    email => SendEmail(email),
    sms => SendSms(sms),
    push => SendPush(push));

In Fachanwendungen ist die Aufteilung „Rückgabewerte und Zustände als ADT, austauschbares Verhalten über eine Schnittstelle“ leicht verständlich.

26. Pattern Matching nicht zu weit verstreuen

Beginnt man, ADT zu nutzen, will man schnell überall switch oder Match schreiben.

Verstreut sich dieselbe Verzweigung aber über mehrere Stellen, wachsen die Änderungsorte bei jedem neuen Fall.

Nehmen wir an, PaymentResult wird an verschiedenen Stellen mit switch behandelt.

Umwandlung der API-Antwort
Protokollausgabe
Erzeugung von Bildschirmmeldungen
Erfassung von Metriken
Erzeugung von Audit-Protokollen

Wird ein Fall hinzugefügt, müssen alle switch-Stellen angepasst werden.

Das lässt sich nicht immer vermeiden, aber die Verantwortung für die Verzweigung möglichst zu bündeln, erleichtert die Pflege.

public static class PaymentResultMapper
{
    public static PaymentResultDto ToDto(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }

    public static string ToLogMessage(PaymentResult result)
    {
        return result.Match(
            succeeded => ...,
            rejected => ...,
            failure => ...);
    }
}

Manchmal ist es auch besser, statt zu verzweigen, die Verarbeitung dem Fall selbst zu übergeben.

public abstract class PaymentResult
{
    public abstract bool IsSuccess { get; }
}

Trägt der Fall aber zu viel Verarbeitung, beginnt der Domänentyp, Belange von API oder UI zu kennen.

Solche Verarbeitung gehört meist besser nicht direkt in den Domänentyp.

  • Umwandlung in HTTP-Statuscodes
  • Umwandlung in ein JSON-DTO
  • Anzeigemeldungen für Bildschirme
  • Format der Protokolle
  • Darstellung für OpenAPI

Der Domänentyp drückt die geschäftliche Bedeutung aus. Die Umwandlung an der Grenze gehört in einen Mapper.

Wird diese Trennung beachtet, bleibt ein ADT langfristig gut pflegbar.

27. Namensgebung

Bei ADT-artigen Typen ist der Name wichtig.

Allgemeine Namen wie Result, Error oder Response allein tragen wenig Bedeutung.

Häufig genutzte Namen sind etwa diese.

CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState

Fallnamen orientieren sich am Geschäftsvokabular.

Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked

Mit Error1, Error2 oder bloßem Failed fällt es der Aufrufseite schwer, die Bedeutung zu verstehen.

Auch die Daten, die ein Fall trägt, sollten möglichst geschäftliche Typen sein.

public sealed class CreditLimitExceeded : SubmitOrderResult
{
    public Money Limit { get; }
    public Money RequestedAmount { get; }
}

decimal oder string funktionieren zwar auch, aber kombiniert mit Value Objects wie Money, Email, UserId oder OrderId wird die Absicht noch klarer.

ADT und Value Objects passen gut zusammen.

Value Object
  drückt Bedeutung und Einschränkung eines einzelnen Werts aus

ADT
  drückt mehrere mögliche Formen aus

Kombiniert man beide, lassen sich Geschäftsregeln leichter im Typ einschließen.

28. Auf Versionierung achten

Da ein ADT die Fallmenge explizit macht, wirkt sich das Hinzufügen eines Falls auf die Aufrufseite aus.

Das ist zugleich Vorteil und Warnhinweis.

Bei internem Code ist es willkommen, wenn beim Hinzufügen eines Falls ein Kompilierfehler entsteht. Denn dadurch lässt sich fehlende Behandlung finden.

Bei einem als NuGet-Paket oder öffentliche API extern bereitgestellten Typ kann das Hinzufügen eines Falls dagegen fast einer Breaking Change gleichkommen.

Nehmen wir an, ein Bibliotheksnutzer hat alle Fälle so behandelt.

var text = result.Match(
    success => ...,
    validationError => ...,
    permissionDenied => ...);

Fügt die Bibliothek einen Fall RateLimited hinzu und ändert dadurch auch die Signatur von Match, führt das beim Nutzer zu einem Kompilierfehler.

Das ist zwar sicher, hat aber Auswirkungen auf die Kompatibilität der öffentlichen API.

Bei öffentlichen Bibliotheken gilt deshalb Folgendes zu bedenken.

  • Soll das Hinzufügen von Fällen erlaubt sein, die Version anheben und als Breaking Change behandeln
  • Sollen externe Nutzer eine Art default-Verarbeitung erlauben dürfen, statt eines geschlossenen ADT ein anderes Design wählen
  • Innerhalb der Domäne strikt bleiben, an der externen API mit DTO und versioniertem Vertrag arbeiten

Innerhalb einer Fachanwendung ist es willkommen, wenn beim Hinzufügen eines Falls ein Kompilierfehler entsteht.

Bei öffentlichen APIs muss die Kompatibilität mitgedacht werden.

29. Zur Performance

ADT-artiges Design kann für die Ausdrucksstärke mehr Objekte erzeugen.

Wird unter .NET Framework eine class-Hierarchie verwendet, wird für jeden Fall ein Objekt erzeugt.

return PaymentResult.Success(receiptNo);

Das ist in gewöhnlichen Fachanwendungen meist kein großes Problem.

An folgenden Stellen ist jedoch Vorsicht geboten.

  • Sehr häufig aufgerufene Low-Level-Verarbeitung
  • Stream-Verarbeitung großer Ereignismengen
  • Spiele oder Echtzeitverarbeitung
  • Verarbeitung, bei der Allokationen stark minimiert werden sollen
  • Verarbeitung, die ADTs massenhaft in großen Collections speichert

Ist Performance wichtig, gibt es mehrere Optionen.

  • Einen struct-basierten Result-Typ verwenden
  • F#s struct discriminated union in Betracht ziehen
  • Mit einem Source Generator Allokationen reduzieren
  • Auf dem Hot Path enum + dedizierte Felder verwenden und erst an der Grenze in ADT umwandeln
  • Erst messen, dann optimieren

Es ist nicht nötig, von Anfang an übermäßig zu optimieren.

In vielen Fachanwendungen wiegt die Klarheit des ADT-Designs die geringen Kosten der Objekterzeugung bei Weitem auf.

Bei strengen Performance-Anforderungen sollten Design und Messung jedoch zusammen gedacht werden.

30. Beispiel für die Refaktorierung von bestehendem Code

Zum Abschluss betrachten wir, wie sich häufig anzutreffender bestehender Code ADT-artig umbauen lässt.

Der Ausgangscode sieht so aus. Damit sich später leichter vergleichen lässt, sind die zu ändernden Stellen nummeriert.

public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
    // (1) Erfolg ist bool, der Grund ist out string. Ihre Beziehung ist im Typ nicht sichtbar
    errorCode = null;

    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        errorCode = "SKU_NOT_FOUND"; // (2) Der Fehlgrund ist ein String-Literal
        return false;
    }

    if (stock.Available < quantity)
    {
        errorCode = "OUT_OF_STOCK"; // (3) Wie viele Einheiten fehlen, erfährt die Aufrufseite nicht
        return false;
    }

    stock.Reserve(quantity);
    return true; // (4) Auch bei Erfolg wird nicht mitgeteilt, welche Zuweisung entstanden ist
}

In diesem Code wird der Fehlgrund als string ausgedrückt. Die Aufrufseite muss Strings vergleichen.

string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
    if (errorCode == "SKU_NOT_FOUND")
    {
        ...
    }
    else if (errorCode == "OUT_OF_STOCK")
    {
        ...
    }
}

Das wird nun zu einem Ergebnistyp.

public abstract class ReserveStockResult
{
    private ReserveStockResult()
    {
    }

    public sealed class Reserved : ReserveStockResult
    {
        internal Reserved(ReservationId reservationId)
        {
            ReservationId = reservationId;
        }

        public ReservationId ReservationId { get; }
    }

    public sealed class SkuNotFound : ReserveStockResult
    {
        internal SkuNotFound(Sku sku)
        {
            Sku = sku;
        }

        public Sku Sku { get; }
    }

    public sealed class OutOfStock : ReserveStockResult
    {
        internal OutOfStock(Sku sku, int requested, int available)
        {
            Sku = sku;
            Requested = requested;
            Available = available;
        }

        public Sku Sku { get; }
        public int Requested { get; }
        public int Available { get; }
    }

    public static ReserveStockResult Success(ReservationId reservationId)
        => new Reserved(reservationId);

    public static ReserveStockResult NotFound(Sku sku)
        => new SkuNotFound(sku);

    public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
        => new OutOfStock(sku, requested, available);

    public T Match<T>(
        Func<Reserved, T> reserved,
        Func<SkuNotFound, T> skuNotFound,
        Func<OutOfStock, T> outOfStock)
    {
        var r = this as Reserved;
        if (r != null) return reserved(r);

        var n = this as SkuNotFound;
        if (n != null) return skuNotFound(n);

        var o = this as OutOfStock;
        if (o != null) return outOfStock(o);

        throw new InvalidOperationException("Unbekanntes Lagerbestandszuweisungsergebnis.") ;
    }
}

Die Service-Methode sieht dann so aus. Die Nummern entsprechen dem Ausgangscode.

public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
    // (1) Ein einzelner Rückgabewert deckt alle möglichen Ergebnisfälle ab. Ein out-Parameter ist nicht nötig
    var stock = stockRepository.Find(sku);
    if (stock == null)
    {
        return ReserveStockResult.NotFound(sku); // (2) Ein Falltyp statt eines Strings
    }

    if (stock.Available < quantity)
    {
        // (3) Auch die Mangelsituation (angeforderte Menge und Lagerbestand) trägt der Fall
        return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
    }

    var reservationId = stock.Reserve(quantity);
    return ReserveStockResult.Success(reservationId); // (4) Nur der Erfolgsfall trägt die Zuweisungs-ID
}

Die Änderungen im Überblick.

# vorher nachher
(1) Rückgabewert bool + out string errorCode Ein einzelner Ergebnistyp ReserveStockResult
(2) Ein String "SKU_NOT_FOUND" Fall SkuNotFound (trägt Sku)
(3) Nur "OUT_OF_STOCK", die Fehlmenge bleibt unbekannt Fall OutOfStock (trägt Requested und Available)
(4) Bei Erfolg nur true Fall Reserved trägt ReservationId

Die Aufrufseite kann auf String-Vergleiche verzichten.

var result = service.ReserveStock(sku, quantity);

return result.Match(
    reserved => Ok(new { reserved.ReservationId }),
    notFound => NotFound(new { sku = notFound.Sku.Value }),
    outOfStock => Conflict(new
    {
        sku = outOfStock.Sku.Value,
        requested = outOfStock.Requested,
        available = outOfStock.Available
    }));

Der Kern dieser Refaktorierung ist, dass sich die interne Bedeutung in den Typ verlagern lässt, ohne das äußere Verhalten zu ändern.

Zunächst wird der Rückgabewert gestärkt. Als Nächstes wird die Aufrufseite auf Match umgestellt. Zuletzt werden String-Fehlercodes und nullable Hilfseigenschaften abgebaut.

In dieser Reihenfolge lässt sich das auch in bestehenden Systemen schrittweise einführen.

31. Checkliste für die Einführung

Beim Anlegen eines ADT-artigen Typs lohnt es sich, Folgendes zu prüfen.

Drückt dieser Typ „genau einer von mehreren“ aus?
Ist die Fallmenge geschäftlich geschlossen?
Unterscheiden sich die je Fall nötigen Daten?
Bricht die Bedeutung bei bool / enum / null / string-Fehlercode zusammen?
Soll die Aufrufseite auf alle Fälle achten müssen?
Beeinträchtigt das die Kompatibilität einer öffentlichen API?
Gibt es eine Umwandlungsstrategie für JSON / DB / Bildschirm-DTO?
Reicht eine gewöhnliche class, wenn auch .NET Framework genutzt wird?
Lohnt sich record oder ein Source Generator, wenn nur aktuelles .NET gilt?

Die Implementierungsstrategie lässt sich so wählen.

F#-Projekt
  F#s diskriminierte Vereinigung verwenden

C# unter .NET Framework
  abstract class + private-Konstruktor + verschachtelte sealed-Klassen + Match

C# ab .NET 5
  abstract record + sealed record-Fälle + Pattern Matching

Lokal begrenzter Rückgabewert
  eine Bibliothek wie OneOf

Boilerplate im aktuellen .NET reduzieren
  Source-Generator-basierte Bibliotheken

Zukünftige Machbarkeitsstudie
  C# 15 Union-Preview

Unabhängig von der gewählten Methode bleibt das Ziel dasselbe.

Regeln, die bisher Kommentare schützten, mit dem Typ schützen.

Das ist die größte Bedeutung des Einsatzes von ADT.

32. Zusammenfassung

Algebraische Datentypen gehören nicht nur funktionalen Sprachen.

Auch in C# unter .NET Framework lassen sie sich mit abstrakten Klassen und sealed-Klassen ausreichend praktisch nutzen. Im C# des aktuellen .NET lassen sie sich mit record und Pattern Matching noch knapper schreiben. In F# lässt sich das Sprachfeature der diskriminierten Vereinigung direkt nutzen. Mit Bibliotheken lassen sich auch in C# unkompliziert OneOf oder Result einsetzen.

Wichtig ist nicht die Syntax, sondern der Design-Gedanke.

Überdenken Sie, was Sie bisher mit bool, null, enum + nullable Eigenschaften oder string ErrorCode ausgedrückt haben, und fragen Sie sich:

Welcher der Fälle ist dieser Wert genau?
Welche Daten braucht jeder Fall?
Welche Daten dürfen außerhalb dieses Falls nicht existieren?
Was soll die Aufrufseite unbedingt behandeln müssen?

Bauen Sie Typen, die diese Fragen beantworten, und ungültige Zustände nehmen ab, Verzweigungen werden übersichtlicher, und Geschäftsvokabular bleibt im Code erhalten.

In bestehenden Systemen empfiehlt es sich, zunächst bei den Rückgabewerten zu beginnen.

Ersetzen Sie Stellen, an denen sich TryXxx, null, ErrorCode und ausnahmegetriebene geschäftliche Verzweigungen häufen, probeweise durch einen eigenen Ergebnistyp.

Allein das verändert Lesbarkeit und Sicherheit des Codes bereits erheblich.

Referenzen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Was ist ein algebraischer Datentyp (ADT)?
Ein algebraischer Datentyp ist eine Kombination aus Produkttypen (ein Typ, der sowohl A als auch B hält) und Summentypen (ein Typ, der entweder A oder B ist). In der Praxis bedeutet das: Die Tatsache, dass „dieser Wert von vornherein eine feste Menge möglicher Formen hat“, wird nicht durch Kommentare oder Namenskonventionen, sondern durch den Typ selbst ausgedrückt. Besonders häufig genutzt wird der Summentyp – zum Beispiel lässt sich ein Zahlungsergebnis als „genau eines von Succeeded(receiptNo), Rejected(reason) oder NetworkFailure(message)“ durch den Typ ausdrücken, sodass sich ein ungültiger Zustand gar nicht erst konstruieren lässt.
Wie implementiert man in C# eine diskriminierte Vereinigung (einen Summentyp)?
Am leichtesten einzuführen ist in bestehenden Systemen einschließlich .NET Framework das Muster aus einer abstrakten Basisklasse mit private-Konstruktor, verschachtelten sealed-Klassen und einer Match-Methode. Da nur die verschachtelten Typen die Basisklasse erben können, entsteht eine geschlossene Menge an Fällen. Ab .NET 5 lässt sich das mit einer Hierarchie aus abstract record und sealed record noch knapper schreiben. Für einen lokal begrenzten Rückgabewert ist auch eine Bibliothek wie OneOf eine Option, und in F# lassen sich diskriminierte Vereinigungen natürlich als Sprachfeature nutzen.
Wann sollte man einen algebraischen Datentyp statt enum oder bool verwenden?
Reicht es, nur die Fälle zu unterscheiden, genügt enum; ist es wirklich eine reine Ja/Nein-Entscheidung ohne zusätzliche Information, genügt bool. Unterscheiden sich die Daten je Fall dagegen (bei Erfolg receiptNo, bei unzureichendem Guthaben shortage und so weiter), passt ein ADT besser. Fängt die Kombination aus enum und einer Gruppe nullable Eigenschaften an zu wachsen, oder beginnt man, Fehlgründe über string-Fehlercodes zu vergleichen, ist das ein Signal für die Umstellung auf ein ADT. Wächst die Menge der Fälle dagegen von außen, etwa durch Plugins, passt eine Schnittstelle besser als ein geschlossenes ADT.
Sollte ein geschäftlicher Fehlschlag durch eine Ausnahme oder einen Result-Typ dargestellt werden?
In der Praxis bewährt sich diese Rollenteilung: erwartete Fehler, die der Aufrufer als normale Verzweigung behandeln soll (Lagerbestand knapp, doppelte E-Mail-Adresse, falsches Passwort und Ähnliches), werden als Result/ADT zurückgegeben; unerwartete Störungen, von denen sich die normale Verarbeitung nicht erholen kann (abgerissene DB-Verbindung, defekte Konfigurationsdatei), bleiben Ausnahmen. Werden auch geschäftlich häufig auftretende Verzweigungen zu Ausnahmen gemacht, wird try-catch zum Ersatz für Geschäftslogik-Verzweigungen, und die Übersicht leidet. Schon diese Rollenteilung verbessert die Übersichtlichkeit der Anwendungsservice- und API-Schicht erheblich.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog