Code First bez bólu: migracje, które nie wywracają produkcji

Code First jest wygodny dopóki model, migracja i produkcyjna baza idą tym samym rytmem. Ten wpis pokazuje, jak projektować encje, indeksy, migracje, Azure deployment i kontrolę po wdrożeniu tak, żeby zmiana była przewidywalna.

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

Lekcja produkcyjna • EF Core • Azure • SQL

Code First nie boli wtedy, gdy migracja jest decyzją, a nie przypadkiem.

Code First jest wygodny, ale bardzo szybko robi się niebezpieczny, jeśli traktujemy bazę jak automatyczny skutek uboczny modelu C#. Ten wpis pokazuje proces: model, migracja, indeksy, deployment, konsola kontroli i odbiór po wdrożeniu.

model migracja Azure odbiór
01 Encja nie jest formularzem. To kontrakt z bazą.
02 Migracja ma mieć plan, test i drogę powrotu.
03 Po deployu zostaje dowód: log, health, query i wynik.

Najczęściej problem zaczyna się niewinnie. Ktoś dodaje jedną funkcję: opinie klientów, status moderacji, historię zdarzeń, płatność, flagę w panelu admina. Lokalnie wszystko działa. Formularz zapisuje dane, lista się odświeża, przycisk publikuje wpis. Dopiero produkcja pokazuje, czy zrobiliśmy system, czy tylko formularz z tabelą.

W dobrze prowadzonym projekcie baza danych nie jest miejscem, do którego „jakoś się zapisze”. Jest umową między kodem, użytkownikiem, administracją, backupem, prywatnością i przyszłymi zmianami. Code First może być świetny, ale tylko wtedy, gdy nie zwalnia z myślenia o danych.

arccloud migration lab

$ dotnet ef migrations add CustomerReviews

✓ model snapshot updated

$ dotnet ef migrations script --idempotent -o db.sql

✓ idempotent script generated

$ az webapp deployment slot swap --slot staging

• smoke tests running: api, admin, public blog

✓ release accepted ▌

Scenariusz: mały moduł, który dotyka produkcji

Załóżmy, że dodajemy moduł opinii klientów. Użytkownik zostawia imię, firmę, ocenę i komentarz. Opinia nie trafia od razu publicznie na stronę, bo musi przejść moderację. E-mail służy tylko do weryfikacji i nie powinien być widoczny. W panelu admina trzeba zaakceptować albo odrzucić wpis.

Na pierwszy rzut oka to kilka pól. W praktyce to decyzje o prywatności, indeksach, statusach, antyspamie, walidacji, przepływie admina i tym, jak system ma zachować się za pół roku.

Źle

Tworzymy tabelę jak odbicie formularza. Wrzucamy e-mail, treść i rating. Publikacja to boolean. Po kilku tygodniach dochodzi moderacja, źródło opinii, blokada spamu i historia zmian. Model zaczyna puchnąć bez planu.

Dobrze

Projektujemy dane pod proces: oczekuje, zaakceptowana, odrzucona. E-mail trzymamy prywatnie albo jako hash. Indeksujemy zapytania, które faktycznie wykonuje strona i panel. Migrację opisujemy i testujemy przed publikacją.

Model C#: minimum, które ma sens

Encja powinna mówić, co jest trwałą prawdą systemu. Nie musi znać wyglądu formularza. Musi znać reguły, które będą ważne także wtedy, gdy zmieni się UI.

public sealed class CustomerReview
{
    public Guid Id { get; set; }
    public string DisplayName { get; set; } = null!;
    public string? CompanyName { get; set; }
    public string EmailHash { get; set; } = null!;
    public int Rating { get; set; }
    public string Body { get; set; } = null!;
    public ReviewStatus Status { get; set; } = ReviewStatus.Pending;
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? PublishedAt { get; set; }
}

public enum ReviewStatus
{
    Pending = 0,
    Approved = 1,
    Rejected = 2
}

Najważniejsza rzecz: w tym modelu nie publikujemy prywatnego e-maila. Jeśli e-mail jest potrzebny do weryfikacji, trzymamy go poza publicznym modelem albo zapisujemy hash do deduplikacji. Dla strony liczy się opinia, autor widoczny publicznie, firma i ocena. Dla administracji liczy się źródło, historia i decyzja.

