Padrão de repositório CRUD genérico em Go para uma camada de dados limpa
Aprenda um padrão prático de repositório CRUD com generics em Go para reutilizar lógica de list/get/create/update/delete com constraints legíveis, sem reflection e com código claro.

Por que repositórios CRUD ficam bagunçados em Go
Repositórios CRUD começam simples. Você escreve GetUser, depois ListUsers, depois o mesmo para Orders, depois Invoices. Algumas entidades depois, a camada de dados vira um monte de cópias quase idênticas onde pequenas diferenças são fáceis de perder.
O que mais se repete não é o SQL em si. É o fluxo ao redor: executar a query, fazer scan das linhas, tratar “não encontrado”, mapear erros do banco, aplicar padrões de paginação e converter inputs para os tipos certos.
Os pontos quentes habituais são familiares: código Scan duplicado, padrões repetidos de context.Context e transação, boilerplate de LIMIT/OFFSET (às vezes com contagens totais), a mesma checagem “0 rows significa não encontrado” e variações copiadas de INSERT ... RETURNING id.
Quando a repetição começa a doer, muitas equipes recorrem à reflection. Ela promete “escreva uma vez”: pega qualquer struct e preenche a partir das colunas em runtime. O custo aparece depois. Código pesado em reflection fica mais difícil de ler, o suporte do IDE piora e falhas saem do tempo de compilação para o tempo de execução. Pequenas mudanças, como renomear um campo ou adicionar uma coluna anulável, viram surpresas que só aparecem em testes ou produção.
Reuso type-safe significa compartilhar o fluxo CRUD sem abrir mão dos confortos do dia a dia em Go: assinaturas claras, tipos verificados pelo compilador e autocomplete que realmente ajuda. Com generics, você pode reutilizar operações como Get[T] e List[T] enquanto ainda exige que cada entidade forneça o que não pode ser adivinhado, como como escanear uma linha em T.
Este padrão trata deliberadamente da camada de acesso a dados. Mantém SQL e mapeamento consistentes e entediantes. Não tenta modelar seu domínio, impor regras de negócio ou substituir lógica de serviço.
Objetivos de design (e o que isso não tenta resolver)
Um bom padrão de repositório torna o acesso a banco previsível. Você deve conseguir ler um repositório e ver rapidamente o que ele faz, qual SQL executa e quais erros pode retornar.
Os objetivos são simples:
- Segurança de tipos de ponta a ponta (IDs, entidades e resultados não são
any) - Constraints que explicam a intenção sem malabarismos de tipos
- Menos boilerplate sem esconder comportamento importante
- Comportamento consistente entre List/Get/Create/Update/Delete
Os não-objetivos importam tanto quanto. Isso não é um ORM. Não deve adivinhar mapeamento de campos, fazer joins automáticos ou alterar queries silenciosamente. “Mapeamento mágico” te empurra de volta para reflection, tags e casos de borda.
Pressuponha um fluxo SQL normal: SQL explícito (ou um query builder fino), limites claros de transação e erros que você consiga raciocinar. Quando algo falhar, o erro deve dizer “não encontrado”, “conflito/violação de constraint” ou “DB indisponível”, não um vago “erro do repositório”.
A decisão chave é o que se torna genérico versus o que permanece por entidade.
- Genérico: o fluxo (executar query, scan, retornar valores tipados, traduzir erros comuns).
- Por entidade: o significado (nomes de tabela, colunas selecionadas, joins e strings SQL).
Forçar todas as entidades a um sistema universal de filtros geralmente torna o código mais difícil de ler do que escrever duas queries claras.
Escolhendo as constraints da entidade e do ID
A maior parte do código CRUD se repete porque cada tabela faz os mesmos movimentos básicos, mas cada entidade tem seus próprios campos. Com generics, o truque é compartilhar uma forma pequena e deixar o resto livre.
Comece decidindo o que o repositório realmente precisa saber sobre uma entidade. Para muitas equipes, a única peça universal é o ID. Timestamps podem ser úteis, mas não são universais, e forçá-los em todo tipo costuma deixar o modelo artificial.
Escolha um tipo de ID com o qual você possa viver
Seu tipo de ID deve corresponder a como você identifica linhas no banco. Alguns projetos usam int64, outros usam UUID strings. Se você quiser uma abordagem que funcione entre serviços, torne o ID genérico. Se toda sua base usar um tipo único de ID, mantê-lo fixo pode encurtar assinaturas.
Uma constraint padrão boa para IDs é comparable, já que você vai comparar IDs, usá-los como chaves de map e passá-los por aí.
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
Mantenha as constraints da entidade mínimas
Evite exigir campos via embedding de struct ou truques de type-set como ~struct{...}. Eles parecem poderosos, mas acoplam seus tipos de domínio ao padrão do repositório.
Em vez disso, exija apenas o que o fluxo CRUD compartilhado precisa:
- Obter e definir o ID (para que Create possa retorná-lo e Update/Delete possam usá-lo)
Se você depois adicionar recursos como soft deletes ou locking otimista, acrescente pequenas interfaces opt-in (por exemplo, GetVersion/SetVersion) e as use somente onde necessário. Interfaces pequenas tendem a envelhecer bem.
Uma interface genérica de repositório que continua legível
Uma interface de repositório deve descrever o que sua aplicação precisa, não o que o banco faz. Se a interface virar um vazamento de SQL, ela espalha detalhes por toda a aplicação.
Mantenha o conjunto de métodos pequeno e previsível. Coloque context.Context primeiro, depois o input principal (ID ou dados), depois os ajustes opcionais agrupados em uma 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, evite forçar um tipo de filtro universal. Filtros são onde entidades diferem mais. Uma abordagem prática é tipos de query por entidade mais uma pequena forma de paginação compartilhada que você pode embutir.
type Page struct {
Limit int
Offset int
}
O tratamento de erros é onde repositórios costumam ficar barulhentos. Decida antecipadamente em quais erros os callers podem ramificar. Um conjunto simples costuma funcionar:
ErrNotFoundquando um ID não existeErrConflictpara violações únicas ou conflitos de versãoErrValidationquando o input é inválido (só se o repo validar)
Todo o resto pode ser um erro de baixo nível envolvido (DB/rede). Com esse contrato, código de serviço pode tratar “não encontrado” ou “conflito” sem se preocupar se o armazenamento é PostgreSQL hoje ou outra coisa no futuro.
Como evitar reflection e ainda assim reutilizar o fluxo
Reflection costuma aparecer quando você quer que um pedaço de código “preencha qualquer struct”. Isso oculta erros até o tempo de execução e deixa as regras pouco claras.
Uma abordagem mais limpa é reaproveitar só as partes chatas: executar queries, iterar linhas, checar counts afetados e envolver erros de forma consistente. Mapeamento de/para structs deve permanecer explícito.
Separe responsabilidades: SQL, mapeamento, fluxo compartilhado
Uma divisão prática fica assim:
- Por entidade: mantenha as strings SQL e a ordem dos parâmetros em um lugar
- Por entidade: escreva pequenas funções de mapeamento que façam scan das linhas para a struct concreta
- Genérico: ofereça o fluxo compartilhado que executa a query e chama o mapper
Dessa forma, generics reduzem repetição sem esconder o que o banco está fazendo.
Aqui vai uma pequena abstração que deixa você passar *sql.DB ou *sql.Tx sem que o resto do código se importe:
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
}
O que generics devem (e não devem) fazer
A camada genérica não deve tentar “entender” sua struct. Em vez disso, deve aceitar funções explícitas que você fornece, como:
- um binder que transforma inputs em argumentos de query
- um scanner que lê colunas em uma entidade
Por exemplo, um repositório de Customer pode armazenar SQL como constantes (selectByID, insert, update) e implementar scanCustomer(rows) uma vez. Um List genérico pode cuidar do loop, do contexto e do envolvimento de erros, enquanto scanCustomer mantém o mapeamento seguro por tipos e óbvio.
Se você adicionar uma coluna, atualize o SQL e o scanner. O compilador ajuda a encontrar o que quebrou.
Passo a passo: implementando o padrão
O objetivo é um fluxo reutilizável para List/Get/Create/Update/Delete enquanto mantém cada repositório honesto sobre seu SQL e mapeamento de linhas.
1) Defina os tipos centrais
Comece com o mínimo de constraints possível. Escolha um tipo de ID que funcione para sua base e uma interface de repositório previsível.
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) Adicione um executor para DB e transações
Não amarre código genérico diretamente a *sql.DB ou *sql.Tx. Dependa de uma pequena interface executor que combine com o que você chama (QueryContext, ExecContext, QueryRowContext). Assim serviços podem passar um DB ou uma transação sem mudar o repositório.
3) Construa uma base genérica com o fluxo compartilhado
Crie um baseRepo[E,K] que armazene o executor e alguns campos de função. A base lida com as partes chatas: executar a query, mapear “não encontrado”, checar linhas afetadas e retornar erros consistentes.
4) Implemente partes específicas por entidade
Cada repositório de entidade fornece o que não pode ser genérico:
- SQL para list/get/create/update/delete
- uma função
scan(row)que converte uma linha emE - uma função
bind(...)que retorna os args da query
5) Conecte repositórios concretos e use-os dos serviços
Construa NewCustomerRepo(exec Executor) *CustomerRepo que embute ou envolve baseRepo. Sua camada de serviço depende da interface Repo[E,K] e decide quando abrir uma transação; o repositório apenas usa o executor recebido.
Lidando com List/Get/Create/Update/Delete sem surpresas
Um repositório genérico só ajuda se cada método se comportar do mesmo jeito em todos os lugares. A maior parte da dor vem de pequenas inconsistências: um repo ordena por created_at, outro por id; um retorna nil, nil para linhas faltantes, outro retorna erro.
List: paginação e ordenação que não mudam
Escolha um estilo de paginação e aplique consistentemente. Paginação por offset (limit/offset) é simples e funciona bem para telas admin. Cursor pagination é melhor para scroll infinito, mas precisa de uma chave de ordenação estável.
Seja qual for a escolha, torne a ordenação explícita e estável. Ordenar por uma coluna única (geralmente a PK) evita que itens pulem entre páginas quando novas linhas aparecem.
Get: um sinal claro de “não encontrado”
Get(ctx, id) deve retornar uma entidade tipada e um sinal claro de ausência, normalmente um erro sentinel compartilhado como ErrNotFound. Evite retornar um valor zero com erro nil. Chamadores não conseguem distinguir “ausente” de “campos vazios”.
Pegue esse hábito cedo: o tipo é para dados, o erro é para estado.
Antes de implementar métodos, tome algumas decisões e mantenha-as consistentes:
Create: você aceita um tipo de input (sem ID, sem timestamps) ou a entidade inteira? Muitos preferemCreate(ctx, in CreateX)para impedir que callers setem campos controlados pelo servidor.Update: é um replace completo ou um patch? Se for patch, não use structs comuns onde valores zero são ambíguos. Use ponteiros, tipos nullable ou uma máscara de campos explícita.Delete: hard delete ou soft delete? Se for soft delete, decida seGetoculta linhas deletadas por padrão.
Decida também o que métodos de escrita retornam. Opções de baixa surpresa incluem retornar a entidade atualizada (após defaults do DB) ou retornar apenas o ID mais ErrNotFound quando nada foi alterado.
Estratégia de testes para partes genéricas e específicas
Essa abordagem só compensa se for fácil de confiar. Separe testes da mesma forma que o código: teste helpers compartilhados uma vez, depois o SQL e o scan de cada entidade separadamente.
Trate pedaços compartilhados como pequenas funções puras sempre que possível, como validação de paginação, mapear chaves de ordenação para colunas permitidas ou construir fragmentos WHERE. Esses pontos podem ter testes unitários rápidos.
Para queries de list, testes dirigidos por tabela funcionam bem porque edge cases são o problema inteiro. Cubra casos como filtros vazios, chave de ordenação desconhecida, limit 0, limite acima do máximo, offset negativo e fronteiras de próxima página onde busca-se uma linha a mais.
Mantenha testes por entidade focados no que é verdadeiramente específico: o SQL esperado e como as linhas escaneiam para o tipo de entidade. Use um mock de SQL ou um banco de teste leve e verifique que o scanner lida com nulls, colunas opcionais e conversões de tipo.
Se seu padrão suporta transações, teste commit/rollback com um executor fake que registra chamadas e simula erros:
- Begin retorna um executor com escopo de tx
- em erro, rollback é chamado exatamente uma vez
- em sucesso, commit é chamado exatamente uma vez
- se commit falhar, o erro é retornado sem alteração
Você também pode adicionar pequenos “testes de contrato” que todo repositório deve passar: create então get retorna os mesmos dados, update muda os campos pretendidos, delete faz get retornar ErrNotFound e list retorna ordenação estável com os mesmos inputs.
Erros comuns e armadilhas
Generics tornam tentador construir um repositório que governe tudo. Acesso a dados está cheio de pequenas diferenças, e essas diferenças importam.
Algumas armadilhas comuns:
- Generalizar demais até que todo método receba um grande saco de opções (joins, busca, permissões, soft deletes, cache). Aí você construiu um segundo ORM.
- Constraints muito espertas. Se leitores precisam decodificar type sets para entender o que uma entidade deve implementar, a abstração custa mais do que economiza.
- Tratar tipos de input como o modelo de DB. Quando Create e Update usam a mesma struct que você escaneia das linhas, detalhes do DB vazam para handlers e testes, e mudanças de schema se espalham pela app.
- Comportamento silencioso em
List: ordenação instável, defaults inconsistentes ou regras de paginação que variam por entidade. - Tratamento de não-encontrado que força callers a parsear strings de erro em vez de usar
errors.Is.
Um exemplo concreto: ListCustomers retorna clientes em ordem diferente a cada chamada porque o repositório não define ORDER BY. Paginação então duplica ou pula registros entre requests. Torne a ordenação explícita (mesmo que seja só pela PK) e documente/teste o padrão.
Checklist rápido antes de adotar
Antes de espalhar um repositório genérico por todo pacote, garanta que ele remove repetição sem esconder comportamento importante do banco.
Comece com consistência. Se um repo aceita context.Context e outro não, ou um retorna (T, error) e outro (*T, error), a dor aparece em serviços, testes e mocks.
Garanta que cada entidade ainda tenha um lugar óbvio para seu SQL. Generics devem reaproveitar o fluxo (scan, validar, mapear erros), não espalhar queries por fragmentos de string.
Um conjunto rápido de checagens que previne a maioria das surpresas:
- Uma convenção de assinatura para List/Get/Create/Update/Delete
- Uma regra previsível de not-found usada por todo repo
- Ordenação estável e documentada para listas
- Uma forma limpa de rodar o mesmo código em
*sql.DBe*sql.Tx(via interface executor) - Uma fronteira clara entre código genérico e regras de entidade (validação e checagens de negócio ficam fora da camada genérica)
Se você está construindo ferramentas internas rapidamente no AppMaster e depois exportando ou estendendo o código Go gerado, essas checagens ajudam a manter a camada de dados previsível e fácil de testar.
Um exemplo realista: construindo um repositório Customer
Aqui vai um formato pequeno para Customer que permanece type-safe sem ficar esperto demais.
Comece com um modelo armazenado. Mantenha o ID fortemente tipado para não misturar com outros IDs por acidente:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // \"active\", \"blocked\", \"trial\"...
}
Agora separe “o que a API aceita” do “que você armazena”. É aqui que Create e Update devem diferir.
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
Sua base genérica pode cuidar do fluxo compartilhado (executar SQL, scan, mapear erros), enquanto o Customer repo possui o SQL específico do Customer e o mapeamento. Do ponto de vista da camada de serviço, a interface fica limpa:
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, trate filtros e paginação como um objeto de requisição de primeira classe. Mantém call sites legíveis e dificulta esquecer limites.
type CustomerListQuery struct {
Status *string // filter
Search *string // name contains
Limit int
Offset int
}
A partir daqui, o padrão escala bem: copie a estrutura para a próxima entidade, mantenha inputs separados dos modelos armazenados e mantenha o scan explícito para que mudanças fiquem óbvias e o compilador te ajude a encontrar quebrafes.
FAQ
Use generics para reutilizar o fluxo (consulta, loop de scan, tratamento de “não encontrado”, padrões de paginação, mapeamento de erros), mas mantenha SQL e mapeamento de colunas explícitos por entidade. Assim você reduz repetição sem transformar a camada de dados numa “mágica” que quebra em tempo de execução.
Reflection oculta as regras de mapeamento e move falhas para tempo de execução. Você perde checagens do compilador, o suporte do IDE fica pior e pequenas mudanças no schema viram surpresas. Com generics mais funções de scanner explícitas, você preserva a segurança de tipos enquanto compartilha as partes repetitivas.
Um bom padrão é usar comparable, pois IDs são comparados, usados como chaves de map e passados por aí. Se seu sistema usa vários estilos (como int64 e UUID strings), fazer o tipo de ID genérico evita forçar uma escolha em todos os repositórios.
Mantenha mínimo: geralmente só o que o fluxo CRUD compartilhado precisa, como GetID() e SetID(). Evite forçar campos comuns via embedding ou type sets complexos, porque isso acopla seus tipos de domínio ao padrão do repositório e dificulta refatorações.
Use uma pequena interface executor (por exemplo, DBTX) com apenas os métodos que você chama: QueryContext, QueryRowContext, ExecContext. Assim o código do repositório funciona tanto com *sql.DB quanto com *sql.Tx sem duplicação.
Evite retornar um valor zero com erro nil para “não encontrado”. Use um sentinel compartilhado como ErrNotFound para sinalizar ausência; assim o código chamador pode usar errors.Is para ramificar com segurança.
Separe inputs de create/update dos modelos armazenados. Prefira Create(ctx, CreateInput) e Update(ctx, id, UpdateInput) para impedir que callers setem campos controlados pelo servidor (IDs, timestamps). Para updates parciais, use ponteiros ou tipos anuláveis para distinguir “não enviado” de “enviado com zero”.
Sempre aplique um ORDER BY estável e explícito, idealmente por uma coluna única (por exemplo, a PK). Sem ordenação estável, paginação pode pular ou duplicar registros quando novas linhas surgem ou a ordem do plano muda.
Exponha um pequeno conjunto de erros que o chamador pode ramificar, como ErrNotFound e ErrConflict, e envolva o restante com contexto do erro de baixo nível (DB/rede). Não faça callers parsearem strings; prefira errors.Is e mensagens úteis para logs.
Teste helpers compartilhados uma vez (normalização de paginação, mapeamento de not-found, checagens de affected-rows) e teste o SQL e scan de cada entidade separadamente. Adicione testes de contrato por repositório: create→get devolve o mesmo dado, update altera campos esperados, delete faz get retornar ErrNotFound, list mantém ordenação estável.


