04 maj 2025·5 min czytania

Wzorzec repozytorium CRUD z generykami w Go dla czytelnej warstwy danych

Poznaj praktyczny wzorzec repozytorium CRUD z użyciem generyków w Go, który pozwala ponownie użyć logiki list/get/create/update/delete z czytelnymi ograniczeniami, bez refleksji i z przejrzystym kodem.

Wzorzec repozytorium CRUD z generykami w Go dla czytelnej warstwy danych

Dlaczego repozytoria CRUD w Go się zaśmiecają

Repozytoria CRUD zaczynają prosto. Najpierw piszesz GetUser, potem ListUsers, potem to samo dla Orders, a potem dla Invoices. Po kilku encjach warstwa danych zmienia się w stos niemal-identycznych kopii, w których łatwo przeoczyć drobne różnice.

To, co najczęściej się powtarza, to nie sam SQL, lecz otaczający go przepływ: uruchomienie zapytania, skanowanie wierszy, obsługa „nie znaleziono”, mapowanie błędów z DB, domyślne paginowanie i konwersja danych wejściowych do odpowiednich typów.

Znane punkty zapalne to powielany kod Scan, powtarzające się wzorce z context.Context i transakcjami, boilerplate do LIMIT/OFFSET (czasem z całkowitą liczbą wyników), ten sam check „0 wierszy oznacza nie znaleziono” oraz powielane wariacje INSERT ... RETURNING id.

Kiedy powtórzeń jest za dużo, wiele zespołów sięga po refleksję. Obiecuje ona "napisz raz": weź dowolną strukturę i wypełnij ją kolumnami w czasie wykonywania. Koszt pojawia się później. Kod oparty na refleksji jest trudniejszy do czytania, wsparcie IDE słabnie, a błędy przenoszą się z czasu kompilacji do czasu wykonywania. Małe zmiany, jak zmiana nazwy pola czy dodanie kolumny nullable, stają się niespodziankami widocznymi dopiero w testach lub produkcji.

Ponowne użycie z bezpieczeństwem typów oznacza dzielenie przepływu CRUD bez rezygnacji z codziennych zalet Go: jasnych sygnatur, sprawdzania przez kompilator i autouzupełniania, które rzeczywiście pomaga. Dzięki generykom można ponownie używać operacji takich jak Get[T] i List[T], jednocześnie wymagając od każdej encji dostarczenia tego, czego nie da się odgadnąć, np. funkcji skanującej wiersz do T.

Ten wzorzec dotyczy świadomie warstwy dostępu do danych. Utrzymuje SQL i mapowanie spójnymi i nudnymi. Nie próbuje modelować domeny, wymuszać reguł biznesowych ani zastępować logiki na poziomie serwisów.

Cele projektowe (i czego to nie rozwiązuje)

Dobry wzorzec repozytorium sprawia, że codzienny dostęp do bazy jest przewidywalny. Powinieneś móc przeczytać repozytorium i szybko zrozumieć, co robi, jaki SQL wykonuje i jakie błędy może zwrócić.

Cele są proste:

  • Bezpieczeństwo typów na całej ścieżce (ID, encje i wyniki nie są any)
  • Ograniczenia, które wyjaśniają intencję bez gimnastyki typów
  • Mniej boilerplate bez ukrywania istotnego zachowania
  • Spójne zachowanie dla List/Get/Create/Update/Delete

Równie ważne są non-goals. To nie jest ORM. Nie powinien zgadywać mapowań pól, automatycznie łączyć tabel ani cicho zmieniać zapytań. „Magiczne mapowanie” pcha z powrotem w refleksję, tagi i przypadki brzegowe.

Zakładaj normalny przepływ SQL: jawny SQL (lub cienki query builder), wyraźne granice transakcji i błędy, które da się rozumieć. Gdy coś zawiedzie, błąd powinien mówić „nie znaleziono”, „konflikt/niezgodność ograniczeń” lub „DB niedostępna”, a nie lakoniczne „błąd repozytorium”.

Kluczowa decyzja to, co zrobić generyczne, a co pozostawić per encję.

  • Generyczne: przepływ (uruchom zapytanie, skanuj, zwróć typowane wartości, przetłumacz wspólne błędy).
  • Per encję: znaczenie (nazwy tabel, wybrane kolumny, joiny i same stringi SQL).