Dlaczego status jest lepszy niż samo Published = true?

Bo realny proces ma więcej niż dwa stany. Opinia może czekać, być zaakceptowana, odrzucona, zgłoszona do poprawy albo ukryta po czasie. Boolean działa tylko do pierwszego wyjątku. Status pozwala rozwijać system bez dokładania chaotycznych flag.

Konfiguracja EF Core: limity, indeksy, intencja

Jeśli konfiguracja EF wygląda jak formalność, to zwykle znaczy, że nikt nie zadał pytania: jak ta tabela będzie czytana na produkcji?

builder.Entity<CustomerReview>(e =>
{
    e.Property(x => x.DisplayName)
        .HasMaxLength(120)
        .IsRequired();

    e.Property(x => x.CompanyName)
        .HasMaxLength(160);

    e.Property(x => x.EmailHash)
        .HasMaxLength(96)
        .IsRequired();

    e.Property(x => x.Body)
        .HasMaxLength(1800)
        .IsRequired();

    e.Property(x => x.Rating)
        .IsRequired();

    e.HasIndex(x => new { x.Status, x.PublishedAt });
    e.HasIndex(x => x.EmailHash);
});

Indeks Status + PublishedAt ma sens, bo publiczna strona będzie pobierała tylko zaakceptowane opinie, zwykle sortowane po dacie publikacji. Indeks po EmailHash pomaga w moderacji i ograniczaniu nadużyć. To nie są indeksy „na zapas”. To indeksy pod znane ścieżki systemu.

Formularzwalidacja + zgoda
APIrate limit + hash
Bazastatus + indeks
Admindecyzja + ślad
Publictylko zaakceptowane

Migracja: skrypt, który można przeczytać

Migracja nie powinna być czarną skrzynką. Jeżeli zespół nie czyta wygenerowanego skryptu, to tak naprawdę nie wie, co trafi do bazy.

migrationBuilder.CreateTable(
    name: "CustomerReviews",
    columns: table => new
    {
        Id = table.Column<Guid>(nullable: false),
        DisplayName = table.Column<string>(maxLength: 120, nullable: false),
        CompanyName = table.Column<string>(maxLength: 160, nullable: true),
        EmailHash = table.Column<string>(maxLength: 96, nullable: false),
        Rating = table.Column<int>(nullable: false),
        Body = table.Column<string>(maxLength: 1800, nullable: false),
        Status = table.Column<int>(nullable: false),
        CreatedAt = table.Column<DateTimeOffset>(nullable: false),
        PublishedAt = table.Column<DateTimeOffset>(nullable: true)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_CustomerReviews", x => x.Id);
    });

migrationBuilder.CreateIndex(
    name: "IX_CustomerReviews_Status_PublishedAt",
    table: "CustomerReviews",
    columns: new[] { "Status", "PublishedAt" });

Nowa tabela jest względnie bezpieczna. Dużo trudniejsze są zmiany na istniejących tabelach: dodanie kolumny NOT NULL, przebudowa indeksu, zmiana typu, backfill danych albo usunięcie starego pola. Wtedy trzeba planować blokady i czas wykonania.

Co sprawdzam przed migracją istniejącej tabeli?
  • czy kolumna ma wartość domyślną i czy nie wymusi pełnego locka na dużej tabeli,
  • czy backfill danych można wykonać partiami,
  • czy nowy kod jest kompatybilny ze starą i nową strukturą,
  • czy rollback oznacza cofnięcie kodu, danych, czy obu rzeczy,
  • czy mamy backup i test odtworzenia, a nie tylko obietnicę backupu.

Azure: migracja jako jawny krok, nie magia przy starcie

Automatyczne migracje przy starcie aplikacji są wygodne, ale na produkcji potrafią być ryzykowne. Jeśli migracja jest ciężka, aplikacja może długo startować, wpaść w timeout albo zablokować kilka instancji naraz. Dla prostych zmian da się to kontrolować. Dla zmian w krytycznej bazie lepszy jest jawny krok.

