04 de mai. de 2025·6 min de leitura

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.

Padrão de repositório CRUD genérico em Go para uma camada de dados limpa

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:

  • ErrNotFound quando um ID não existe
  • ErrConflict para violações únicas ou conflitos de versão
  • ErrValidation quando 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

Faça deploy onde você roda
Faça deploy no AppMaster Cloud ou no seu provedor preferido sem reescrever o backend.
Deploy App

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

Padronize comportamento CRUD
Crie endpoints de list e get com comportamento estável entre entidades.
Gerar API

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 preferem Create(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 se Get oculta 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

Tenha controle do seu código Go
Gere código Go e mova para seu repositório quando quiser controle total.
Exportar Código

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

Do esquema ao app completo
Desenhe dados PostgreSQL e gere apps web e mobile por cima deles.
Começar a Construir

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

Qual problema repositórios CRUD genéricos em Go realmente resolvem?

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.

Por que evitar helpers CRUD baseados em reflection que “fazem scan de qualquer struct"?

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.

Qual é uma constraint sensata para o tipo de ID?

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.

O que a constraint da entidade deve incluir (e o que não deve)?

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.

Como dar suporte limpo a `*sql.DB` e `*sql.Tx`?

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.

Qual é a melhor forma de sinalizar “não encontrado” no Get?

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.

Create/Update deve receber a struct inteira da entidade?

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

Como evitar que paginação em List retorne resultados inconsistentes?

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.

Qual contrato de erros os repositórios devem fornecer aos serviços?

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.

Como testar um padrão genérico de repositório sem testar demais?

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.

Fácil de começar
Criar algo espantoso

Experimente o AppMaster com plano gratuito.
Quando estiver pronto, você poderá escolher a assinatura adequada.

Comece