Próba zmuszenia wszystkich encji do jednego uniwersalnego systemu filtrów zwykle utrudnia czytanie kodu bardziej niż napisanie dwóch jasnych zapytań.

Wybór encji i ograniczeń ID

Większość kodu CRUD się powtarza, bo każda tabela robi podobne ruchy, ale każda encja ma własne pola. Przy generykach sztuka polega na podzieleniu wspólnego kształtu i pozostawieniu reszty wolnej.

Zacznij od zdecydowania, czego repozytorium naprawdę musi wiedzieć o encji. Dla wielu zespołów jedynym uniwersalnym elementem jest ID. Znaczniki czasu mogą być przydatne, ale nie są uniwersalne i wymuszanie ich we wszystkich typach często powoduje, że model wydaje się sztuczny.

Wybierz typ ID, z którym możesz żyć

Twój typ ID powinien odpowiadać temu, jak identyfikujesz wiersze w bazie. Niektóre projekty używają int64, inne UUID jako stringów. Jeśli chcesz podejścia działającego w wielu usługach, uczyń ID generycznym. Jeśli w całej bazie używany jest jeden typ ID, utrzymanie go stałego może skrócić sygnatury.

Dobrym domyślnym ograniczeniem dla ID jest comparable, bo porównujesz ID, używasz ich jako kluczy map i przekazujesz je w aplikacji.

type ID interface {
	comparable
}

type Entity[IDT ID] interface {
	GetID() IDT
	SetID(IDT)
}

Trzymaj ograniczenia encji minimalne

Unikaj wymuszania pól przez embedding struktur czy sztuczek z type-setami jak ~struct{...}. Wygląda to potężnie, ale sprzęża twoje typy domenowe z wzorcem repozytorium.

Zamiast tego wymagaj tylko tego, czego potrzebuje wspólny przepływ CRUD:

  • Pobranie i ustawienie ID (żeby Create mogło je zwrócić, a Update/Delete mogły na nim operować)

Jeśli później dodasz funkcje jak soft delete czy optimistic locking, dodaj małe opcjonalne interfejsy (np. GetVersion/SetVersion) i używaj ich tylko tam, gdzie trzeba. Małe interfejsy zwykle dobrze się skalują w czasie.

Czytelny generyczny interfejs repozytorium

Interfejs repozytorium powinien opisywać, czego potrzebuje twoja aplikacja, a nie tego, co robi baza. Jeśli interfejs przypomina SQL, wycieka za dużo szczegółów.

Trzymaj metody małe i przewidywalne. Umieść context.Context jako pierwszy argument, potem główne wejście (ID albo dane), a opcjonalne parametry zapakuj w strukturę.

type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
	Get(ctx context.Context, id ID) (T, error)
	List(ctx context.Context, q ListQ) ([]T, error)
	Create(ctx context.Context, in CreateIn) (T, error)
	Update(ctx context.Context, id ID, in UpdateIn) (T, error)
	Delete(ctx context.Context, id ID) error
}

Dla List unikaj wymuszania uniwersalnego typu filtra. Filtry to właśnie to, czym encje różnią się najbardziej. Praktyczne podejście to per-encja typy zapytań plus mały, wspólny kształt paginacji, który można osadzić.

type Page struct {
	Limit  int
	Offset int
}

Obsługa błędów to miejsce, gdzie repozytoria często robią bałagan. Zdecyduj z góry, na które błędy wywołujący mogą reagować. Zwykle wystarczy prosty zestaw:

  • ErrNotFound gdy ID nie istnieje
  • ErrConflict przy naruszeniach unikalności lub konfliktach wersji
  • ErrValidation przy nieprawidłowym wejściu (tylko jeśli repo waliduje)

Wszystko inne może być opakowane w błędy niskiego poziomu (DB/sieć). Dzięki takiemu kontraktowi kod serwisu może obsłużyć „nie znaleziono” lub „konflikt” bez względu na to, czy dziś storage to PostgreSQL, czy coś innego.

Jak unikać refleksji, a mimo to ponownie używać przepływu

Ship a clean data layer
Użyj wizualnych modeli, aby utrzymać spójny dostęp do danych w miarę zmiany wymagań.
Try AppMaster

Refleksja zwykle wkrada się, gdy chcesz, żeby jedna część kodu „wypełniła dowolną strukturę”. To ukrywa błędy aż do czasu wykonania i czyni reguły niejasnymi.

