Agiler Code, stabiles Modell: Agile, OOP und Code First in einem Ablauf
Eine praktische Lektion vom Akzeptanzkriterium über Objekt- und Datenmodell bis zu EF-Core-Migration, Tests und sicherem Release. Lies, entscheide und sieh die Folgen.
Der vollständige Artikel steht unten. Aktivieren Sie JavaScript für Quiz und Simulationen.
Workshop: von der Anforderung zum sicheren Release
Du lernst nicht drei getrennte Techniken, sondern baust einen funktionierenden Ablauf.
Agile definiert den kleinen Schritt und seine Abnahme. OOP verankert Regeln im Modell. Code First überträgt das Modell in die Datenbank. Ist ein Teil schwach, kann der Pull Request gut aussehen und Produktion trotzdem scheitern.
Agile, objektorientierte Programmierung und Code First werden oft getrennt erklärt: Agile am Aufgabenboard, OOP im Klassenlehrbuch, Code First in der EF-Core-Dokumentation. In Produktion treffen sie in einem Pull Request zusammen.
Wir bauen einen Ausschnitt eines Arbeitsmanagementsystems: Projekte haben Sprints, Sprints Aufgaben, Aufgaben können Teammitgliedern zugewiesen werden. Wir beginnen beim gewünschten Verhalten statt bei der Tabelle und gehen bis zur sicheren Migration.
1. Agilität beginnt mit Grenzen
Unsere Story klingt harmlos: Als Projektleiter möchte ich eine Aufgabe einer Person im aktiven Sprint zuweisen, damit Verantwortung im Panel klar sichtbar ist. Der Umfang ist damit noch nicht fertig. Darf eine abgeschlossene Aufgabe neu zugewiesen werden? Muss die Person Projektmitglied sein? Wer darf ändern? Was bei zwei gleichzeitigen Anfragen?
Zuweisung nur innerhalb eines offenen Arbeitszeitraums.
Die Person muss aktives Mitglied des Aufgabenprojekts sein.
Eine abgeschlossene Aufgabe kehrt nicht durch die Hintertür in Bearbeitung zurück.
Dieselbe Entscheidung erneut auszuführen erzeugt kein zweites Ereignis.
Wir speichern, wer wann wen zugewiesen hat.
Konflikte enden mit verständlicher Antwort statt Datenverlust.
Entscheidung 1 von 3
Womit beginnst du die Implementierung?
Eine Story und ein leerer Branch: Wähle den ersten Schritt.
Mit der Tabelle WorkItems
Mit Kriterien und Verhalten
Mit dem POST-Endpoint
Wähle eine Antwort und sieh die Konsequenz.
2. OOP: Das Objekt schützt die Entscheidung
Ein anämisches Modell ist ein Datensack mit öffentlichen Settern. Jeder Handler kann beliebigen Zustand setzen und muss dieselben Prüfungen kennen. Ein reichhaltigeres Modell repräsentiert einen Fachbegriff und schützt erlaubte Übergänge, statt eine Tabelle nachzuahmen.
public sealed class WorkItem
{
private WorkItem() { }
public Guid Id { get; private set; }
public Guid ProjectId { get; private set; }
public Guid SprintId { get; private set; }
public string Title { get; private set; } = string.Empty;
public WorkItemStatus Status { get; private set; }
public Guid? AssigneeId { get; private set; }
public DateTimeOffset? AssignedAt { get; private set; }
public Guid? AssignedBy { get; private set; }
public uint Version { get; private set; }
public Result AssignTo(
TeamMember member,
Sprint sprint,
Guid actorId,
DateTimeOffset now)
{
if (Status == WorkItemStatus.Done)
return Result.Fail("Eine abgeschlossene Aufgabe kann nicht erneut zugewiesen werden.");
if (sprint.ProjectId != ProjectId || !sprint.IsActiveAt(now))
return Result.Fail("Die Aufgabe gehört nicht zum aktiven Sprint.");
if (member.ProjectId != ProjectId || !member.IsActive)
return Result.Fail("Die Person ist kein aktives Projektmitglied.");
if (AssigneeId == member.Id)
return Result.Ok();
AssigneeId = member.Id;
AssignedAt = now;
AssignedBy = actorId;
return Result.Ok();
}
}
Der private Konstruktor dient EF Core, private Setter begrenzen zufällige Mutationen, und die Methode AssignTo benennt die Absicht. Beachte Idempotenz: Dieselbe Person erneut zuzuweisen endet erfolgreich, ohne Zeit oder Historie zu ändern.
Warum nimmt die Methode Sprint und TeamMember entgegen?
Die Regel braucht Informationen aus drei Objekten. In einem kleinen Modell ist die Übergabe beteiligter Entitäten verständlich. In größeren Systemen kann eine Domänenpolicy passen, wenn die Regel keinem Aggregat natürlich gehört. Sie darf nicht zwischen Controller, Repository und Frontend zerstreut werden.
3. Ein Objektmodell ersetzt kein relationales Denken
Code First bedeutet nicht „Klassen schreiben und der Migration vertrauen“. Code ist die Modellquelle, doch die Datenbank hat eigene Regeln: Schlüssel, Indexselektivität, Sperren, referenzielle Integrität und Abfragekosten.
Modellkarte. Der aktuelle Zustand unterstützt schnelle Betriebsansichten; eine separate Historie bewahrt jede Entscheidung.
| Tabelle | Aufgabe | Wichtige Felder | Schutz |
|---|---|---|---|
| Projects | Arbeitsgrenze | Id, Name, Status | eindeutiger Name je Organisation |
| Sprints | Ausführungszeitraum | ProjectId, StartAt, EndAt | EndAt größer als StartAt |
| WorkItems | Aktueller Zustand | SprintId, Status, AssigneeId, Version | Fremdschlüssel, Indizes, Nebenläufigkeitstoken |
| TeamMembers | Projektzugriff | ProjectId, UserId, IsActive | eindeutiges Paar ProjectId + UserId |
| WorkItemAssignments | Entscheidungshistorie | WorkItemId, AssigneeId, AssignedBy, AssignedAt | nur ergänzen |
Der aktuelle AssigneeId in WorkItems ermöglicht schnelles Lesen des Boards. Die separate Historientabelle liefert einen verlässlichen Auditnachweis. Das ist ein bewusster Kompromiss zwischen operativem Lesen und Entscheidungsspur, keine zufällige Datenduplikation.
Entscheidung 2 von 3
Was soll beim Löschen eines TeamMember passieren?
Historische Aufgaben müssen lesbar bleiben.
Cascade Delete
Restrict und Deaktivierung
Ohne Fremdschlüssel
Wähle das Datenbankverhalten.
4. EF-Core-Konfiguration ohne Vermutungen
public sealed class WorkItemConfiguration : IEntityTypeConfiguration<WorkItem>
{
public void Configure(EntityTypeBuilder<WorkItem> builder)
{
builder.ToTable("WorkItems");
builder.HasKey(x => x.Id);
builder.Property(x => x.Title)
.HasMaxLength(180)
.IsRequired();
builder.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(24);
builder.Property(x => x.Version)
.IsRowVersion()
.IsConcurrencyToken();
builder.HasOne<TeamMember>()
.WithMany()
.HasForeignKey(x => x.AssigneeId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.ProjectId, x.SprintId, x.Status });
builder.HasIndex(x => new { x.ProjectId, x.AssigneeId });
}
}
Wir definieren Längen, Enum-Konvertierung, Indizes, Nebenläufigkeit und Löschverhalten ausdrücklich. Die generierte Migration wird dadurch zum Reviewgegenstand statt zur Überraschung nach dem Deployment.
5. Sichere Migration: Expand, Migrate, Contract
Ein häufiger Irrtum: Datenbank und alle Anwendungsinstanzen schalten gleichzeitig um. Beim Rolling Deployment können alte und neue Version minutenlang parallel laufen. Das Schema muss diesen Zeitraum vertragen.
Nullable-Spalten, Historientabelle und Indizes hinzufügen. Alte Anwendung nicht brechen.
Neue Schreibpfade und Backfill in Chargen starten. Lücken und Fehler messen.
Erst nach dem Nachweis Constraints verstärken und Abwärtskompatibilität entfernen.
migrationBuilder.AddColumn<Guid>(
name: "AssigneeId",
table: "WorkItems",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_WorkItems_ProjectId_AssigneeId",
table: "WorkItems",
columns: new[] { "ProjectId", "AssigneeId" });
Entscheidung 3 von 3
Du hast acht Millionen Aufgaben. Wann setzt du NOT NULL?
Sofort in der ersten Migration
Nach Backfill und dem Nachweis, dass keine Werte fehlen
Niemals
Wähle den Zeitpunkt zur Schemaverstärkung.
6. Anwendungsschicht orchestriert, Domäne entscheidet
Der Handler lädt Entscheidungsdaten, ruft Domänenverhalten auf, speichert die Historie und behandelt Infrastrukturkonflikte. Er schreibt keine Entitätsregeln erneut.
public async Task<Result> Handle(AssignWorkItem command, CancellationToken ct)
{
var item = await db.WorkItems
.SingleOrDefaultAsync(x => x.Id == command.WorkItemId, ct);
var member = await db.TeamMembers
.SingleOrDefaultAsync(x => x.Id == command.MemberId, ct);
var sprint = item is null
? null
: await db.Sprints.SingleOrDefaultAsync(x => x.Id == item.SprintId, ct);
if (item is null || member is null || sprint is null)
return Result.NotFound();
var now = clock.UtcNow;
var result = item.AssignTo(member, sprint, command.ActorId, now);
if (result.IsFailure) return result;
db.WorkItemAssignments.Add(
WorkItemAssignment.From(item, command.ActorId, now));
try
{
await db.SaveChangesAsync(ct);
return Result.Ok();
}
catch (DbUpdateConcurrencyException)
{
return Result.Conflict(
"Die Aufgabe wurde gerade geändert. Aktualisiere die Ansicht.");
}
}
Nebenläufigkeit ist kein theoretisches Extra. Zwei Personen können dasselbe Board offen haben. Ohne Versionstoken gewinnt still der letzte Schreibvorgang; Nutzer bemerken nicht, dass sie fremde Entscheidungen überschreiben.
7. Lesezugriffe für die Ansicht entwerfen
Eine Domänenentität trifft Entscheidungen. Die Boardansicht braucht flache Daten und eine schnelle Abfrage. Dafür müssen weder ganze Objektgraphen geladen noch Lazy Loading gestartet oder Navigationen serialisiert werden.
var board = await db.WorkItems
.AsNoTracking()
.Where(x => x.ProjectId == projectId && x.SprintId == sprintId)
.OrderBy(x => x.Status)
.ThenBy(x => x.Title)
.Select(x => new WorkItemBoardRow(
x.Id,
x.Title,
x.Status,
x.AssigneeId,
x.AssigneeId == null
? null
: db.TeamMembers
.Where(m => m.Id == x.AssigneeId)
.Select(m => m.DisplayName)
.Single()))
.ToListAsync(ct);
Die Projektion begrenzt Spalten, AsNoTracking entfernt Tracking-Kosten, und der Index ProjectId + SprintId + Status entspricht tatsächlichen Filtern. Lesen und Schreiben können unterschiedliche Modelle haben, ohne ein schweres CQRS-System zu erzwingen.
8. Tests schützen Entscheidungen, nicht Implementierungsdetails
[Fact]
public void AssignTo_rejects_member_from_another_project()
{
var item = WorkItemTestFactory.Open(projectId: ProjectA.Id);
var sprint = SprintTestFactory.Active(projectId: ProjectA.Id);
var outsider = TeamMemberTestFactory.Active(projectId: ProjectB.Id);
var result = item.AssignTo(outsider, sprint, Admin.Id, Now);
result.IsFailure.Should().BeTrue();
item.AssigneeId.Should().BeNull();
}
[Fact]
public void AssignTo_is_idempotent_for_the_same_member()
{
var item = WorkItemTestFactory.Open(projectId: ProjectA.Id);
var member = TeamMemberTestFactory.Active(projectId: ProjectA.Id);
var sprint = SprintTestFactory.Active(projectId: ProjectA.Id);
item.AssignTo(member, sprint, Admin.Id, Now);
var second = item.AssignTo(member, sprint, Admin.Id, Now.AddMinutes(1));
second.IsSuccess.Should().BeTrue();
item.AssignedAt.Should().Be(Now);
}
Der erste Test beweist, dass die Regel nicht umgangen wird. Der zweite, dass Wiederholungen keine Historie ändern. Danach folgt Integration mit echtem PostgreSQL: Constraints, Mapping und Versionskonflikte dürfen nicht durch einen In-Memory-Provider nur vorgetäuscht werden.
9. Definition of Done ist ein Abnahmevertrag
„Code fertig“ heißt nicht „Funktion fertig“. Ein agiles Team verkürzt die Rückkopplung, streicht aber keine Qualität. Die folgende Liste ist kurz, weil jeder Punkt überprüfbar ist.
- Akzeptanzkriterien haben Domänen- oder Vertragstests.
- Die Migration wurde als SQL gelesen, nicht nur als C#.
- Indizes passen zu Ansichtsabfragen und Administrationsvorgängen.
- Logs enthalten Aufgaben- und Korrelations-ID, aber keine privaten Daten.
- Metriken unterscheiden Erfolg, Regelablehnung und Nebenläufigkeitskonflikt.
- Feature Flag oder schrittweiser Rollout kontrolliert den Release.
- Rollback der alten Anwendung verträgt das neue Schema.
- Dokumentation beschreibt Entscheidung und Konsequenzen statt nur Dateien.
Interaktive Abnahme
Führe die Änderung durch die Entscheidungspipeline
Die Animation zeigt die Prüfreihenfolge. Sie modelliert den Prozess und führt den Artikelcode nicht aus.
Prüfung starten
Bereit zum Start.
Abnahmepfad. Jede Prüfung liefert einen überprüfbaren Nachweis vor dem nächsten Schritt.
10. Abkürzungen, die später teuer werden
| Abkürzung | Warum sie lockt | Was sie beschädigt | Bessere Entscheidung |
|---|---|---|---|
| Öffentliche Setter | schnelles CRUD | Regeln lassen sich umgehen | Methoden mit ausdrücklicher Absicht |
| Eine riesige Migration | weniger Schritte | Sperren und fehlendes Rollback | Expand–Migrate–Contract |
| Include des ganzen Graphen | einfaches Mapping | schwere Abfragen | Projektion für die Ansicht |
| Nur Unit-Tests | schnell | prüfen weder SQL noch Constraints | Domänentest plus Datenbankintegration |
| Definition of Done: „läuft“ | klingt einfach | keine Nachweise oder Verantwortung | prüfbare Abnahmekriterien |
Der wichtigste Gedanke
Agile bedeutet Entwurf eines kleinen Schritts, der schnell Nachweise liefert. OOP verankert Regeln so, dass sie nicht versehentlich umgangen werden. Code First macht Code zur Modellquelle, entbindet Menschen aber nicht von der Verantwortung für Datenbank- und Produktionsfolgen.
Ein gutes Modell ist nicht nur elegant im Diagramm. Es hilft dem Team, die nächste Änderung sicher vorzunehmen.
Für dein Projekt
Fünf Fragen vor dem nächsten Pull Request
- Welche fachliche Entscheidung schützt dieser Code?
- Verhindert das Modell unzulässige Zustände?
- Stärkt oder schwächt das Datenbankschema diese Regel?
- Verträgt die Migration alte und neue Anwendungsversion?
- Welcher Test oder Messwert belegt die Funktion nach dem Deployment?