Zwinny kod, trwały model: Agile, OOP i Code First w jednym przepływie

Praktyczna lekcja projektowania funkcji od kryteriów akceptacji, przez model obiektowy i relacyjny, po migrację EF Core, testy i bezpieczny release. Czytasz, podejmujesz decyzje i od razu widzisz ich konsekwencje.

Poniżej znajduje się pełna treść artykułu. Quizy i symulacje wymagają włączenia JavaScript.

Warsztat: od wymagania do bezpiecznego release

Nie uczysz się trzech osobnych technik. Budujesz jeden działający przepływ.

Agile określa mały krok i sposób odbioru. OOP zamyka reguły w modelu. Code First przenosi ten model do bazy. Jeśli jeden element jest słaby, całość może wyglądać dobrze w pull requeście, a polec na produkcji.

01Story i kryteria akceptacji
02Model domenowy i relacyjny
03Migracja, test i release

Agile, programowanie obiektowe i Code First często opisuje się osobno. Agile trafia na tablicę zadań, OOP do podręcznika o klasach, a Code First do dokumentacji Entity Framework Core. Na produkcji te trzy światy spotykają się w jednym pull requeście.

W tym materiale zbudujemy fragment systemu zarządzania pracą: projekt ma sprinty, sprint ma zadania, a zadanie może zostać przypisane członkowi zespołu. Nie zaczniemy od tabeli. Zaczniemy od zachowania, które użytkownik chce uzyskać, i przejdziemy całą drogę aż do bezpiecznej migracji.

1. Zwinność zaczyna się od granic

Nasza historia brzmi niewinnie: jako lider projektu chcę przypisać zadanie do osoby w aktywnym sprincie, aby odpowiedzialność była jasna i widoczna w panelu. To nadal nie jest gotowy zakres. Nie wiemy, czy zamknięte zadanie można przypisać ponownie, czy osoba musi należeć do projektu, kto ma uprawnienie do zmiany i co zrobić z dwoma żądaniami wysłanymi jednocześnie.

01Aktywny sprint

Zadanie można przypisać tylko w otwartym przedziale pracy.

02Ten sam projekt

Osoba musi być aktywnym członkiem projektu zadania.

03Stan zadania

Zamknięte zadanie nie wraca tylnymi drzwiami do realizacji.

04Idempotencja

Powtórzenie tej samej decyzji nie tworzy drugiego zdarzenia.

05Audyt

Zapisujemy kto, kogo i kiedy przypisał.

06Współbieżność

Konflikt kończy się czytelną odpowiedzią, a nie utratą danych.

Decyzja 1 z 3

Od czego zaczniesz implementację?

Masz story i pustą gałąź. Wybierz pierwszy ruch.

Od tabeli WorkItems

Od kryteriów i zachowania

Od endpointu POST

Wybierz odpowiedź, aby zobaczyć konsekwencję.

2. OOP: obiekt ma pilnować decyzji

Model anemiczny jest workiem danych z publicznymi setterami. Każdy handler może wtedy ustawić dowolny stan i każdy musi pamiętać o tych samych walidacjach. Bogatszy model nie udaje tabeli. Reprezentuje pojęcie biznesowe i broni dozwolonych przejść.

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("Zamkniętego zadania nie można przypisać ponownie.");

        if (sprint.ProjectId != ProjectId || !sprint.IsActiveAt(now))
            return Result.Fail("Zadanie nie należy do aktywnego sprintu.");

        if (member.ProjectId != ProjectId || !member.IsActive)
            return Result.Fail("Osoba nie jest aktywnym członkiem projektu.");

        if (AssigneeId == member.Id)
            return Result.Ok();

        AssigneeId = member.Id;
        AssignedAt = now;
        AssignedBy = actorId;
        return Result.Ok();
    }
}

Prywatny konstruktor istnieje dla EF Core, prywatne settery ograniczają przypadkowe mutacje, a metoda AssignTo nazywa zamiar. Zwróć uwagę na idempotencję: ponowne przypisanie tej samej osoby kończy się sukcesem bez zmiany czasu i bez nowego śladu.

Dlaczego metoda przyjmuje Sprint i TeamMember?

