Die dritte Frage einer Klasse: Wer darf sie verändern, und in welche Zustände kann sie dabei geraten?
public class Warenkorb
{
public List<Position> Positionen { get; set; }
public decimal Gesamtbetrag { get; set; }
public string KundenNummer { get; set; }
}Diese Klasse verspricht nichts. Jeder darf alles, jederzeit:
korb.Positionen = null; // erlaubt
korb.Positionen.Add(new Position(-5)); // negative Menge, erlaubt
korb.Gesamtbetrag = 0; // passt jetzt nicht mehr zu den Positionen
korb.KundenNummer = ""; // erlaubtJede dieser Zeilen erzeugt einen Warenkorb, den es fachlich nicht geben darf. Und weil jeder sie schreiben kann, muss jede Stelle, die einen Warenkorb liest, mit diesen Zuständen rechnen.
Der Konstruktor stellt die Gültigkeit her
public sealed class Warenkorb
{
private readonly List<Position> _positionen = new List<Position>();
public Warenkorb(string kundenNummer)
{
if (string.IsNullOrWhiteSpace(kundenNummer))
throw new ArgumentException("Kundennummer fehlt", nameof(kundenNummer));
KundenNummer = kundenNummer;
}
public string KundenNummer { get; }
// Nach aussen lesbar, aber nicht veränderbar
public IReadOnlyList<Position> Positionen => _positionen;
// Kein Setter: der Betrag folgt aus den Positionen
public decimal Gesamtbetrag => _positionen.Sum(p => p.Menge * p.Einzelpreis);
public void FuegeHinzu(Position position)
{
if (position.Menge < 1)
throw new ArgumentOutOfRangeException(nameof(position), "Menge muss positiv sein");
_positionen.Add(position);
}
}Jetzt gilt: Wenn ein Warenkorb existiert, ist er gültig. Er hat eine Kundennummer, seine Positionen haben positive Mengen, und der Gesamtbetrag passt immer zu ihnen, weil er berechnet und nicht gespeichert wird.
Das ist der eigentliche Zweck von Kapselung — nicht «Felder privat machen», sondern Zustände unmöglich machen, die es nicht geben darf.
`IReadOnlyList` ist eine Ansage, kein Schloss
Positionen gibt die interne Liste als IReadOnlyList<Position> heraus. Ein Aufrufer kann sie zurück auf List<Position> casten und doch verändern. Für die meisten Projekte ist die Ansage genug; wo es wirklich darauf ankommt, gib _positionen.ToArray() oder .AsReadOnly() heraus.
Werte als record
Für Objekte, die nur Daten transportieren und keine Identität haben — Geldbeträge, Adressen, Zeiträume, Koordinaten — gibt es seit C# 9 eine kürzere Form:
public sealed record Adresse(string Strasse, string Plz, string Ort);
var a = new Adresse("Bahnhofstrasse 1", "8001", "Zürich");
var b = new Adresse("Bahnhofstrasse 1", "8001", "Zürich");
Console.WriteLine(a == b); // True — Werte, keine Referenzen
var neu = a with { Ort = "Winterthur" }; // Kopie mit einer Änderungrecord bringt Wertgleichheit, ToString, GetHashCode und with mit — und die Eigenschaften sind standardmässig init, also nach der Erzeugung unveränderlich. Unveränderliche Objekte haben keine ungültigen Zwischenzustände und sind nebenläufig unbedenklich.
Anämische Modelle
Der Gegenpol zur Klasse mit zu vielen Aufgaben ist die Klasse ohne jede:
Daten ohne Verhalten
public class Konto
{
public decimal Saldo { get; set; }
}
// Die Regel steht woanders:
if (konto.Saldo >= betrag)
konto.Saldo -= betrag;
else
throw new …;
// … und noch einmal woanders
// … und noch einmalVerhalten bei den Daten
public class Konto
{
public decimal Saldo
{ get; private set; }
public void Belaste(decimal betrag)
{
if (betrag > Saldo)
throw new
DeckungFehltException();
Saldo -= betrag;
}
}Links kann jede Stelle den Saldo verändern und muss die Deckungsprüfung selbst kennen. Rechts gibt es genau einen Weg, und die Regel steht dort, wo die Daten liegen.
Wo reine Datenklassen richtig sind
An den Rändern: DTOs für eine API, Zeilen aus einer Datei, Konfigurationsobjekte. Dort ist «nur Daten» genau richtig. Falsch wird es erst, wenn die fachlichen Regeln eines Kernbegriffs über zehn Services verteilt sind, statt bei ihm zu stehen.