04 mei 2025·6 min leestijd

CRUD-repositorypatroon met Go-generics voor een nette Go-datalaag

Leer een praktisch CRUD-repositorypatroon met Go-generics om list/get/create/update/delete-logica te hergebruiken met leesbare constraints, zonder reflectie en met duidelijke code.

CRUD-repositorypatroon met Go-generics voor een nette Go-datalaag

Waarom CRUD-repositories in Go rommelig worden

CRUD-repositories beginnen simpel. Je schrijft GetUser, daarna ListUsers, dan hetzelfde voor Orders, daarna Invoices. Na een paar entiteiten verandert de datalaag in een stapel bijna-kopieën waar kleine verschillen makkelijk over het hoofd worden gezien.

Wat het meest terugkeert is meestal niet de SQL zelf. Het is de omliggende flow: de query uitvoeren, rijen scannen, “niet gevonden” afhandelen, databasefouten omzetten, paginatie-standaarden toepassen en inputs naar de juiste types omzetten.

De gebruikelijke hotspots zijn herkenbaar: gedupliceerde Scan-code, herhaalde context.Context- en transactiestructuren, boilerplate LIMIT/OFFSET-afhandeling (soms met totale aantallen), dezelfde “0 rijen betekent niet gevonden”-controle en gekopieerde varianten van INSERT ... RETURNING id.

Als de herhaling genoeg begint te pijnigen, grijpen veel teams naar reflectie. Dat belooft “write it once” CRUD: neem een willekeurige struct en vul die tijdens runtime. De prijs verschijnt later. Reflectie-zware code is moeilijker te lezen, de IDE-ondersteuning verslechtert en fouten verhuizen van compile-tijd naar runtime. Kleine wijzigingen, zoals het hernoemen van een veld of het toevoegen van een nullable kolom, worden verrassingen die alleen in tests of productie opduiken.

Type-veilige herbruikbaarheid betekent de CRUD-flow delen zonder afstand te doen van de dagelijkse comfort van Go: duidelijke signaturen, compiler-gecontroleerde types en autocomplete die echt helpt. Met generics kun je operaties hergebruiken zoals Get[T] en List[T] terwijl je nog steeds van elke entiteit eist wat niet geraden kan worden, zoals hoe je een rij in T scant.

Dit patroon gaat expres over de data-access laag. Het houdt SQL en mapping consistent en saai. Het probeert je domein niet te modelleren, businessregels af te dwingen of service-level logica te vervangen.

Ontwerpdoelen (en wat dit niet probeert op te lossen)

Een goed repositorypatroon maakt dagelijks databasegebruik voorspelbaar. Je moet een repository kunnen lezen en snel zien wat hij doet, welke SQL hij draait en welke fouten hij kan retourneren.

De doelen zijn eenvoudig:

  • Typeveiligheid end-to-end (IDs, entiteiten en resultaten zijn geen any)
  • Constraints die intentie uitleggen zonder type-acrobatiek
  • Minder boilerplate zonder belangrijk gedrag te verbergen
  • Consistent gedrag voor List/Get/Create/Update/Delete

De niet-doelen zijn net zo belangrijk. Dit is geen ORM. Het zou geen veldmappings moeten raden, automatisch tabellen moeten joinen of queries stilletjes moeten veranderen. “Magische mapping” duwt je terug richting reflectie, tags en randgevallen.

Ga uit van een normale SQL-werkwijze: expliciete SQL (of een dunne querybuilder), duidelijke transactiegrenzen en fouten die je kunt beredeneren. Als iets faalt, moet de fout je vertellen “not found”, “conflict/constraint violation” of “DB unavailable”, niet een vage “repository error”.

De sleutelbeslissing is wat generiek wordt en wat per entiteit blijft.

  • Generiek: de flow (query uitvoeren, scannen, getypte waarden teruggeven, veelvoorkomende fouten vertalen).
  • Per entiteit: de betekenis (tabelnamen, geselecteerde kolommen, joins en SQL-strings).

Proberen om alle entiteiten in één universeel filter-systeem te dwingen maakt de code meestal lastiger te lezen dan het schrijven van twee duidelijke queries.

De entiteit- en ID-constraints kiezen

De meeste CRUD-code herhaalt zich omdat elke tabel dezelfde basismoves heeft, maar elke entiteit eigen velden. Met generics is de truc een kleine vorm te delen en de rest vrij te houden.

Begin met beslissen wat het repository echt over een entiteit moet weten. Voor veel teams is het enige universele stuk de ID. Timestamps kunnen nuttig zijn, maar zijn niet universeel en ze in elk type forceren maakt het model vaak onnatuurlijk.

