04 may 2025·7 min de lectura

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.

Patrón de repositorio CRUD con genéricos en Go para una capa de datos limpia

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:

  • ErrNotFound cuando un ID no existe
  • ErrConflict para violaciones únicas o choques de versión
  • ErrValidation cuando 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

Haz la lógica explícita
Pon las reglas de negocio en el Business Process Editor en lugar de esparcir comprobaciones por los repos.
Construir lógica

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 en E
  • 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

Despliega donde ejecutas
Despliega en AppMaster Cloud o en tu proveedor preferido sin rehacer el backend.
Desplegar app

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 prefieren Create(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 si Get oculta 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

Reduce el boilerplate con seguridad
Construye backends tipados y pantallas UI manteniendo el código fácil de extender.
Comenzar

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

Ve más allá del CRUD básico
Empieza con módulos integrados como autenticación y Stripe cuando tu app necesite algo más que CRUD.
Añadir módulos

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.DB y *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

What problem do generic CRUD repositories in Go actually solve?

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.

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

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.

What’s a sensible constraint for an ID type?

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.

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

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.

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

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.

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

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.

Should Create/Update take the full entity struct?

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.”

How do I keep List pagination from returning inconsistent results?

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.

What error contract should repositories provide to services?

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.

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

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.

Fácil de empezar
Crea algo sorprendente

Experimente con AppMaster con plan gratuito.
Cuando esté listo, puede elegir la suscripción adecuada.

Empieza