Patrón de repositorio CRUD con genéricos en Go para una capa de datos limpia
Aprende un patrón práctico de repositorio CRUD con genéricos en Go para reutilizar la lógica de list/get/create/update/delete con restricciones claras, sin reflexión y con código legible.

Por qué los repositorios CRUD se vuelven desordenados en Go
Los repositorios CRUD empiezan sencillos. Escribes GetUser, luego ListUsers, luego lo mismo para Orders, luego Invoices. Tras unas cuantas entidades, la capa de datos se convierte en un montón de copias casi iguales donde las pequeñas diferencias son fáciles de pasar por alto.
Lo que más se repite no suele ser el SQL en sí. Es el flujo alrededor: ejecutar la consulta, escanear filas, manejar el “no encontrado”, mapear errores de BD, aplicar valores por defecto de paginación y convertir entradas a los tipos correctos.
Los puntos calientes habituales son conocidos: código Scan duplicado, patrones repetidos de context.Context y transacciones, boilerplate de LIMIT/OFFSET (a veces con conteos totales), la misma comprobación de “0 filas significa no encontrado” y variaciones copypasteadas de INSERT ... RETURNING id.
Cuando la repetición duele lo suficiente, muchos equipos recurren a la reflexión. Promete “escríbelo una vez”: toma cualquier struct y lo rellena desde columnas en tiempo de ejecución. El coste aparece después. El código pesado en reflexión es más difícil de leer, el soporte del IDE empeora y los fallos se trasladan del tiempo de compilación al de ejecución. Cambios pequeños, como renombrar un campo o añadir una columna nullable, se convierten en sorpresas que solo aparecen en tests o en producción.
El reuso seguro en tipos significa compartir el flujo CRUD sin renunciar a las comodidades habituales de Go: firmas claras, tipos verificados por el compilador y autocompletado que realmente ayuda. Con genéricos puedes reutilizar operaciones como Get[T] y List[T] mientras sigues requiriendo que cada entidad aporte lo que no se puede adivinar, por ejemplo cómo escanear una fila en T.
Este patrón trata deliberadamente de la capa de acceso a datos. Mantiene SQL y mapeo consistentes y aburridos. No trata de modelar tu dominio, imponer reglas de negocio ni sustituir la lógica a nivel de servicio.
Objetivos de diseño (y lo que esto no va a intentar resolver)
Un buen patrón de repositorio hace que el acceso a la base de datos sea predecible en el día a día. Deberías poder leer un repositorio y ver rápidamente qué hace, qué SQL ejecuta y qué errores puede devolver.
Los objetivos son simples:
- Seguridad de tipos de extremo a extremo (IDs, entidades y resultados no son
any) - Restricciones que expliquen la intención sin malabarismos de tipos
- Menos boilerplate sin ocultar comportamientos importantes
- Comportamiento consistente en List/Get/Create/Update/Delete
Los no-objetivos importan tanto como los objetivos. Esto no es un ORM. No debería adivinar mapeos de campos, hacer joins automáticos entre tablas ni cambiar consultas en silencio. El “mapeo mágico” te empuja de nuevo hacia la reflexión, etiquetas y casos límite.
Asume un flujo SQL normal: SQL explícito (o un pequeño query builder), límites claros de transacción y errores sobre los que puedas razonar. Cuando algo falla, el error debería decirte “not found”, “conflict/constraint violation” o “DB unavailable”, no un vago “repository error”.
La decisión clave es qué se vuelve genérico y qué se mantiene por entidad.
- Genérico: el flujo (ejecutar consulta, escanear, devolver valores tipados, traducir errores comunes).
- Por entidad: el significado (nombres de tabla, columnas seleccionadas, joins y strings SQL).
Forzar a todas las entidades dentro de un sistema universal de filtros suele hacer el código más difícil de leer que escribir dos consultas claras.
Elegir las restricciones para la entidad y el ID
La mayoría del código CRUD se repite porque cada tabla tiene los mismos movimientos básicos, pero cada entidad tiene sus propios campos. Con genéricos, el truco es compartir una pequeña forma y mantener todo lo demás libre.
Empieza por decidir qué necesita realmente saber el repositorio sobre una entidad. Para muchos equipos, la única pieza universal es el ID. Los timestamps pueden ser útiles, pero no son universales, y forzarlos en todos los tipos suele hacer que el modelo parezca falso.
Elige un tipo de ID con el que puedas vivir
Tu tipo de ID debe coincidir con cómo identificas filas en la base de datos. Algunos proyectos usan int64, otros usan UUID en forma de string. Si quieres un enfoque que funcione entre servicios, haz el ID genérico. Si todo tu código usa un solo tipo de ID, mantenerlo fijo puede acortar firmas.
Una buena restricción por defecto para IDs es comparable, ya que vas a comparar IDs, usarlos como claves de mapa y pasarlos por ahí.
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
Mantén las restricciones de entidad mínimas
Evita requerir campos mediante embedding de structs o trucos de conjuntos de tipos como ~struct{...}. Parecen potentes, pero acoplan tus tipos de dominio al patrón de repositorio.
En su lugar, exige solo lo que el flujo CRUD compartido necesita:
- Obtener y establecer el ID (para que Create pueda devolverlo, y Update/Delete puedan apuntarlo)
Si más adelante añades funciones como soft deletes u optimistic locking, añade pequeñas interfaces opt-in (por ejemplo, GetVersion/SetVersion) y úsalas solo donde sean necesarias. Las interfaces pequeñas tienden a envejecer bien.
Una interfaz de repositorio genérica que siga siendo legible
Una interfaz de repositorio debería describir lo que tu app necesita, no lo que la base de datos hace. Si la interfaz parece SQL, filtras detalles por todas partes.
Mantén el conjunto de métodos pequeño y predecible. Pon context.Context primero, luego la entrada primaria (ID o datos) y, luego, los botones opcionales agrupados en una 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
}
Para List, evita forzar un tipo de filtro universal. Los filtros son donde las entidades más difieren. Un enfoque práctico es tipos de consulta por entidad más un pequeño esquema de paginación compartido que puedas embeber.
type Page struct {
Limit int
Offset int
}
El manejo de errores es donde los repositorios suelen ponerse ruidosos. Decide desde el principio sobre qué errores pueden ramificarse los llamadores. Un conjunto simple suele funcionar:
ErrNotFoundcuando un ID no existeErrConflictpara violaciones únicas o choques de versiónErrValidationcuando la entrada es inválida (solo si el repo valida)
Todo lo demás puede ser un error de bajo nivel envuelto (BD/red). Con ese contrato, el código de servicio puede manejar “not found” o “conflict” sin importar si hoy el almacenamiento es PostgreSQL u otra cosa.
Cómo evitar la reflexión y aún así reutilizar el flujo
La reflexión suele colarse cuando quieres que una pieza de código “llene cualquier struct”. Eso oculta errores hasta la ejecución y hace las reglas poco claras.
Un enfoque más limpio es reutilizar solo las partes aburridas: ejecutar consultas, iterar filas, comprobar conteos afectados y envolver errores consistentemente. Mantén el mapeo hacia/desde structs explícito.
Divide responsabilidades: SQL, mapeo, flujo compartido
Una división práctica es:
- Por entidad: mantener los strings SQL y el orden de parámetros en un lugar
- Por entidad: escribir pequeñas funciones de mapeo que escaneen filas en el struct concreto
- Genérico: proporcionar el flujo compartido que ejecuta una consulta y llama al mapper
De ese modo, los genéricos reducen la repetición sin ocultar lo que hace la base de datos.
Aquí hay una pequeña abstracción que te permite pasar *sql.DB o *sql.Tx sin que el resto del código lo note:
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
}
Qué deben (y no deben) hacer los genéricos
La capa genérica no debería intentar “entender” tu struct. En su lugar, debe aceptar funciones explícitas que proporciones, tales como:
- un binder que convierte inputs en argumentos de consulta
- un scanner que lea columnas en una entidad
Por ejemplo, un repositorio Customer puede almacenar SQL como constantes (selectByID, insert, update) e implementar scanCustomer(rows) una vez. Un List genérico puede encargarse del bucle, el contexto y el wrapping de errores, mientras scanCustomer mantiene el mapeo seguro en tipos y obvio.
Si añades una columna, actualizas el SQL y el scanner. El compilador te ayuda a encontrar lo que se rompió.
Paso a paso: implementar el patrón
El objetivo es un flujo reutilizable para List/Get/Create/Update/Delete mientras cada repositorio se mantiene honesto sobre su SQL y mapeo de filas.
1) Define los tipos núcleo
Empieza con las menos restricciones posibles. Elige un tipo de ID que funcione para tu base de código y una interfaz de repositorio que siga siendo predecible.
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) Añade un ejecutor para BD y transacciones
No ligues el código genérico directamente a *sql.DB o *sql.Tx. Depende de una pequeña interfaz executor que coincida con lo que llamas (QueryContext, ExecContext, QueryRowContext). Así los servicios pueden pasar una DB o una transacción sin cambiar el código del repo.
3) Construye una base genérica con el flujo compartido
Crea un baseRepo[E,K] que guarde el executor y unos pocos campos de función. La base maneja las partes aburridas: llamar la consulta, mapear “no encontrado”, comprobar filas afectadas y devolver errores consistentes.
4) Implementa las piezas específicas por entidad
Cada repositorio de entidad proporciona lo que no puede ser genérico:
- SQL para list/get/create/update/delete
- una función
scan(row)que convierte una fila enE - una función
bind(...)que devuelve los args de la consulta
5) Conecta los repos concretos y úsalos desde servicios
Construye NewCustomerRepo(exec Executor) *CustomerRepo que embeberá o envolverá baseRepo. Tu capa de servicio depende de la interfaz Repo[E,K] y decide cuándo iniciar una transacción; el repositorio solo usa el executor que se le dio.
Manejar List/Get/Create/Update/Delete sin sorpresas
Un repositorio genérico solo ayuda si cada método se comporta igual en todas partes. La mayoría del dolor viene de pequeñas inconsistencias: un repo ordena por created_at, otro por id; uno devuelve nil, nil para filas faltantes y otro devuelve un error.
List: paginación y orden que no cambien
Elige un estilo de paginación y aplícalo de forma consistente. La paginación por offset (limit/offset) es simple y funciona bien para pantallas de administración. La paginación por cursor es mejor para scroll infinito, pero necesita una clave de ordenación estable.
Sea cual sea la opción, haz el orden explícito y estable. Ordenar por una columna única (a menudo la PK) evita que los elementos salten entre páginas cuando aparecen filas nuevas.
Get: una señal clara de “no encontrado”
Get(ctx, id) debería devolver una entidad tipada y una señal clara de registro ausente, normalmente un error sentinel compartido como ErrNotFound. Evita devolver una entidad con valor cero y error nil. Los llamadores no pueden distinguir “ausente” de “campos vacíos”.
Adopta este hábito pronto: el tipo es para datos, el error es para el estado.
Antes de implementar métodos, toma unas decisiones y mantenlas consistentes:
Create: ¿aceptas un tipo input (sin ID, sin timestamps) o una entidad completa? Muchos equipos prefierenCreate(ctx, in CreateX)para evitar que los llamadores establezcan campos propios del servidor.Update: ¿es reemplazo completo o patch? Si es patch, no uses structs planos donde los valores cero son ambiguos. Usa punteros, tipos nullable o una máscara de campos explícita.Delete: ¿borrado físico o soft delete? Si es soft delete, decide siGetoculta las filas borradas por defecto.
También decide qué devuelven los métodos de escritura. Opciones de baja sorpresa son devolver la entidad actualizada (tras defaults DB) o devolver solo el ID más ErrNotFound cuando nada cambió.
Estrategia de pruebas para partes genéricas y específicas
Este enfoque solo merece la pena si es fácil confiar en él. Separa tests igual que el código: prueba los helpers compartidos una vez y luego prueba el SQL y el escaneo de cada entidad por separado.
Trata las piezas compartidas como pequeñas funciones puras siempre que sea posible, como validación de paginación, mapeo de claves de orden a columnas permitidas o construcción de fragmentos WHERE. Estas se cubren con tests unitarios rápidos.
Para queries de lista, las pruebas basadas en tablas (table-driven) funcionan bien porque los casos límite son precisamente el problema. Cubre cosas como filtros vacíos, claves de orden desconocidas, limit 0, límite por encima del máximo, offset negativo y fronteras de “siguiente página” donde buscas una fila extra.
Mantén los tests por entidad enfocados en lo verdaderamente específico: el SQL que esperas ejecutar y cómo las filas se escanean en el tipo entidad. Usa un mock de SQL o una BD de pruebas ligera y asegura que la lógica de scan maneje nulls, columnas opcionales y conversiones de tipo.
Si tu patrón soporta transacciones, prueba commit/rollback con un pequeño executor falso que registre llamadas y simule errores:
- Begin devuelve un executor con scope de tx
- en error, rollback se llama exactamente una vez
- en éxito, commit se llama exactamente una vez
- si commit falla, el error se devuelve sin cambiar
También puedes añadir pequeñas “pruebas de contrato” que todo repositorio debe pasar: create luego get devuelve los mismos datos, update cambia los campos previstos, delete hace que get devuelva not found y list devuelve orden estable con las mismas entradas.
Errores comunes y trampas
Los genéricos invitan a construir un único repositorio para todo. El acceso a datos está lleno de pequeñas diferencias, y esas diferencias importan.
Algunas trampas habituales son:
- Sobre-generalizar hasta que cada método recibe una gran bolsa de opciones (joins, búsqueda, permisos, soft deletes, cache). En ese punto has construido un segundo ORM.
- Restricciones demasiado ingeniosas. Si los lectores necesitan descifrar conjuntos de tipos para entender qué debe implementar una entidad, la abstracción cuesta más de lo que ahorra.
- Tratar los tipos de entrada como el modelo DB. Cuando Create y Update usan el mismo struct que escaneas desde filas, los detalles de BD se filtran hacia handlers y tests y los cambios de esquema se propagan por la app.
- Comportamiento silencioso en
List: orden inestable, valores por defecto inconsistentes o reglas de paginación que varían por entidad. - Manejo de “no encontrado” que fuerza a los llamadores a parsear cadenas en vez de usar
errors.Is.
Un ejemplo concreto: ListCustomers devuelve clientes en orden distinto cada vez porque el repositorio no establece ORDER BY. La paginación entonces duplica u omite registros entre peticiones. Haz el orden explícito (aunque sea por PK) y mantén defaults consistentes.
Lista rápida antes de adoptar esto
Antes de lanzar un repositorio genérico en cada paquete, asegúrate de que elimina repetición sin ocultar comportamientos importantes de la base de datos.
Empieza por la consistencia. Si un repo toma context.Context y otro no, o uno devuelve (T, error) mientras otro devuelve (*T, error), el dolor aparece en todas partes: servicios, tests y mocks.
Asegúrate de que cada entidad tenga un hogar obvio para su SQL. Los genéricos deben reutilizar el flujo (scan, validar, mapear errores), no dispersar consultas en fragmentos de string.
Un conjunto de comprobaciones rápidas que evita la mayoría de sorpresas:
- Una convención de firmas para List/Get/Create/Update/Delete
- Una regla predecible de not-found usada por todos los repos
- Orden estable en list que esté documentado y testeado
- Una forma limpia de ejecutar el mismo código en
*sql.DBy*sql.Tx(vía una interfaz executor) - Un límite claro entre código genérico y reglas de entidad (validación y comprobaciones de negocio permanecen fuera de la capa genérica)
Si construyes herramientas internas rápidamente en AppMaster y luego exportas o extiendes el código Go generado, estas comprobaciones mantienen la capa de datos predecible y fácil de probar.
Un ejemplo realista: construir un repositorio Customer
Aquí tienes la forma de un pequeño repositorio Customer que se mantiene tipo-safe sin volverse ingenioso.
Empieza con un modelo almacenado. Mantén el ID fuertemente tipado para no mezclarlo por accidente:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
Ahora separa “lo que acepta la API” de “lo que guardas”. Aquí es donde Create y Update deberían diferir.
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
Tu base genérica puede encargarse del flujo compartido (ejecutar SQL, escanear, mapear errores), mientras que el repo Customer posee el SQL específico y el mapeo. Desde la capa de servicio, la interfaz permanece limpia:
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)
}
Para List, trata filtros y paginación como un objeto de petición de primera clase. Mantiene los sitios de llamada legibles y hace más difícil olvidar límites.
type CustomerListQuery struct {
Status *string // filter
Search *string // name contains
Limit int
Offset int
}
A partir de ahí, el patrón escala bien: copia la estructura para la siguiente entidad, mantiene inputs separados de los modelos almacenados y deja el escaneo explícito para que los cambios sigan siendo obvios y el compilador ayude.
FAQ
Usa genéricos para reutilizar el flujo (consulta, bucle de escaneo, manejo de “no encontrado”, valores por defecto de paginación, mapeo de errores), pero mantiene el SQL y el mapeo de filas explícitos por entidad. Así reduces repetición sin convertir la capa de datos en “magia” en tiempo de ejecución que falla silenciosamente.
La reflexión oculta las reglas de mapeo y traslada fallos al tiempo de ejecución. Pierdes comprobaciones del compilador, el soporte del IDE empeora y pequeños cambios de esquema se convierten en sorpresas. Con genéricos y funciones de scanner explícitas mantienes seguridad de tipos y a la vez reutilizas las partes repetitivas.
Un buen valor por defecto es comparable, porque los IDs se comparan, se usan como claves de mapa y se pasan por todo el código. Si tu sistema usa varios estilos de ID (por ejemplo int64 y UUID como string), hacer el tipo de ID genérico evita imponer una única elección en todos los repos.
Manténlo mínimo: normalmente solo lo que el flujo CRUD compartido necesita, como GetID() y SetID(). Evita forzar campos comunes vía embedding o conjuntos de tipos ingeniosos, porque eso acopla tus tipos de dominio al patrón de repositorio y dificulta refactorizaciones.
Usa una pequeña interfaz executor (a menudo llamada DBTX) que incluya sólo los métodos que llamas, por ejemplo QueryContext, QueryRowContext y ExecContext. Así el código del repo puede ejecutarse con *sql.DB o *sql.Tx sin ramificaciones ni duplicación de métodos.
Devolver un valor cero con error nil para “no encontrado” obliga a los llamadores a adivinar si la entidad falta o simplemente tiene campos vacíos. Un sentinel compartido como ErrNotFound coloca el estado en el canal de error, de modo que el código de servicio pueda ramificarse con errors.Is de forma fiable.
Separa los inputs del modelo almacenado. Prefiere Create(ctx, CreateInput) y Update(ctx, id, UpdateInput) para evitar que los llamadores fijen campos de servidor como IDs o timestamps. Para actualizaciones tipo patch, usa punteros (o tipos nulos) para distinguir “no seteado” de “seteado a cero.”
Usa un ORDER BY estable y explícito cada vez, idealmente en una columna única como la clave primaria. Sin eso, la paginación puede omitir o duplicar elementos entre peticiones cuando aparecen filas nuevas o el planificador cambia el orden de escaneo.
Expone un pequeño conjunto de errores sobre los que los llamadores puedan ramificarse, como ErrNotFound y ErrConflict, y envuelve todo lo demás con contexto del error de la base de datos subyacente. No obligues a los llamadores a parsear cadenas; apunta a comprobaciones con errors.Is y mensajes útiles para los logs.
Testea los helpers compartidos una vez (normalización de paginación, mapeo de “no encontrado”, comprobaciones de filas afectadas), y luego prueba por separado el SQL y el escaneo de cada entidad. Añade pequeñas “pruebas de contrato” por repo: create->get coincide, update cambia campos esperados, delete hace que get devuelva ErrNotFound, y list mantiene orden estable.