Kies een ID-type waar je mee kunt leven

Je ID-type moet overeenkomen met hoe je rijen in de database identificeert. Sommige projecten gebruiken int64, andere gebruiken UUID-strings. Als je één aanpak wilt die in alle services werkt, maak het ID generiek. Als je hele codebase één ID-type gebruikt, kan het vastzetten daarvan signaturen verkorten.

Een goede default-constraint voor IDs is comparable, omdat je IDs zult vergelijken, als map-keys gebruiken en rondgeven.

type ID interface {
	comparable
}

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

Houd entiteitsconstraints minimaal

Vermijd het verplicht stellen van velden via struct-embedding of type-set trucs zoals ~struct{...}. Ze zien er krachtig uit, maar koppelen je domeintypes aan je repositorypatroon.

Eis in plaats daarvan alleen wat de gedeelde CRUD-flow nodig heeft:

  • Get en set de ID (zodat Create deze kan teruggeven en Update/Delete hierop kunnen richten)

Als je later features toevoegt zoals soft deletes of optimistic locking, voeg dan kleine opt-in interfaces toe (bijv. GetVersion/SetVersion) en gebruik die alleen waar nodig. Kleine interfaces verouderen meestal goed.

Een generieke repository-interface die leesbaar blijft

Een repository-interface moet beschrijven wat je app nodig heeft, niet wat de database toevallig doet. Als de interface naar SQL gaat ruiken, lekt die details overal.

Houd de methodeset klein en voorspelbaar. Zet context.Context eerst, daarna de primaire input (ID of data), daarna optionele knoppen gebundeld in een 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
}

Voor List vermijd je het forceren van een universeel filtertype. Filters verschillen het meest per entiteit. Een praktische aanpak is per-entiteit querytypes plus een kleine gedeelde paginatiestructuur die je kunt embedden.

type Page struct {
	Limit  int
	Offset int
}

Foutenafhandeling is waar repositories vaak lawaaiig worden. Beslis vooraf op welke fouten callers mogen branch-en. Een eenvoudige set werkt meestal:

  • ErrNotFound wanneer een ID niet bestaat
  • ErrConflict voor unieke schendingen of versieconflicten
  • ErrValidation wanneer input ongeldig is (alleen als de repo valideert)

Alles anders kan een gewrapte low-level fout zijn (DB/netwerk). Met dat contract kan servicecode “not found” of “conflict” afhandelen zonder te weten of de opslag vandaag PostgreSQL is of iets anders later.

Hoe reflectie te vermijden maar toch de flow te hergebruiken

Own your Go output
Genereer Go-broncode en verplaats het naar je eigen repo wanneer je volledige controle nodig hebt.
Exporteer code

Reflectie sluipt meestal binnen wanneer je één stuk code wilt dat “elke struct vult”. Dat verbergt fouten tot runtime en maakt de regels onduidelijk.

Een schonere aanpak is slechts de saaie delen te hergebruiken: queries uitvoeren, rijen loopen, affected counts controleren en consistent errors wrappen. Houd mapping naar en van structs expliciet.

Verdeel verantwoordelijkheden: SQL, mapping, gedeelde flow

Een praktische scheiding ziet er zo uit:

  • Per entiteit: houd SQL-strings en parameter-volgorde op één plek
  • Per entiteit: schrijf kleine mappingfuncties die rijen in de concrete struct scannen
  • Generiek: bied de gedeelde flow die een query uitvoert en de mapper aanroept

Op die manier verminderen generics herhaling zonder te verbergen wat de database doet.

Hier is een kleine abstractie die je toelaat om of *sql.DB of *sql.Tx door te geven zonder dat de rest van de code zich daar om hoeft te bekommeren:

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
}

Wat generics wél (en niet) moeten doen

De generieke laag mag niet proberen je struct “te begrijpen”. In plaats daarvan moet hij expliciete functies accepteren die jij levert, zoals:

  • een binder die inputs in query-argumenten omzet
  • een scanner die kolommen in een entiteit leest

Bijvoorbeeld kan een Customer-repo zijn SQL als constants bewaren (selectByID, insert, update) en scanCustomer(rows) één keer implementeren. Een generieke List kan de loop, context en error-wrapping afhandelen, terwijl scanCustomer de mapping typeveilig en duidelijk houdt.

Als je een kolom toevoegt, update je de SQL en de scanner. De compiler helpt je vinden wat kapot is.

Stap voor stap: het patroon implementeren

Het doel is één herbruikbare flow voor List/Get/Create/Update/Delete terwijl je elk repository eerlijk houdt over zijn SQL en row-mapping.