Reguła potrzebuje danych z trzech obiektów. W małym modelu przekazanie współpracujących encji jest czytelne. W większym systemie można użyć polityki domenowej, jeśli reguła nie należy naturalnie do jednego agregatu. Ważne, aby decyzja nie została rozrzucona między kontroler, repozytorium i frontend.

3. Model obiektowy nie zwalnia z myślenia relacyjnego

Code First nie oznacza: „napisz klasy i zaufaj migracji”. Kod jest źródłem modelu, ale baza nadal ma własne prawa: klucze, selektywność indeksów, blokady, integralność referencyjną i koszty zapytań.

Diagram modelu Code First: Project, Sprint, WorkItem, TeamMember i historia WorkItemAssignment

Mapa modelu. Stan bieżący wspiera szybki ekran operacyjny, a osobna historia zachowuje dowód każdej decyzji.

Projects1
ma wiele
SprintsN
grupują
WorkItemsN
przypisane do
TeamMembers0..1
TabelaRolaKluczowe polaOchrona
ProjectsGranica pracyId, Name, Statusunikalna nazwa w organizacji
SprintsPrzedział realizacjiProjectId, StartAt, EndAtEndAt większe od StartAt
WorkItemsStan bieżącySprintId, Status, AssigneeId, VersionFK, indeksy, concurrency token
TeamMembersDostęp do projektuProjectId, UserId, IsActiveunikalna para ProjectId + UserId
WorkItemAssignmentsHistoria decyzjiWorkItemId, AssigneeId, AssignedBy, AssignedAttylko dopisywanie

Bieżący AssigneeId w WorkItems daje szybki odczyt tablicy. Osobna tabela historii daje wiarygodny audyt. To nie jest przypadkowe dublowanie danych, tylko świadomy kompromis między odczytem operacyjnym i śladem decyzji.

Decyzja 2 z 3

Co powinno się stać przy usunięciu TeamMember?

Zadania historyczne nadal muszą być czytelne.

Cascade Delete

Restrict i dezaktywacja

Bez klucza obcego

Wybierz zachowanie bazy.

4. Konfiguracja EF Core bez domysłów

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 });
    }
}

Jawnie określamy długości, konwersję enuma, indeksy, współbieżność i zachowanie przy usuwaniu. Dzięki temu wygenerowana migracja jest punktem do review, a nie niespodzianką po wdrożeniu.

5. Bezpieczna migracja: expand, migrate, contract

Najczęstsza pomyłka polega na założeniu, że baza i wszystkie instancje aplikacji przełączą się jednocześnie. W środowisku z rolling deployment przez kilka minut mogą działać dwie wersje kodu. Schemat musi tolerować ten okres.

Expand

Dodaj nullable kolumny, tabelę historii i indeksy. Nie łam starej aplikacji.

Migrate

Uruchom nowy zapis i backfill partiami. Mierz braki oraz błędy.

Contract

Dopiero po dowodzie wzmocnij constrainty i usuń zgodność wsteczną.

migrationBuilder.AddColumn<Guid>(
    name: "AssigneeId",
    table: "WorkItems",
    type: "uuid",
    nullable: true);

migrationBuilder.CreateIndex(
    name: "IX_WorkItems_ProjectId_AssigneeId",
    table: "WorkItems",
    columns: new[] { "ProjectId", "AssigneeId" });

Decyzja 3 z 3

Masz 8 milionów zadań. Kiedy ustawisz kolumnę jako NOT NULL?

Od razu w pierwszej migracji

Po backfillu i pomiarze zerowych braków

Nigdy

Wybierz moment wzmocnienia schematu.

6. Warstwa aplikacyjna orkiestruje, domena decyduje

Handler ładuje dane potrzebne do decyzji, wywołuje zachowanie domenowe, zapisuje historię i obsługuje konflikt infrastruktury. Nie przepisuje reguł z encji.

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(
            "Zadanie zostało właśnie zmienione. Odśwież widok.");
    }
}

Współbieżność nie jest teoretycznym dodatkiem. Dwie osoby mogą mieć otwartą tę samą tablicę. Bez tokenu wersji ostatni zapis wygrywa po cichu, a użytkownik nie wie, że nadpisał cudzą decyzję.

7. Odczyt projektujemy pod ekran

Encja domenowa służy do podejmowania decyzji. Widok tablicy potrzebuje płaskich danych i szybkiego zapytania. Nie ma powodu ładować pełnego grafu encji, uruchamiać lazy loadingu ani serializować nawigacji.

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);

