CRUD-Repository-Pattern mit Go-Generics für eine saubere Datenebene
Lerne ein praktisches Go-Generics CRUD-Repository-Pattern, um List/Get/Create/Update/Delete-Logik wiederzuverwenden — mit lesbaren Constraints, ohne Reflection und mit klarem Code.

Warum CRUD-Repositories in Go unübersichtlich werden
CRUD-Repositories starten einfach. Du schreibst GetUser, dann ListUsers, dann das Gleiche für Orders, dann Invoices. Nach ein paar Entities verwandelt sich die Datenebene in einen Haufen fast identischer Kopien, bei denen kleine Unterschiede leicht übersehen werden.
Was sich am häufigsten wiederholt, ist nicht das SQL selbst. Es ist der umgebende Ablauf: Query ausführen, Rows scannen, „nicht gefunden“ behandeln, Datenbankfehler übersetzen, Pagination-Defaults anwenden und Eingaben in die richtigen Typen konvertieren.
Die üblichen Hotspots sind bekannt: duplizierter Scan-Code, wiederkehrende context.Context- und Transaktionsmuster, Boilerplate für LIMIT/OFFSET (manchmal mit Gesamtanzahl), die gleiche Prüfung „0 Zeilen bedeutet nicht gefunden“ und kopierte INSERT ... RETURNING id-Varianten.
Wenn die Wiederholung zu sehr schmerzt, greifen viele Teams zu Reflection. Sie verspricht „einmal schreiben“ CRUD: nimm irgendeine Struct und fülle sie zur Laufzeit aus Spalten. Die Kosten zeigen sich später. Reflection-lastiger Code ist schwerer zu lesen, die IDE-Unterstützung wird schlechter und Fehler wandern von der Compile-Zeit in die Laufzeit. Kleine Änderungen — wie ein Feld umzubenennen oder eine nullable Spalte hinzuzufügen — werden zu Überraschungen, die nur in Tests oder Produktion auffallen.
Typsichere Wiederverwendung bedeutet, den CRUD-Ablauf zu teilen, ohne auf die Alltagskomforts von Go zu verzichten: klare Signaturen, Compiler-geprüfte Typen und Autocomplete, das tatsächlich hilft. Mit Generics kannst du Operationen wie Get[T] und List[T] wiederverwenden und gleichzeitig von jeder Entity verlangen, die Teile bereitzustellen, die nicht erraten werden können — z. B. wie eine Row in T gescannt wird.
Dieses Pattern bezieht sich bewusst auf die Data-Access-Schicht. Es hält SQL und Mapping konsistent und langweilig. Es versucht nicht, deine Domain zu modellieren, Geschäftsregeln durchzusetzen oder Service-Logik zu ersetzen.
Designziele (und was hier nicht gelöst werden soll)
Ein gutes Repository-Pattern macht den alltäglichen Datenbankzugriff vorhersehbar. Du solltest ein Repository lesen können und schnell sehen, was es tut, welches SQL es ausführt und welche Fehler es zurückgeben kann.
Die Ziele sind einfach:
- Typsicherheit durchgängig (IDs, Entities und Ergebnisse sind nicht
any) - Constraints, die die Absicht erklären, ohne Typgymnastik
- Weniger Boilerplate, ohne wichtiges Verhalten zu verbergen
- Konsistentes Verhalten für List/Get/Create/Update/Delete
Die Non-Goals sind genauso wichtig. Das ist kein ORM. Es sollte Feld-Mappings nicht raten, Tabellen automatisch joinen oder Queries stillschweigend verändern. „Magisches Mapping“ treibt dich zurück zu Reflection, Tags und Randfällen.
Gehe von einem normalen SQL-Workflow aus: explizites SQL (oder ein dünner Query-Builder), klare Transaktionsgrenzen und Fehler, die du nachvollziehen kannst. Wenn etwas fehlschlägt, sollte der Fehler sagen: „nicht gefunden“, „Konflikt/Constraint-Verstoß“ oder „DB nicht verfügbar“, nicht ein vages „Repository-Fehler".
Die zentrale Entscheidung ist, was generisch wird und was pro Entity bleibt.
- Generisch: der Ablauf (Query ausführen, scannen, typisierte Werte zurückgeben, häufige Fehler übersetzen).
- Pro Entity: die Bedeutung (Tabellenamen, ausgewählte Spalten, Joins und SQL-Strings).
Zu versuchen, alle Entities in ein universelles Filter-System zu pressen, macht den Code meist schwerer lesbar als zwei klare Queries zu schreiben.
Auswahl der Entity- und ID-Constraints
Die meisten CRUD-Codes wiederholen sich, weil jede Tabelle die gleichen Grundbewegungen hat, aber jede Entity eigene Felder. Mit Generics besteht der Trick darin, eine kleine Form zu teilen und alles andere frei zu lassen.
Beginne damit, zu entscheiden, was das Repository wirklich über eine Entity wissen muss. Für viele Teams ist das einzige universelle Stück die ID. Timestamps können nützlich sein, sind aber nicht universell, und sie in jeden Typ zu zwingen, lässt das Modell oft künstlich wirken.
Wähle einen ID-Typ, mit dem du leben kannst
Dein ID-Typ sollte zu der Art passen, wie du Zeilen in der DB identifizierst. Manche Projekte verwenden int64, andere UUID-Strings. Wenn du eine Lösung willst, die quer durch Services funktioniert, mache die ID generisch. Wenn dein ganzes Codebase einen ID-Typ nutzt, kann ein fixer Typ Signaturen kürzer machen.
Ein guter Default-Constraint für IDs ist comparable, weil du IDs vergleichst, als Map-Keys verwendest und herumreichst.
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
Halte Entity-Constraints minimal
Vermeide es, Felder per Struct-Embedding oder Type-Set-Tricks wie ~struct{...} zu erzwingen. Die sehen mächtig aus, koppeln aber deine Domain-Typen an das Repository-Pattern.
Stattdessen verlange nur das, was der gemeinsame CRUD-Ablauf braucht:
- Get und Set der ID (damit Create sie zurückgeben kann und Update/Delete sie adressieren können)
Wenn du später Features wie Soft Deletes oder Optimistic Locking hinzufügst, erweitere um kleine opt-in Interfaces (z. B. GetVersion/SetVersion) und nutze sie nur dort, wo sie gebraucht werden. Kleine Interfaces alternieren oft gut.
Ein generisches Repository-Interface, das lesbar bleibt
Ein Repository-Interface sollte beschreiben, was deine App braucht, nicht, was die Datenbank zufällig tut. Wenn das Interface wie SQL aussieht, leakt es Details überall.
Halte die Methodensammlung klein und vorhersehbar. Setze context.Context an erste Stelle, dann das primäre Input (ID oder Daten), dann optionale Knöpfe gebündelt in einer Struct.
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
}
Für List vermeide es, einen universellen Filter-Typ zu erzwingen. Filter sind der Bereich, in dem sich Entities am meisten unterscheiden. Eine praktische Lösung sind pro-Entity Query-Typen plus eine kleine geteilte Pagination-Form, die du einbetten kannst.
type Page struct {
Limit int
Offset int
}
Fehlerbehandlung ist dort, wo Repositories oft laut werden. Entscheide im Voraus, auf welche Fehler Aufrufer verzweigen dürfen. Eine einfache Menge funktioniert meistens:
ErrNotFound, wenn eine ID nicht existiertErrConflictbei Unique-Verletzungen oder VersionskonfliktenErrValidation, wenn Input ungültig ist (nur wenn das Repo validiert)
Alles andere kann ein gewickelter Low-Level-Fehler (DB/Netzwerk) sein. Mit diesem Vertrag kann Service-Code „nicht gefunden“ oder „Konflikt“ behandeln, ohne sich darum zu kümmern, ob heute PostgreSQL oder etwas anderes dahinter steckt.
Wie man Reflection vermeidet und trotzdem den Ablauf wiederverwendet
Reflection schleicht sich oft ein, wenn du willst, dass ein Stück Code „jede Struct füllt“. Das verheimlicht Fehler bis zur Laufzeit und macht die Regeln undurchsichtig.
Ein sauberer Ansatz ist, nur die langweiligen Teile wiederzuverwenden: Queries ausführen, Rows durchlaufen, betroffene Zeilen prüfen und Fehler konsistent wrappen. Das Mapping zu und von Structs bleibt explizit.
Verantwortlichkeiten aufteilen: SQL, Mapping, gemeinsamer Ablauf
Eine praktische Aufteilung sieht so aus:
- Pro Entity: halte SQL-Strings und Parameterreihenfolge an einem Ort
- Pro Entity: schreibe kleine Mapping-Funktionen, die Rows in die konkrete Struct scannen
- Generisch: stelle den gemeinsamen Ablauf bereit, der eine Query ausführt und den Mapper aufruft
So reduzieren Generics Wiederholung, ohne zu verbergen, was die DB macht.
Hier eine kleine Abstraktion, mit der du entweder *sql.DB oder *sql.Tx übergeben kannst, ohne dass der Rest davon etwas merkt:
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
}
Was Generics tun (und nicht tun) sollten
Die generische Schicht sollte nicht versuchen, deine Struct zu „verstehen“. Stattdessen sollte sie explizite Funktionen akzeptieren, die du bereitstellst, z. B.:
- einen Binder, der Inputs in Query-Argumente verwandelt
- einen Scanner, der Spalten in eine Entity liest
Beispielsweise kann ein Customer-Repository SQL als Konstanten (selectByID, insert, update) halten und scanCustomer(rows) einmal implementieren. Ein generisches List übernimmt die Schleife, Context und Fehler-Wrapping, während scanCustomer das Mapping typsicher und klar hält.
Fügst du eine Spalte hinzu, aktualisierst du das SQL und den Scanner. Der Compiler hilft dir, herauszufinden, was kaputt ging.
Schritt für Schritt: Implementierung des Patterns
Das Ziel ist ein wiederverwendbarer Ablauf für List/Get/Create/Update/Delete, während jedes Repository ehrlich über sein SQL und Row-Mapping bleibt.
1) Definiere die Kerntypen
Beginne mit den geringsten Constraints, die möglich sind. Wähle einen ID-Typ, der für dein Codebase passt, und ein Repository-Interface, das vorhersehbar bleibt.
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) Füge einen Executor für DB und Transaktionen hinzu
Binde generischen Code nicht direkt an *sql.DB oder *sql.Tx. Hänge ihn an ein kleines Executor-Interface, das die Methoden enthält, die du aufrufst (QueryContext, ExecContext, QueryRowContext). Dann können Services eine DB oder eine Transaktion übergeben, ohne Repository-Code zu ändern.
3) Baue eine generische Basis mit gemeinsamem Ablauf
Erstelle ein baseRepo[E,K], das den Executor und ein paar Funktionsfelder speichert. Die Basis übernimmt die langweiligen Teile: Query aufrufen, „nicht gefunden“ mappen, betroffene Zeilen prüfen und konsistente Fehler zurückgeben.
4) Implementiere die Entity-spezifischen Teile
Jedes Entity-Repository liefert, was nicht generisch sein kann:
- SQL für list/get/create/update/delete
- eine
scan(row)-Funktion, die eine Row inEkonvertiert - eine
bind(...)-Funktion, die Query-Args liefert
5) Verdrahte konkrete Repos und verwende sie in Services
Baue NewCustomerRepo(exec Executor) *CustomerRepo, das baseRepo einbettet oder umhüllt. Deine Service-Schicht hängt vom Repo[E,K]-Interface ab und entscheidet, wann eine Transaktion begonnen wird; das Repository benutzt einfach den ihm gegebenen Executor.
List/Get/Create/Update/Delete ohne Überraschungen behandeln
Ein generisches Repository hilft nur, wenn jede Methode überall gleich verhält. Der meiste Schmerz kommt von kleinen Inkonsistenzen: Ein Repo ordnet nach created_at, ein anderes nach id; eines gibt nil, nil für fehlende Zeilen zurück, ein anderes einen Fehler.
List: Pagination und Ordering, das stabil bleibt
Wähle einen Pagination-Stil und wende ihn konsistent an. Offset-Pagination (limit/offset) ist einfach und gut für Admin-Oberflächen. Cursor-Pagination ist besser für endloses Scrollen, braucht aber einen stabilen Sortierschlüssel.
Was auch immer du wählst: mache die Sortierung explizit und stabil. Nach einer eindeutigen Spalte zu sortieren (oft dem Primärschlüssel) verhindert, dass Elemente zwischen Seiten springen, wenn neue Zeilen erscheinen.
Get: ein klares "nicht gefunden"-Signal
Get(ctx, id) sollte eine typisierte Entity und ein klares Missing-Record-Signal zurückgeben, üblicherweise einen gemeinsamen Sentinel-Fehler wie ErrNotFound. Vermeide es, eine Null-Instanz mit nil Fehler zurückzugeben. Callers können dann nicht zwischen „fehlend“ und „leere Felder“ unterscheiden.
Mache dir diese Gewohnheit früh angewöhnt: der Typ ist für Daten, der Fehler ist für Zustand.
Bevor du Methoden implementierst, triff ein paar Entscheidungen und bleibe konsistent:
Create: Akzeptierst du einen Input-Typ (ohne ID, ohne Timestamps) oder das volle Entity? Viele Teams bevorzugenCreate(ctx, in CreateX), um zu verhindern, dass Caller server-eigene Felder setzen.Update: Ist es ein vollständiges Ersetzen oder ein Patch? Wenn Patch, verwende keine Plain-Structs, bei denen Nullwerte mehrdeutig sind. Nutze Pointer, nullable Typen oder ein explizites Field-Mask.Delete: Hard delete oder soft delete? Wenn soft delete, entscheide, obGetstandardmäßig gelöschte Zeilen ausblendet.
Entscheide auch, was Schreibmethoden zurückgeben. Low-Surprise-Optionen sind, die aktualisierte Entity (nach DB-Defaults) zurückzugeben oder nur die ID plus ErrNotFound, wenn nichts geändert wurde.
Teststrategie für generische und entity-spezifische Teile
Dieser Ansatz lohnt sich nur, wenn er leicht zu vertrauen ist. Teile Tests entlang der gleichen Trennlinie wie den Code: teste geteilte Helfer einmal, teste dann jede Entity-SQL und das Scanning separat.
Behandle geteilte Teile als kleine pure Funktionen, wo möglich, z. B. Pagination-Validierung, Mapping von Sortier-Schlüsseln auf erlaubte Spalten oder das Bauen von WHERE-Fragmenten. Diese lassen sich mit schnellen Unit-Tests abdecken.
Für List-Queries eignen sich Table-Driven-Tests, weil Randfälle das ganze Problem sind. Decke Dinge wie leere Filter, unbekannte Sortier-Schlüssel, limit 0, Limits über dem Maximum, negative Offsets und „nächste Seite“-Grenzen ab, bei denen du eine zusätzliche Zeile holst.
Halte per-Entity-Tests fokussiert auf das, was wirklich entity-spezifisch ist: das erwartete SQL und wie Rows in den Entity-Typ scannen. Nutze einen SQL-Mock oder eine leichte Test-DB und stelle sicher, dass das Scan-Logic nulls, optionale Spalten und Typkonversionen behandelt.
Wenn dein Pattern Transaktionen unterstützt, teste Commit-/Rollback-Verhalten mit einem kleinen Fake-Executor, der Aufrufe protokolliert und Fehler simuliert:
- Begin liefert einen tx-gebundenen Executor
- bei Fehler wird einmal rollback aufgerufen
- bei Erfolg wird einmal commit aufgerufen
- wenn commit fehlschlägt, wird der Fehler unverändert zurückgegeben
Du kannst auch kleine „Contract-Tests“ hinzufügen, die jedes Repository bestehen muss: create-then-get liefert gleiche Daten, update ändert die beabsichtigten Felder, delete lässt get ErrNotFound liefern und list hat stabile Sortierung bei gleichen Inputs.
Häufige Fehler und Fallen
Generics verleiten dazu, ein Repository für alles zu bauen. Data-Access enthält viele kleine Unterschiede — und die zählen.
Ein paar Fallen tauchen oft auf:
- Überverallgemeinerung, bis jede Methode eine riesige Options-Map annimmt (Joins, Suche, Permissions, Soft Deletes, Caching). Dann hast du ein zweites ORM gebaut.
- Constraints, die zu clever sind. Wenn Leser Typ-Sets dekodieren müssen, um zu verstehen, was eine Entity implementieren muss, kostet die Abstraktion mehr, als sie nutzt.
- Input-Typen als DB-Modell behandeln. Wenn Create und Update dieselbe Struct nehmen, wie du aus Rows scannst, leaken DB-Details in Handler und Tests und Schema-Änderungen ziehen sich durchs ganze System.
- Stilles Verhalten in
List: instabile Sortierung, inkonsistente Defaults oder Paging-Regeln, die pro Entity variieren. - Not-Found-Handling, das Caller zwingt, Fehlermeldungen zu parsen, anstatt
errors.Iszu verwenden.
Ein konkretes Beispiel: ListCustomers liefert Kunden in unterschiedlicher Reihenfolge, weil das Repository kein ORDER BY setzt. Pagination dupliziert oder überspringt dann Datensätze zwischen Requests. Mach die Sortierung explizit (selbst wenn es nur nach Primärschlüssel ist) und halte Defaults konsistent.
Kurze Checkliste vor der Übernahme
Bevor du ein generisches Repository in jedes Package rollst, vergewissere dich, dass es Wiederholung entfernt, ohne wichtiges DB-Verhalten zu verbergen.
Fang mit Konsistenz an. Wenn ein Repo context.Context nimmt und ein anderes nicht, oder eines (T, error) zurückgibt und ein anderes (*T, error), taucht der Schmerz überall auf: Services, Tests und Mocks.
Stelle sicher, dass jede Entity einen offensichtlichen Ort für ihr SQL hat. Generics sollten den Ablauf (scannen, validieren, Fehler mappen) wiederverwenden, nicht Queries über String-Fragmente verstreuen.
Eine kurze Reihe von Checks, die die meisten Überraschungen verhindert:
- Eine Signaturkonvention für List/Get/Create/Update/Delete
- Eine vorhersehbare Not-Found-Regel, die jedes Repo verwendet
- Stabile List-Ordering, dokumentiert und getestet
- Ein sauberer Weg, denselben Code auf
*sql.DBund*sql.Txlaufen zu lassen (via Executor-Interface) - Eine klare Grenze zwischen generischem Code und Entity-Regeln (Validierung und Business-Checks bleiben außerhalb der generischen Schicht)
Wenn du interne Tools schnell in AppMaster baust und später den generierten Go-Code exportierst oder erweiterst, helfen diese Checks, die Datenebene vorhersehbar und testbar zu halten.
Ein realistisches Beispiel: Customer-Repository bauen
Hier ein kleines Customer-Repository, das typsicher bleibt, ohne clever zu werden.
Beginne mit einem gespeicherten Modell. Halte die ID stark typisiert, damit du sie nicht versehentlich mit anderen IDs mischst:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
Trenne nun „was die API akzeptiert“ von „was du speicherst“. Hier sollten Create und Update unterschiedlich sein.
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
Deine generische Basis kann den gemeinsamen Ablauf übernehmen (SQL ausführen, scannen, Fehler mappen), während das Customer-Repo das Customer-spezifische SQL und Mapping besitzt. Aus Sicht der Service-Schicht bleibt das Interface sauber:
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)
}
Für List behandle Filter und Pagination als erstklassiges Request-Objekt. Das hält Aufrufer lesbar und macht es schwerer, Limits zu vergessen.
type CustomerListQuery struct {
Status *string // filter
Search *string // name contains
Limit int
Offset int
}
Von dort skaliert das Pattern gut: Kopiere die Struktur für die nächste Entity, halte Inputs getrennt von gespeicherten Modellen und halte Scans explizit, damit Änderungen offensichtlich und vom Compiler erkennbar sind.
FAQ
Verwende Generics, um den Ablauf wiederzuverwenden (Query, Scan-Schleife, Not-Found-Behandlung, Pagination-Defaults, Fehler-Mapping), behalte SQL und Zeilen-Mapping aber pro Entity explizit. So bekommst du weniger Wiederholung, ohne die Datenebene in unsichtbare Laufzeit-Magie zu verwandeln.
Reflection verheimlicht Mapping-Regeln und verschiebt Fehler in die Laufzeit. Du verlierst Compiler-Prüfungen, IDE-Unterstützung leidet, und kleine Schemaänderungen werden zu Überraschungen. Mit Generics plus expliziten Scanner-Funktionen behältst du Typsicherheit und kannst trotzdem die sich wiederholenden Teile teilen.
Ein guter Default ist comparable, weil IDs verglichen, als Map-Keys verwendet und überall durchgereicht werden. Wenn dein System mehrere ID-Stile nutzt (z. B. int64 und UUID-Strings), vermeide eine feste Vorgabe und mache den ID-Typ generisch.
Halt es minimal: normalerweise nur das, was der gemeinsame CRUD-Ablauf braucht, z. B. GetID() und SetID(). Vermeide es, Felder per Embedding oder clevere Type-Sets vorzuschreiben — das koppelt deine Domain-Typen an das Repository-Pattern und erschwert Refactorings.
Nutze ein kleines Executor-Interface (oft DBTX genannt) mit genau den Methoden, die du aufrufst, z. B. QueryContext, QueryRowContext und ExecContext. So kann dein Repository gegen *sql.DB oder *sql.Tx laufen, ohne dass du Methoden duplizierst.
Die Rückgabe einer Null-Instanz mit nil Fehler verdeckt, ob ein Datensatz fehlt oder nur leere Felder hat. Ein gemeinsamer Sentinel-Fehler wie ErrNotFound hält den Zustand im Error-Kanal, sodass Service-Code zuverlässig mit errors.Is verzweigen kann.
Trenne Input-Typen von den gespeicherten Modellen. Bevorzuge Create(ctx, CreateInput) und Update(ctx, id, UpdateInput), damit Caller nicht server-eigene Felder wie IDs oder Timestamps setzen. Für Patch-Updates nutze Pointer (oder nullable Typen), um „nicht gesetzt“ von „auf Null gesetzt“ unterscheiden zu können.
Setze immer eine stabile, explizite ORDER BY, idealerweise auf einer eindeutigen Spalte wie dem Primärschlüssel. Ohne das kann Pagination Elemente überspringen oder duplizieren, wenn neue Zeilen auftauchen oder der Planner die Scan-Reihenfolge ändert.
Stelle ein kleines Set von Fehlern bereit, auf die Caller verzweigen können, z. B. ErrNotFound und ErrConflict, und wrappe alles andere mit Kontext aus dem zugrunde liegenden DB-Fehler. Lass Caller keine Strings parsen; ziele auf errors.Is-Checks und hilfreiche Log-Messages.
Teste shared Helfer einmal (Pagination-Normalisierung, Not-Found-Mapping, Affected-Row-Prüfungen), teste dann pro Entity SQL und Scanning separat. Füge kleine "Contract-Tests" pro Repository hinzu, z. B. create-then-get liefert gleiche Daten, update ändert erwartete Felder, delete sorgt dafür, dass get ErrNotFound liefert, und list hat stabile Sortierung.