1) Definieer de kerntypen

Begin met de kleinste mogelijke constraints. Kies een ID-type dat bij je codebase past en een repository-interface die voorspelbaar blijft.

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) Voeg een executor toe voor DB en transacties

Koppel generieke code niet direct aan *sql.DB of *sql.Tx. Veronderstel een kleine executor-interface die overeenkomt met wat je aanroept (QueryContext, ExecContext, QueryRowContext). Diensten kunnen dan een DB of een transactie doorgeven zonder de repositorycode te veranderen.

3) Bouw een generieke basis met gedeelde flow

Maak een baseRepo[E,K] die de executor en een paar functievelden bewaart. De basis handelt de saaie delen af: de query aanroepen, “niet gevonden” mappen, affected rows checken en consistente fouten teruggeven.

4) Implementeer de entiteit-specifieke stukken

Elke entiteits-repo levert wat niet generiek kan zijn:

  • SQL voor list/get/create/update/delete
  • een scan(row)-functie die een rij naar E converteert
  • een bind(...)-functie die query-args teruggeeft

5) Koppel concrete repos en gebruik ze vanuit services

Bouw NewCustomerRepo(exec Executor) *CustomerRepo die baseRepo embedt of wrapt. Je servicelaag hangt af van de Repo[E,K]-interface en beslist wanneer een transactie te starten; het repository gebruikt gewoon de executor die het kreeg.

List/Get/Create/Update/Delete afhandelen zonder verrassingen

Keep inputs consistent
Scheid create- en update-inputs netjes zodat handlers voorspelbaar blijven als schema's evolueren.
Probeer nu

Een generieke repository helpt alleen als elke methode overal hetzelfde werkt. De meeste pijn komt van kleine inconsistenties: de ene repo ordent op created_at, een andere op id; de ene retourneert nil, nil voor ontbrekende rijen, een andere geeft een fout.

List: paginatie en ordering die niet schuift

Kies één paginatiestijl en pas die consequent toe. Offset-paginatie (limit/offset) is eenvoudig en werkt goed voor admin-schermen. Cursor-paginatie is beter voor endless scrolling, maar heeft een stabiele sorteerkey nodig.

Maak ordering expliciet en stabiel. Sorteren op een unieke kolom (meestal de primaire sleutel) voorkomt dat items tussen pagina's verschuiven wanneer er nieuwe rijen bijkomen.

Get: een duidelijk “niet gevonden”-signaal

Get(ctx, id) moet een getypte entiteit en een duidelijk ontbrekend-recordsignaal retourneren, gewoonlijk een gedeelde sentinel error zoals ErrNotFound. Vermijd het retourneren van een zero-waarde entiteit met een nil error. Callers kunnen dan niet onderscheiden of iets ontbreekt of lege velden heeft.

Raak deze gewoonte vroeg aan: het type is voor data, de error is voor toestand.

Voordat je methodes implementeert, maak een paar beslissingen en houd ze consistent:

  • Create: accepteer je een inputtype (zonder ID, zonder timestamps) of een volledige entiteit? Veel teams geven de voorkeur aan Create(ctx, in CreateX) om te voorkomen dat callers server-eigenschappen instellen.
  • Update: is het een volledige vervanging of een patch? Als het een patch is, gebruik dan geen gewone structs waar zero-waarden ambigu zijn. Gebruik pointers, nullable types of een expliciet field-mask.
  • Delete: hard delete of soft delete? Als het soft delete is, beslis of Get standaard verwijderde rijen verbergt.

Beslis ook wat schrijfmethoden teruggeven. Lage-verrassingsopties zijn het teruggeven van de geüpdatete entiteit (na DB-defaults) of alleen het ID plus ErrNotFound wanneer er niets veranderd is.

Teststrategie voor generieke en entiteit-specifieke delen

Reduce boilerplate safely
Bouw getypte backends en UI-schermen terwijl de codebase eenvoudig uitbreidbaar blijft.
Aan de slag

Deze aanpak betaalt zich alleen uit als hij gemakkelijk te vertrouwen is. Verdeel tests op dezelfde lijn als de code: test gedeelde helpers één keer, en test daarna de SQL en scanning per entiteit.

Behandel gedeelde stukken als kleine pure functies waar mogelijk, zoals paginatie-validatie, het mappen van sort keys naar toegestane kolommen of het bouwen van WHERE-fragmenten. Deze zijn geschikt voor snelle unit-tests.

Voor list-queries werken table-driven tests goed omdat randgevallen het hele probleem zijn. Dek zaken af zoals lege filters, onbekende sort keys, limit 0, limiet boven max, negatieve offset en “volgende pagina”-grenzen waar je één extra rij ophaalt.