Czystsze podejście to ponowne użycie tylko nudnych części: wykonanie zapytania, iteracja po wierszach, sprawdzanie liczby zmienionych wierszy i spójne opakowywanie błędów. Mapowanie do i z struktur trzymaj jawne.

Podziel odpowiedzialności: SQL, mapowanie, wspólny przepływ

Praktyczny podział wygląda tak:

  • Per encję: trzymaj stringi SQL i kolejność parametrów w jednym miejscu
  • Per encję: napisz małe funkcje mapujące, które skanują wiersz do konkretnej struktury
  • Generyczne: zapewnij wspólny przepływ, który wykonuje zapytanie i wywołuje mapper

W ten sposób generyki zmniejszają powtarzalność bez ukrywania, co robi baza.

Oto mała abstrakcja pozwalająca przekazać albo *sql.DB, albo *sql.Tx bez zmiany reszty kodu:

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

Co powinny (i nie powinny) robić generyki

Warstwa generyczna nie powinna "rozumieć" twojej struktury. Powinna przyjmować jawne funkcje, które dostarczysz, takie jak:

  • binder, który zamienia wejścia na argumenty zapytania
  • scanner, który czyta kolumny do encji

Na przykład repozytorium Customer może trzymać SQL jako stałe (selectByID, insert, update) i zaimplementować scanCustomer(rows) raz. Generyczne List może obsłużyć pętlę, kontekst i opakowywanie błędów, podczas gdy scanCustomer utrzymuje mapowanie typowane i oczywiste.

Jeśli dodasz kolumnę, aktualizujesz SQL i skaner. Kompilator pomoże znaleźć, co się zepsuło.

Krok po kroku: implementacja wzorca

Celem jest jeden powtarzalny przepływ dla List/Get/Create/Update/Delete, przy jednoczesnym wymaganiu, by każde repozytorium było uczciwe względem własnego SQL i mapowania wierszy.

1) Zdefiniuj typy bazowe

Zacznij od najmniejszych możliwych ograniczeń. Wybierz typ ID, który pasuje do twojej bazy, oraz interfejs repozytorium, który pozostanie przewidywalny.

type ID interface{ ~int64 | ~string }

type Repo[E any, K ID] interface {
	Get(ctx context.Context, id K) (E, error)
	List(ctx context.Context, limit, offset int) ([]E, error)
	Create(ctx context.Context, e *E) error
	Update(ctx context.Context, e *E) error
	Delete(ctx context.Context, id K) error
}

2) Dodaj executor dla DB i transakcji

Nie wiąż generycznego kodu bezpośrednio z *sql.DB lub *sql.Tx. Polegaj na małym interfejsie executor, który ma metody, których wywołujesz (QueryContext, ExecContext, QueryRowContext). Dzięki temu serwisy mogą przekazać DB lub transakcję bez zmiany kodu repozytorium.

3) Zbuduj generyczną bazę ze wspólnym przepływem

Utwórz baseRepo[E,K], który trzyma executor i kilka pól funkcyjnych. Baza zajmuje się nudnymi rzeczami: wywoływaniem zapytania, mapowaniem „nie znaleziono”, sprawdzaniem ilości dotkniętych wierszy i zwracaniem spójnych błędów.

4) Zaimplementuj elementy specyficzne dla encji

Każde repozytorium encji dostarcza to, czego nie da się uogólnić:

  • SQL dla list/get/create/update/delete
  • funkcję scan(row) konwertującą wiersz na E
  • funkcję bind(...) zwracającą argumenty zapytania

5) Połącz konkretne repozytoria i używaj ich w serwisach

Zbuduj NewCustomerRepo(exec Executor) *CustomerRepo, który osadza lub opakowuje baseRepo. Warstwa serwisu zależy od interfejsu Repo[E,K] i decyduje, kiedy rozpocząć transakcję; repozytorium po prostu używa przekazanego executora.

Obsługa List/Get/Create/Update/Delete bez niespodzianek

Prototype admin tools faster
Buduj narzędzia wewnętrzne z UI, uwierzytelnianiem i logiką biznesową bez pisania mnóstwa kodu repozytorium.
Create Tool