dotnet ef migrations script --idempotent -o ./artifacts/db.sql

az webapp deployment slot swap \
  --name arccloud-api \
  --resource-group prod \
  --slot staging

psql "$DATABASE_URL" \
  -v ON_ERROR_STOP=1 \
  -f ./artifacts/db.sql

curl -fsS https://www.arccloudstudio.com/health

Nie chodzi o to, że zawsze trzeba robić dokładnie tak. Chodzi o zasadę: migracja ma być widoczna, powtarzalna i możliwa do sprawdzenia. Jeśli coś pójdzie źle, trzeba wiedzieć, czy problem jest w schemacie, danych, aplikacji, routingu czy konfiguracji.

Runbook wdrożenia bazy

  1. Preflight: sprawdź backup, połączenie i wersję aplikacji.
  2. Script: wygeneruj idempotentny SQL i przeczytaj go przed uruchomieniem.
  3. Apply: wykonaj migrację jako osobny krok, z przerwaniem na błędzie.
  4. Smoke: sprawdź endpoint, zapis, odczyt i panel admina.
  5. Closeout: zapisz dowód: log, czas, wynik i co zmieniło się w bazie.

Mała konsola debugowania: co naprawdę dzieje się po deployu?

Po wdrożeniu nie pytam „czy działa?”, tylko „co potwierdza, że działa?”. Różnica jest ogromna. Poniżej przykład minimalnego odbioru po zmianie modelu.

SELECT "Status", count(*)
FROM "CustomerReviews"
GROUP BY "Status";

SELECT indexname
FROM pg_indexes
WHERE tablename = 'CustomerReviews';

SELECT "DisplayName", "Rating", "PublishedAt"
FROM "CustomerReviews"
WHERE "Status" = 1
ORDER BY "PublishedAt" DESC
LIMIT 5;

SQL Status counts: pending=3, approved=0, rejected=0

IDX IX_CustomerReviews_Status_PublishedAt found

API public endpoint returns only approved reviews

ADMIN moderation action creates audit trail

Najczęstszy błąd: model rośnie szybciej niż odpowiedzialność

Najgorszy Code First to taki, w którym model C# rośnie szybciej niż odpowiedzialność za dane. Po pół roku baza ma kolumny bez jasnego znaczenia, indeksy bez powodu, migracje bez opisu i rekordy, których nikt nie potrafi bezpiecznie poprawić.

Dobry Code First jest odwrotny. Kod daje szybkość, ale baza nadal jest traktowana jak produkcyjny kontrakt. Każda zmiana odpowiada na kilka prostych pytań: kto to zapisuje, kto to czyta, jak to filtrujemy, co jest prywatne, jak to cofamy i jak sprawdzamy po wdrożeniu.

Checklist przed migracją

  • Model: encja opisuje proces, a nie tylko pola formularza.
  • Limity: teksty, nazwy i statusy mają realne ograniczenia.
  • Indeksy: wynikają z zapytań, które wykonuje publiczna strona lub admin.
  • Prywatność: e-mail i dane wrażliwe nie wychodzą do publicznego API.
  • Deployment: migracja jest jawna, logowana i możliwa do powtórzenia.
  • Odbiór: po wdrożeniu jest dowód, nie tylko informacja „poszło”.

Mini test dla własnego projektu

Weź dowolną tabelę z aplikacji i odpowiedz bez zaglądania w kod:

Jakie trzy endpointy najczęściej ją czytają? Które dane są prywatne i nie powinny trafić do frontendu? Jaki indeks odpowiada za najważniejszą listę? Co trzeba sprawdzić po migracji?

Jeśli odpowiedź nie jest oczywista, to nie znaczy, że projekt jest zły. To znaczy, że baza zaczyna prosić o porządek zanim stanie się drogim problemem.

Masz zmianę w bazie przed produkcją?

Przejrzyjmy model, migrację i ryzyko zanim użytkownicy poczują błąd.

Opisz moduł, tabelę albo problem. Wrócę z konkretną kolejnością: co sprawdzić, co poprawić i jak odebrać wdrożenie bez zgadywania.

Zgłoś temat