Houd per-entiteit tests gefocust op wat echt entiteit-specifiek is: de verwachte SQL en hoe rijen in het entiteit-type scannen. Gebruik een SQL-mock of een lichte testdatabase en zorg dat scan-logic nulls, optionele kolommen en typeconversies afhandelt.

Als je patroon transacties ondersteunt, test commit/rollback-gedrag met een kleine fake executor die aanroepen registreert en fouten simuleert:

  • Begin retourneert een tx-georiënteerde executor
  • bij error wordt rollback exact één keer aangeroepen
  • bij succes wordt commit exact één keer aangeroepen
  • als commit faalt, wordt de fout onveranderd teruggegeven

Je kunt ook kleine “contracttests” toevoegen die elke repository moet halen: create gevolgd door get geeft dezelfde data, update verandert de bedoelde velden, delete zorgt dat get ErrNotFound retourneert en list geeft stabiele ordering voor dezelfde inputs.

Veelgemaakte fouten en valkuilen

Generics maken het verleidelijk om één repository te maken voor alles. Data-access zit vol kleine verschillen, en die verschillen doen ertoe.

Een paar vaak voorkomende valkuilen:

  • Over-generaliseren totdat elke methode een gigantische optionele bag accepteert (joins, search, permissions, soft deletes, caching). Op dat punt heb je een tweede ORM gebouwd.
  • Constraints die te slim zijn. Als lezers type-sets moeten decoderen om te begrijpen wat een entiteit moet implementeren, kost de abstractie meer dan ze oplevert.
  • Invoertypes behandelen als het DB-model. Als Create en Update dezelfde struct gebruiken die je vanuit rijen scant, lekken DB-details naar handlers en tests en rippleert schema-wijziging door de hele app.
  • Stil gedrag in List: onstabiele sortering, inconsistente defaults of paginatieregels die per entiteit variëren.
  • Not-found handling die callers dwingt foutstrings te parsen in plaats van errors.Is te gebruiken.

Een concreet voorbeeld: ListCustomers retourneert klanten in verschillende volgorde omdat de repository geen ORDER BY zet. Paginatie dupliceert of slaat dan records over tussen verzoeken. Maak ordering expliciet (ook al is het alleen op de primaire sleutel) en test en documenteer die defaults.

Snelle checklist voordat je dit adopteert

Prototype admin tools faster
Bouw interne tools met UI, auth en businesslogica zonder eindeloze repository-code te schrijven.
Maak tool

Voordat je een generieke repository in elk pakket gaat gebruiken, zorg dat hij herhaling vermindert zonder belangrijk databasegedrag te verbergen.

Begin met consistentie. Als de ene repo context.Context neemt en de andere niet, of de ene (T, error) teruggeeft en de andere (*T, error), dan wordt de pijn overal zichtbaar: services, tests en mocks.

Zorg dat elke entiteit nog steeds één duidelijke plek heeft voor zijn SQL. Generics moeten de flow hergebruiken (scannen, valideren, fouten mappen), niet queries over strings verspreiden.

Een korte set checks die de meeste verrassingen voorkomt:

  • Eén signatuurconventie voor List/Get/Create/Update/Delete
  • Eén voorspelbare not-found-regel gebruikt door elke repo
  • Stabiele list-ordering die is gedocumenteerd en getest
  • Een nette manier om dezelfde code op *sql.DB en *sql.Tx te draaien (via een executor-interface)
  • Een duidelijke grens tussen generieke code en entiteitsregels (validatie en businesschecks blijven buiten de generieke laag)

Als je snel interne tools bouwt in AppMaster en later de gegenereerde Go-code exporteert of uitbreidt, helpen deze checks de datalaag voorspelbaar en goed testbaar te houden.

Een realistisch voorbeeld: een Customer-repository bouwen

Hier is een kleine Customer-repo-opzet die typeveilig blijft zonder te slim te doen.

Begin met een opgeslagen model. Houd de ID sterk getypeerd zodat je die niet per ongeluk met andere IDs mengt:

type CustomerID int64

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

Scheid vervolgens wat de API accepteert van wat je opslaat. Dit is waar Create en Update moeten verschillen.

type CreateCustomerInput struct {
	Name   string
	Status string
}

type UpdateCustomerInput struct {
	Name   *string
	Status *string
}

Je generieke basis kan de gedeelde flow afhandelen (SQL uitvoeren, scannen, fouten mappen), terwijl de Customer-repo de Customer-specifieke SQL en mapping bezit. Vanuit de servicelaag blijft de interface netjes:

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

