Po kliknięciu „Zapisz” zamówienie pojawia się w bazie, ale rezerwacja produktu już nie: jedna operacja się udała, druga zakończyła błędem. Unit of Work grupuje zmiany w jedną pracę nad danymi, choć to, czy wszystkie zostaną zapisane atomowo, zależy od granicy transakcji.

O spójności danych decyduje granica zapisu
- DbContext śledzi zmiany encji i zapisuje je przez SaveChanges.
- Jedno SaveChanges obejmuje zmiany w transakcji, jeśli dostawca bazy ją obsługuje.
- Wiele zapisów wymaga jawnej transakcji, jeśli wszystkie operacje mają się udać albo wycofać razem.
- Dodatkowy wrapper wokół DbContext ma sens, gdy wnosi konkretną regułę lub granicę architektoniczną.
Unit of Work zbiera zmiany przed zapisem
W typowym przebiegu aplikacja pobiera dane, modyfikuje kilka obiektów i dopiero na końcu wysyła zmiany do bazy. Unit of Work pilnuje, co zmieniło się w trakcie tej pracy, i koordynuje zapis. Dzięki temu aktualizacja zamówienia i pozycji zamówienia nie musi oznaczać osobnego zapisu po każdej zmianie.
Martin Fowler opisuje ten wzorzec jako mechanizm śledzący obiekty dotknięte transakcją biznesową oraz koordynujący zapis i rozwiązywanie problemów współbieżności. W praktyce chodzi więc o coś więcej niż metodę Commit(): ważne są wspólny zakres śledzenia i decyzja, kiedy praca z danymi jest gotowa do utrwalenia.
W EF Core tę rolę pełni zwykle DbContext. Śledzi encje, wykrywa ich zmiany i zapisuje je przez SaveChanges albo SaveChangesAsync. Kontekst powinien żyć krótko, w granicach pojedynczej operacji aplikacyjnej. Trzymany zbyt długo gromadzi śledzone obiekty i zaciera granicę między jednym przypadkiem użycia a kolejnym.
Jedno SaveChanges obejmuje zmiany w jednej transakcji
Załóżmy, że obsługa zamówienia dodaje rekord zamówienia, pozycje i zmniejsza stan magazynowy. Jeśli te zmiany są śledzone przez ten sam kontekst i trafiają do jednego SaveChangesAsync, EF Core domyślnie zapisuje je w transakcji, o ile dostawca bazy obsługuje transakcje. Błąd jednej z operacji wycofuje cały ten zapis.
Inaczej wygląda przypadek, w którym kod wywołuje zapis po dodaniu zamówienia, a drugi raz po aktualizacji magazynu. Gdy pierwszy zapis się powiedzie, a drugi nie, pierwszy może już pozostać w bazie. Jeśli obie części muszą tworzyć jedną całość, potrzebna jest jawna transakcja obejmująca oba wywołania. Microsoft Learn zaznacza też, że jawne sterowanie transakcją wymaga uwagi, gdy aplikacja korzysta z automatycznych strategii ponawiania po błędach połączenia.
Granica bazy kończy się jednak na bazie. Jeśli ten sam przypadek użycia po zapisie wysyła żądanie do operatora płatności albo publikuje komunikat do zewnętrznego brokera, transakcja EF Core nie cofnie skutków tych działań. W takim przepływie trzeba osobno zaprojektować obsługę ponowień, idempotencję lub zapis zdarzenia do outboxa.
Jawna transakcja wymaga zgodności z ponawianiem połączeń
Włączenie retry dla chwilowych błędów połączenia zmienia sposób, w jaki należy uruchamiać własną transakcję. Strategia ponawiania musi móc powtórzyć cały blok operacji, a nie tylko ostatnie SaveChangesAsync. Gdy kod sam rozpoczyna transakcję poza takim blokiem, provider SQL Server może zgłosić wyjątek o nieobsługiwanej transakcji inicjowanej przez użytkownika.
Reguła jest prosta: jeśli jeden SaveChanges wystarcza, pozostaw transakcję EF Core; jeśli potrzeba kilku zapisów, a cały zestaw ma być powtarzalny po błędzie, uruchom cały blok transakcji przez strategię zwróconą przez Database.CreateExecutionStrategy(). Przy błędzie dokładnie podczas zatwierdzania wynik transakcji może być nieznany, dlatego operacje warto projektować tak, by ich ponowienie nie tworzyło duplikatów.
Dochodzi jeszcze szczegół specyficzny dla SQL Server. EF Core tworzy punkt zapisu przed kolejnym SaveChanges wewnątrz trwającej transakcji, ale nie robi tego, gdy połączenie ma włączone MARS. Wtedy po błędzie stan transakcji może być niepewny. To dobry powód, by nie włączać jawnych transakcji odruchowo do każdej metody serwisowej.
Osobny Unit of Work dodawaj tylko z konkretnym powodem
W projektach EF Core często powstaje interfejs IUnitOfWork, który opakowuje DbContext, oraz generyczne repozytoria opakowujące DbSet. Taki układ może wyglądać na bardziej uporządkowany, ale jeśli każda metoda tylko przekazuje wywołanie dalej, powstaje druga warstwa o tej samej odpowiedzialności. DbContext już pełni rolę Unit of Work i dostarcza mechanizm dostępu do kolekcji encji.
Własny interfejs ma uzasadnienie, gdy wyznacza granicę, której rzeczywiście potrzebuje aplikacja: na przykład port w architekturze heksagonalnej, który ma odseparować domenę od EF Core, albo metodę zapisującą zmiany i zdarzenia w jednym kontrolowanym przebiegu. Samo ukrycie SaveChanges pod nazwą Commit nie daje tej korzyści. Uważam, że dodatkowa abstrakcja powinna wyrażać decyzję architektoniczną, a nie powielać API kontekstu.
| Sytuacja | Prawdopodobny problem | Co zrobić |
|---|---|---|
| Wszystkie zmiany trafiają do jednego SaveChanges | Osobna transakcja może być zbędna | Pozostaw domyślną transakcję EF Core, jeśli provider ją obsługuje |
| Występuje kilka SaveChanges, które muszą się udać razem | Wcześniejszy zapis może zostać zatwierdzony mimo późniejszego błędu | Obejmij zapisy jawną transakcją |
| Jawna transakcja działa z włączonym retry | Strategia może nie umieć powtórzyć transakcji rozpoczętej poza jej zakresem | Uruchom całą transakcję w bloku strategii wykonania |
| DbContext jest używany równolegle w kilku zadaniach | Kontekst nie jest bezpieczny wątkowo | Oczekuj na każde wywołanie asynchroniczne albo użyj osobnych kontekstów |
| Nowa warstwa tylko przekazuje wywołania do DbContext | Abstrakcja powiela istniejące API | Usuń ją albo dodaj jej odpowiedzialność, której kontekst nie realizuje |
Granica transakcji powinna odpowiadać jednemu przypadkowi użycia
Gdy kilka zmian w bazie razem opisuje jedną decyzję biznesową, powinny należeć do jednego przebiegu Unit of Work. W EF Core najczęściej wystarczy krótko żyjący DbContext i pojedyncze SaveChangesAsync; dodatkowa transakcja jest potrzebna dopiero wtedy, gdy operacja obejmuje kilka zapisów. Od tej granicy zacznij, zanim dodasz kolejną warstwę abstrakcji.