Projekcja ogranicza kolumny, AsNoTracking usuwa koszt śledzenia, a indeks ProjectId + SprintId + Status odpowiada rzeczywistemu filtrowaniu. To przykład, w którym odczyt i zapis mają inne modele bez budowania ciężkiego CQRS na siłę.

8. Testy mają chronić decyzję, nie implementację

[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);
}

Pierwszy test dowodzi, że reguły nie da się ominąć. Drugi dowodzi, że ponowione żądanie nie modyfikuje historii. Następny poziom to test integracyjny z prawdziwym PostgreSQL: constrainty, mapowanie i konflikt wersji nie powinny być udawane przez provider in-memory.

Domenaszybkie reguły i przejścia stanów
IntegracjaEF Core, PostgreSQL, indeksy, transakcja
Kontraktstatus HTTP, walidacja, format błędu
E2Ejeden krytyczny przepływ użytkownika

9. Definition of Done jest kontraktem odbioru

„Kod gotowy” nie oznacza, że funkcja jest gotowa. Zwinny zespół skraca pętlę informacji zwrotnej, ale nie wyrzuca jakości. Poniższy zakres jest krótki, ponieważ każdy punkt da się zweryfikować.

  • Kryteria akceptacji mają testy domenowe lub kontraktowe.
  • Migracja została przeczytana jako SQL, nie tylko jako kod C#.
  • Indeksy odpowiadają zapytaniom widoku i operacjom administracyjnym.
  • Log zawiera identyfikator zadania i korelacji, ale nie dane prywatne.
  • Metryki rozróżniają sukces, odrzucenie reguły i konflikt współbieżności.
  • Release jest kontrolowany feature flagą lub stopniowym rolloutem.
  • Rollback starej aplikacji nie łamie nowego schematu.
  • Dokumentacja opisuje decyzję i jej konsekwencje, a nie listę plików.

Interaktywny odbiór

Przepuść zmianę przez pipeline decyzyjny

Animacja pokazuje kolejność kontroli. To model procesu, nie atrapa wykonująca kod z artykułu.

Uruchom kontrolę

1Storywarunki odbioru
2Domenareguły i stan
3Schematconstrainty i indeksy
4Testdowód zachowania
5Releasemetryki i rollback

Gotowe do uruchomienia.

Pięć bramek bezpiecznego wydania: kryteria akceptacji, domena OOP, Code First, testy i release

Ścieżka odbioru. Każda bramka dostarcza sprawdzalny dowód przed przejściem zmiany dalej.

10. Typowe skróty, które później kosztują

SkrótDlaczego kusiCo psujeLepsza decyzja
Publiczne setteryszybki CRUDreguły można ominąćmetody nazywające zamiar
Jedna wielka migracjamniej krokówblokady i brak rollbackuexpand–migrate–contract
Include całego grafułatwy mappingciężkie zapytaniaprojekcja pod ekran
Tylko testy jednostkowesą szybkienie sprawdzają SQL i constraintówtest domeny plus integracja DB
Definition of Done: „działa”brzmi prostobrak dowodu i odpowiedzialnościsprawdzalne kryteria odbioru

Najważniejsza myśl

Agile nie oznacza braku projektu. Oznacza projektowanie na tyle małego kroku, by szybko zdobyć dowód. OOP nie oznacza mnożenia klas. Oznacza umieszczenie reguł tam, gdzie nie da się ich przypadkiem ominąć. Code First nie oznacza ślepego uruchamiania migracji. Oznacza, że kod jest źródłem modelu, a człowiek nadal odpowiada za konsekwencje w bazie i na produkcji.

Dobry model nie jest tym, który wygląda elegancko na diagramie. Dobry model pomaga zespołowi bezpiecznie podjąć kolejną zmianę.

Zabierz do projektu

Pięć pytań przed następnym pull requestem

  1. Jaką decyzję biznesową chroni ten kod?
  2. Czy model uniemożliwia niedozwolony stan?
  3. Czy schemat bazy wzmacnia, czy osłabia tę regułę?
  4. Czy migracja toleruje starą i nową wersję aplikacji?
  5. Jaki test lub pomiar dowodzi, że zmiana działa po wdrożeniu?