Voor List behandel filters en paginatie als een volwaardige request-struct. Dat houdt call-sites leesbaar en maakt het lastiger limieten te vergeten.

type CustomerListQuery struct {
	Status *string // filter
	Search *string // naam bevat
	Limit  int
	Offset int
}

Van daar groeit het patroon goed: kopieer de structuur voor de volgende entiteit, houd inputs gescheiden van opgeslagen modellen en houd scanning expliciet zodat wijzigingen zichtbaar en compiler-vriendelijk blijven.

FAQ

Welk probleem lossen generieke CRUD-repositories in Go eigenlijk op?

Gebruik generics om de flow te hergebruiken (query, scan-loop, not-found handling, paginatie-standaarden, error-mapping), maar houd SQL en row-mapping expliciet per entiteit. Zo verminder je herhaling zonder je datalaag te veranderen in runtime “magic” die stilletjes faalt.

Waarom vermijden reflection-gebaseerde “scan any struct” CRUD-hulpmiddelen?

Reflectie verbergt mappingregels en verschuift fouten naar runtime. Je verliest compilerchecks, IDE-ondersteuning wordt minder nuttig en kleine schemawijzigingen worden verrassingen. Met generics plus expliciete scanner-functies behoud je typeveiligheid en kun je toch de repetitieve delen delen.

Wat is een verstandige constraint voor een ID-type?

Een goede default is comparable, omdat IDs vergeleken worden, als map-keys gebruikt worden en overal worden doorgegeven. Als je systeem meerdere ID-stijlen gebruikt (zoals int64 en UUID-strings), voorkomt een generieke ID-parameter dat je één keuze door het hele project moet forceren.

Wat moet de entiteitsconstraint bevatten (en wat niet)?

Houd het minimaal: meestal alleen wat de gedeelde CRUD-flow nodig heeft, zoals GetID() en SetID(). Vermijd het afdwingen van gemeenschappelijke velden via embedding of slimme type-sets, want dat koppelt je domeintypes aan het repository-patroon en maakt refactors pijnlijk.

Hoe ondersteun ik zowel *sql.DB als *sql.Tx op een nette manier?

Gebruik een kleine executor-interface (vaak DBTX genoemd) die alleen de methoden bevat die je aanroept, zoals QueryContext, QueryRowContext en ExecContext. Dan werkt je repositorycode zowel met *sql.DB als met *sql.Tx zonder conditionals of duplicatie.

Wat is de beste manier om “not found” te signaleren vanuit Get?

Het retourneren van een zero-waarde met een nil error voor “niet gevonden” dwingt callers te raden of de entiteit ontbreekt of gewoon lege velden heeft. Een gedeelde sentinel zoals ErrNotFound houdt de status in het error-kanaal, zodat servicecode betrouwbaar kan branch-en met errors.Is.

Moeten Create/Update de volledige entiteitstruct aannemen?

Scheid inputs van opgeslagen modellen. Geef de voorkeur aan Create(ctx, CreateInput) en Update(ctx, id, UpdateInput) zodat callers geen server-beheerde velden zoals IDs of timestamps kunnen instellen. Voor patch-updates gebruik je pointers (of nullable types) zodat je ‘niet ingesteld’ kunt onderscheiden van ‘naar nul zetten’.

Hoe voorkom ik dat List-paginatie inconsistente resultaten geeft?

Zorg altijd voor een expliciete, stabiele ORDER BY, bij voorkeur op een unieke kolom zoals de primaire sleutel. Zonder die ordering kan paginatie records overslaan of dupliceren tussen requests wanneer er nieuwe rijen bijkomen of de planner de scan-order verandert.

Welk error-contract moeten repositories aan services bieden?

Bied een klein aantal errors aan waarop callers mogen branch-en, zoals ErrNotFound en ErrConflict, en wrap alle andere fouten met context uit de onderliggende DB-error. Laat callers niet strings parsen; mik op errors.Is checks plus een nuttig logbericht.

Hoe test ik een generiek repositorypatroon zonder te veel te testen?

Test gedeelde helpers één keer (paginatie-normalisatie, not-found mapping, affected-row checks) en test daarna per entiteit de SQL en scanning apart. Voeg kleine “contracttests” per repository toe: create-then-get geeft hetzelfde terug, update verandert de bedoelde velden, delete zorgt dat get ErrNotFound retourneert en list heeft stabiele ordering.

Gemakkelijk te starten
Maak iets geweldigs

Experimenteer met AppMaster met gratis abonnement.
Als je er klaar voor bent, kun je het juiste abonnement kiezen.

Aan de slag