Generyczne repozytorium pomaga tylko wtedy, gdy każda metoda zachowuje się tak samo wszędzie. Największe problemy wynikają z małych niespójności: jedno repo sortuje po created_at, inne po id; jedno zwraca nil, nil dla brakujących wierszy, inne błąd.

List: paginacja i sortowanie, które nie zmienia się

Wybierz styl paginacji i stosuj go konsekwentnie. Paginacja offsetowa (limit/offset) jest prosta i dobrze sprawdza się w panelach administracyjnych. Paginacja kursorem lepsza dla niekończącego się scrolla, ale wymaga stabilnego klucza sortowania.

Cokolwiek wybierzesz, niech sortowanie będzie jawne i stabilne. Sortowanie po unikalnej kolumnie (często klucz główny) zapobiega przeskakiwaniu pozycji między stronami, gdy pojawiają się nowe wiersze.

Get: jasny sygnał „nie znaleziono”

Get(ctx, id) powinno zwracać typowaną encję i wyraźny sygnał brakującego rekordu, zwykle wspólny sentinel ErrNotFound. Unikaj zwracania wartości zerowej encji z nil error. Wywołujący nie będą wiedzieć, czy to „brak” czy po prostu puste pola.

Zrób z tego nawyk: typ służy do danych, błąd do stanu.

Zanim zaimplementujesz metody, podejmij kilka decyzji i trzymaj się ich:

  • Create: czy przyjmujesz typ wejściowy (bez ID, bez znaczników czasu), czy pełną encję? Wiele zespołów woli Create(ctx, in CreateX) aby zapobiec ustawianiu pól zarządzanych przez serwer.
  • Update: czy to pełna zamiana, czy patch? Jeśli to patch, nie używaj zwykłych struktur, gdzie wartości zero są niejednoznaczne. Użyj wskaźników, typów nullable lub maski pól.
  • Delete: hard delete czy soft delete? Jeśli soft, zdecyduj czy Get domyślnie ukrywa usunięte wiersze.

Zdecyduj też, co zwracają metody zapisu. Opcje nie sprawiające niespodzianek to zwracanie zaktualizowanej encji (po domyślnych wartościach DB) lub zwracanie tylko ID plus ErrNotFound, gdy nic nie zmieniono.

Strategia testów dla części generycznych i specyficznych

Make logic explicit
Umieść reguły biznesowe w Business Process Editor zamiast rozsypywać je po repozytoriach.
Build Logic

To podejście się opłaca tylko wtedy, gdy łatwo mu zaufać. Podziel testy tak, jak kod: przetestuj wspólne helpery raz, potem testuj SQL i skanowanie każdej encji osobno.

Traktuj wspólne kawałki jako małe, czyste funkcje, kiedy to możliwe, jak normalizacja paginacji, mapowanie kluczy sortowania do dozwolonych kolumn czy budowanie fragmentów WHERE. To można pokryć szybkim testem jednostkowym.

Dla zapytań listujących testy tabelaryczne (table-driven) sprawdzają się dobrze, bo przypadki brzegowe są istotą problemu. Pokryj puste filtry, nieznane klucze sortowania, limit 0, limit ponad maksymalną wartość, ujemny offset i granice „następnej strony”, gdy pobierasz o jeden wiersz więcej.

Testy per encję skup się na tym, co naprawdę specyficzne: jaki SQL spodziewasz się wykonać i jak wiersze skanują się do typu encji. Użyj mocka SQL lub lekkiej bazy testowej i upewnij się, że logika skanowania radzi sobie z nullami, opcjonalnymi kolumnami i konwersjami typów.

Jeśli wzorzec obsługuje transakcje, testuj commit/rollback przy użyciu małego fałszywego executora, który rejestruje wywołania i symuluje błędy:

  • Begin zwraca executor powiązany z tx
  • przy błędzie rollback jest wywoływany dokładnie raz
  • przy sukcesie commit jest wywoływany dokładnie raz
  • jeśli commit się nie powiedzie, błąd jest zwracany bez zmian

Możesz też dodać małe „testy kontraktowe”, które każde repo musi przejść: create potem get zwraca te same dane, update zmienia zamierzone pola, delete sprawia, że get zwraca not found, a list zwraca stabilne sortowanie przy tych samych wejściach.

Typowe błędy i pułapki

Generyki kuszą, by zbudować jedno repo, które rządzi wszystkimi. Dostęp do danych pełen jest drobnych różnic i te różnice mają znaczenie.

Kilka często spotykanych pułapek:

  • Nadmierne uogólnianie, aż każda metoda przyjmuje gigantyczną torbę opcji (joiny, wyszukiwanie, uprawnienia, soft delete, cache). Wtedy zbudowałeś drugi ORM.
  • Ograniczenia zbyt sprytne. Jeśli czytelnicy muszą dekodować type sety, by zrozumieć, co encja musi implementować, abstrakcja więcej kosztuje niż oszczędza.
  • Traktowanie typów wejściowych jako modelu DB. Gdy Create i Update używają tej samej struktury, którą skanujesz z wierszy, szczegóły DB wyciekają do handlerów i testów, a zmiany schematu rozchodzą się po aplikacji.
  • Ciche zachowanie w List: niestabilne sortowanie, niespójne domyślny, lub reguły paginacji różne per encję.
  • Obsługa not-found, która zmusza wywołujących do parsowania stringów zamiast użycia errors.Is.

Przykład: ListCustomers zwraca klientów w różnej kolejności za każdym razem, ponieważ repozytorium nie ustawia ORDER BY. Paginacja wtedy duplikuje lub pomija rekordy między żądaniami. Uczyń sortowanie explicite (nawet jeśli to tylko po PK) i testuj domyślne zachowanie.

Szybka lista kontrolna przed wdrożeniem

Keep inputs consistent
Wyraźnie oddziel pola wejściowe dla create i update, aby handlery pozostały przewidywalne w miarę ewolucji schematu.
Try Now

Zanim rozprowadzisz generyczne repozytoria po całym kodzie, upewnij się, że usuną powtórzenia bez ukrywania istotnego zachowania bazy.

Zacznij od spójności. Jeśli jedno repo przyjmuje context.Context, a inne nie, albo jedno zwraca (T, error), a inne (*T, error), ból pojawi się wszędzie: w serwisach, testach i mockach.

Upewnij się, że każda encja ma jedno oczywiste miejsce dla swojego SQL. Generyki powinny powtarzać przepływ (scan, walidacja, mapowanie błędów), a nie rozsypywać zapytania po fragmentach stringów.

Krótka lista kontroli zapobiegająca większości niespodzianek:

  • Jedna konwencja sygnatur dla List/Get/Create/Update/Delete
  • Jedna przewidywalna reguła not-found stosowana we wszystkich repozytoriach
  • Stabilne sortowanie listy, które jest udokumentowane i przetestowane
  • Czysty sposób uruchamiania tego samego kodu na *sql.DB i *sql.Tx (przez interfejs executor)
  • Wyraźna granica między kodem generycznym a regułami encji (walidacja i sprawdzenia biznesowe poza warstwą generyczną)

Jeśli szybko budujesz narzędzia wewnętrzne w AppMaster i później eksportujesz lub rozszerzasz wygenerowany kod Go, te kontrole pomagają utrzymać warstwę danych przewidywalną i łatwą do testowania.

Realistyczny przykład: repozytorium Customer

Oto małe repozytorium Customer, które pozostaje typowane bez nadmiernej pomysłowości.

Zacznij od modelu przechowywanego. Trzymaj ID silnie typowanym, żeby nie dało się pomylić z innymi ID:

type CustomerID int64

type Customer struct {
	ID     CustomerID
	Name   string
	Status string // "active", "blocked", "trial"...
}

Teraz oddziel „co API akceptuje” od „czego przechowujesz”. To tu Create i Update powinny się różnić.

type CreateCustomerInput struct {
	Name   string
	Status string
}

type UpdateCustomerInput struct {
	Name   *string
	Status *string
}

Twoja generyczna baza może obsłużyć wspólny przepływ (wykonywanie SQL, skanowanie, mapowanie błędów), podczas gdy repozytorium Customer ma specyficzny SQL i mapowanie. Z punktu widzenia warstwy serwisu interfejs zostaje czysty:

type CustomerRepo interface {
	Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
	Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
	Get(ctx context.Context, id CustomerID) (Customer, error)
	Delete(ctx context.Context, id CustomerID) error
	List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}

Dla List traktuj filtry i paginację jako obiekt żądania pierwszej klasy. Dzięki temu call site'y są czytelne i trudniej zapomnieć limity.

type CustomerListQuery struct {
	Status *string // filter
	Search *string // name contains
	Limit  int
	Offset int
}

Stąd wzorzec dobrze skaluje: kopiujesz strukturę dla następnej encji, oddzielasz wejścia od modeli przechowywanych i trzymasz skanowanie jawne, aby zmiany były oczywiste i pomocne dla kompilatora.

FAQ

What problem do generic CRUD repositories in Go actually solve?

Używaj generyków, aby ponownie wykorzystać przepływ (wykonanie zapytania, pętla skanowania, obsługa not found, domyślne paginowanie, mapowanie błędów), ale trzymaj SQL i mapowanie wierszy jawnie per encja. Dzięki temu zmniejszysz powtarzalność bez tworzenia „magii” działającej w czasie wykonywania.

Why avoid reflection-based “scan any struct” CRUD helpers?

Refleksja ukrywa reguły mapowania i przesuwa błędy na czas wykonywania. Tracisz sprawdzanie przez kompilator, wsparcie IDE słabnie, a drobne zmiany schematu stają się niespodziankami. Generyki razem z jawnie napisanymi funkcjami skanującymi zachowują bezpieczeństwo typów, a jednocześnie dzielą powtarzalne części.

What’s a sensible constraint for an ID type?

Dobrym domyślnym ograniczeniem jest comparable, ponieważ identyfikatory porównuje się, używa jako kluczy map i przekazuje w aplikacji. Jeśli system używa kilku stylów ID (np. int64 i UUID w postaci stringów), uczynienie ID generycznym pozwala uniknąć narzucania jednego wyboru.

What should the entity constraint include (and not include)?

Trzymaj to minimalne: zwykle wystarczy to, czego wspólny przepływ CRUD potrzebuje, np. GetID() i SetID(). Unikaj wymuszania pól przez embedding czy skomplikowane zestawy typów — to sprzęża twoje typy domenowe z wzorcem repozytorium i utrudnia refaktoryzację.

How do I support both *sql.DB and *sql.Tx cleanly?

Użyj małego interfejsu wykonawczego (np. DBTX) zawierającego tylko metody, których używasz, takie jak QueryContext, QueryRowContext i ExecContext. Wtedy kod repozytorium będzie działał zarówno na *sql.DB, jak i *sql.Tx bez rozgałęzień.

What’s the best way to signal “not found” from Get?

Zwracanie wartości zerowej plus nil dla brakującego rekordu zmusza wywołujących do zgadywania, czy encja jest nieobecna, czy po prostu ma puste pola. Wspólny sentinel jak ErrNotFound trzyma stan w kanale błędu, więc serwisy mogą niezawodnie użyć errors.Is do rozgałęzienia.

Should Create/Update take the full entity struct?

Oddziel wejścia od modelu przechowywanego. Preferuj Create(ctx, CreateInput) i Update(ctx, id, UpdateInput), aby wywołujący nie mogli ustawiać pól zarządzanych przez serwer, jak ID czy znaczniki czasu. Dla patchy używaj wskaźników (albo typów nullable), by rozróżnić „nieustawione” od „ustawione na zero”.

How do I keep List pagination from returning inconsistent results?

Ustaw stabilne, jawne ORDER BY za każdym razem, najlepiej po unikalnej kolumnie, jak klucz główny. Bez tego paginacja może pomijać lub duplikować rekordy między żądaniami, gdy pojawiają się nowe wiersze lub zmienia się plan zapytania.

What error contract should repositories provide to services?

Udostępniaj mały zestaw błędów, na których wywołujący mogą się rozgałęziać, np. ErrNotFound i ErrConflict, a resztę opakuj z kontekstem błędu DB. Nie każ zmuszać do parsowania stringów; celuj w errors.Is plus pomocny komunikat do logów.

How should I test a generic repository pattern without over-testing it?

Testuj wspólne pomocniki raz (normalizację paginacji, mapowanie not-found, sprawdzanie ilości zmienionych wierszy), a potem testuj SQL i skanowanie dla każdej encji osobno. Dodaj małe „testy kontraktowe” per repo: create-then-get, update zmienia oczekiwane pola, delete powoduje ErrNotFound, a list ma stabilne sortowanie.

Łatwy do uruchomienia
Stworzyć coś niesamowitego

Eksperymentuj z AppMaster z darmowym planem.
Kiedy będziesz gotowy, możesz wybrać odpowiednią subskrypcję.

Rozpocznij