Skip to main content


Organização do projeto​


Quando começamos a desenvolver uma aplicação Go com banco de dados, é comum manter a conexão, as consultas SQL e as regras da aplicação dentro de um único arquivo. Essa abordagem pode funcionar em projetos pequenos, mas conforme novas funcionalidades são adicionadas, o código começa a reunir responsabilidades diferentes, dificultando sua manutenção.


Imagine um sistema de cadastro de produtos que precisa inserir, consultar, atualizar e remover registros. Se todas essas operações forem implementadas diretamente na função main(), qualquer alteração nas consultas ou nas regras de cadastro exigirá modificar o mesmo arquivo. Além disso, outras partes da aplicação poderão precisar executar as mesmas operações, aumentando a repetição de código.


Para evitar isso, podemos organizar o projeto em pacotes com responsabilidades específicas. Uma parte fica responsável por configurar o acesso ao banco, outra concentra as consultas SQL e outra implementa as regras que determinam como os dados podem ser cadastrados ou modificados.


No Go, essa separação é feita principalmente por meio de pacotes, que permitem agrupar arquivos relacionados e compartilhar funções, tipos e estruturas entre diferentes partes da aplicação. Não existe uma única organização obrigatória, mas a estrutura deve facilitar o entendimento do código sem criar divisões desnecessárias.



Separando conexão com banco​


A conexão com o banco é uma das primeiras responsabilidades que podemos separar. Em vez de chamar sql.Open() diretamente em todas as partes da aplicação, podemos criar um pacote responsável por configurar o acesso ao SQLite e devolver um *sql.DB pronto para executar operações.


Essa separação permite concentrar informações como o nome do driver, o caminho do arquivo, a configuração do pool de conexões e a verificação de acesso. Se precisarmos modificar alguma dessas configurações, teremos um local específico para realizar a alteração.


Vamos começar com uma estrutura simples.

meu-projeto/
main.go
go.mod

internal/
database/
sqlite.go

O diretório internal possui um comportamento especial no Go. Ele permite organizar pacotes que pertencem à implementação interna do projeto, impedindo que sejam importados por códigos externos à árvore de diretórios permitida. Isso ajuda a manter as partes internas da aplicação separadas das funcionalidades que poderiam ser compartilhadas com outros projetos.


Dentro dele, criamos o pacote database, que ficará responsável exclusivamente pela configuração do acesso ao banco. O nome sqlite.go identifica o arquivo que contém essa implementação, mas não possui nenhum comportamento especial para o compilador.


Criando o pacote database​


Vamos criar uma função chamada AbrirSQLite(), que recebe o contexto da operação e o caminho do arquivo do banco. Sua responsabilidade será criar o gerenciador de conexões, configurar o pool e verificar se o SQLite está acessível.

// internal/database/sqlite.go

package database

import (
"context"
"database/sql"
"fmt"

// Registra o driver SQLite.
_ "modernc.org/sqlite"
)

// AbrirSQLite configura o acesso ao banco.
func AbrirSQLite(ctx context.Context, caminho string) (*sql.DB, error) {
// Cria o gerenciador de conexões.
db, err := sql.Open("sqlite", caminho)
if err != nil {
return nil, fmt.Errorf("erro ao abrir SQLite: %w", err)
}

// Configura o pool de conexões.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)

// Verifica se o banco está acessível.
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("erro ao conectar no SQLite: %w", err)
}

return db, nil
}

A função recebe ctx context.Context, que permite controlar o cancelamento da verificação de conexão, e caminho string, que representa a localização do arquivo SQLite. O método sql.Open() cria o gerenciador de conexões, enquanto PingContext() verifica se o banco pode ser acessado.


Também configuramos o pool para manter apenas uma conexão aberta. Essa configuração simplifica o funcionamento de pequenos projetos SQLite, embora não seja obrigatória em todas as aplicações. Se ocorrer algum erro durante a verificação, chamamos db.Close() para liberar os recursos antes de retornar o problema.


O retorno *sql.DB permite que outras partes da aplicação executem consultas usando o mesmo gerenciador de conexões. Como os métodos de acesso ao banco ficarão em outros pacotes, não precisamos implementar comandos INSERT, SELECT, UPDATE ou DELETE dentro de database.


Compartilhando o gerenciador de conexões​


Depois de criar a função, podemos chamá-la no arquivo main.go e compartilhar o *sql.DB com as partes da aplicação que precisam acessar o banco.

package main

import (
"context"
"log"
"time"

"exemplo.com/loja/internal/database"
)

func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}

func executar() error {
// Cria um contexto com limite de cinco segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()

// Abre o banco SQLite.
db, err := database.AbrirSQLite(ctx, "dados.db")
if err != nil {
return err
}

// Fecha o gerenciador quando a função terminar.
defer db.Close()

return nil
}

Nesse exemplo, importamos o pacote database usando o caminho definido pelo módulo Go. O prefixo exemplo.com/loja representa o nome do módulo, que deve corresponder ao valor declarado no arquivo go.mod. Já internal/database representa o caminho do pacote dentro do projeto.


A função AbrirSQLite() devolve o gerenciador de conexões, que fica armazenado na variável db. Podemos passar essa variável para outras funções sem precisar abrir um novo gerenciador a cada operação. O sql.DB já possui controle interno de concorrência e um pool de conexões, permitindo que a mesma instância seja compartilhada entre diferentes partes da aplicação.


Também usamos defer db.Close() para encerrar o gerenciador quando a função executar() terminar. Em aplicações maiores, normalmente mantemos esse gerenciador aberto durante todo o funcionamento do programa, fechando apenas durante seu encerramento.


Uma diferença importante é que o pacote database não precisa conhecer as tabelas ou as regras de negócio. Sua responsabilidade termina depois de configurar o acesso ao banco. A criação e alteração das tabelas podem continuar sendo controladas pelas migrations, enquanto as operações com os registros ficam em uma camada separada.



Criando uma camada repository​


A camada repository é responsável por concentrar as operações de acesso aos dados, permitindo que outras partes da aplicação trabalhem com registros sem precisar escrever comandos SQL diretamente. Em vez de executar um SELECT ou INSERT em qualquer função, podemos criar métodos específicos para buscar, cadastrar, atualizar e remover informações.


Imagine que precisamos buscar um produto pelo identificador. Sem uma camada repository, diferentes partes da aplicação poderiam repetir a mesma consulta SQL sempre que precisassem dessa informação. Com um repository, podemos criar uma função BuscarPorID() e chamar esse método sempre que precisarmos recuperar um produto.


Essa separação também facilita alterações no banco. Se modificarmos a estrutura de uma tabela ou a maneira como determinada consulta funciona, podemos ajustar o código do repository sem precisar modificar todas as partes da aplicação que dependem daquela operação.


Podemos representar essa estrutura da seguinte maneira.

       Aplicação Go
|
| Solicita uma operação
v
Repository
|
| Executa comandos SQL
v
database/sql
|
v
Driver SQLite
|
v
dados.db

O repository conhece a estrutura das tabelas e os comandos necessários para trabalhar com elas. Entretanto, ele não deve concentrar todas as regras de negócio da aplicação. Sua principal responsabilidade é executar as operações de persistência e devolver os resultados ou erros encontrados.


Criando um modelo para os produtos​


Antes de implementar o repository, precisamos definir como os produtos serão representados dentro do Go. Podemos criar um pacote chamado model, responsável por armazenar as structs que representam as informações com as quais a aplicação trabalha.

internal/
database/
sqlite.go

model/
produto.go

repository/
produto.go

O pacote model terá uma struct chamada Produto, contendo os campos que representam um registro da tabela produtos.

// internal/model/produto.go

package model

// Produto representa um produto cadastrado.
type Produto struct {
ID int64
Nome string
PrecoCentavos int64
Estoque int64
Ativo bool
}

O campo ID representa o identificador do produto, enquanto Nome, PrecoCentavos e Estoque armazenam suas informações principais. Já Ativo representa o estado do produto por meio de um booleano, permitindo trabalhar com true e false dentro da aplicação.


Essa struct não possui comandos SQL e não precisa importar o pacote database/sql. Ela representa apenas os dados do produto, permitindo que diferentes partes da aplicação compartilhem a mesma estrutura.


Também precisamos considerar que criar ou modificar uma struct não altera automaticamente os registros armazenados no banco. A persistência dessas informações continuará sendo responsabilidade do repository.


Criando o repository de produtos​


Agora podemos criar uma estrutura responsável por armazenar o gerenciador de conexões e oferecer os métodos necessários para trabalhar com produtos.

// internal/repository/produto.go

package repository

import (
"database/sql"
)

// ProdutoRepository concentra o acesso aos produtos.
type ProdutoRepository struct {
db *sql.DB
}

// NovoProdutoRepository recebe o gerenciador de conexões.
func NovoProdutoRepository(db *sql.DB) *ProdutoRepository {
return &ProdutoRepository{
db: db,
}
}

A struct ProdutoRepository possui um campo chamado db, que armazena o ponteiro para o gerenciador de conexões. Esse campo não precisa ser exportado porque será acessado apenas pelos métodos implementados dentro do próprio pacote repository.


A função NovoProdutoRepository() recebe o *sql.DB criado anteriormente e devolve um ponteiro para a estrutura ProdutoRepository. Esse tipo de função é conhecido como construtor, porque sua responsabilidade é criar e configurar um valor que será usado em outras partes da aplicação.


O construtor não abre uma nova conexão com o banco. Ele apenas recebe o gerenciador que já existe, permitindo que todos os métodos do repository compartilhem o mesmo sql.DB.


Podemos criar uma instância do repository da seguinte maneira.

// Cria o repository usando o banco já configurado.
produtoRepo := repository.NovoProdutoRepository(db)

A variável produtoRepo passa a representar a estrutura responsável pelas operações de acesso aos produtos. Nos próximos exemplos, vamos adicionar métodos para executar as operações básicas de cadastro, busca, atualização e remoção.



Criando funções para inserir, buscar, atualizar e remover​


Agora que temos uma estrutura para representar os produtos e um repository responsável pelo acesso ao banco, podemos implementar os métodos necessários para executar as operações CRUD. Cada método receberá os dados necessários, executará o comando SQL correspondente e devolverá o resultado ou erro encontrado.


Vamos usar métodos com context.Context, permitindo que as operações recebam informações de cancelamento e prazo máximo. Também vamos manter os comandos SQL com placeholders, evitando concatenar diretamente valores recebidos pela aplicação.


Os próximos métodos devem ser adicionados ao arquivo internal/repository/produto.go. Para isso, precisamos ampliar seus imports para incluir os pacotes usados pelas operações.

package repository

import (
"context"
"database/sql"
"fmt"

"exemplo.com/loja/internal/model"
)

O pacote context permite receber o contexto das operações, enquanto database/sql fornece os métodos de consulta e execução. Já fmt será usado para adicionar informações aos erros, e o pacote model fornece a struct Produto.


Inserindo produtos​


Para cadastrar um produto, podemos criar o método Inserir(), que recebe um ponteiro para model.Produto. O ponteiro permite atualizar o campo ID depois que o SQLite gerar o identificador do registro.

// Inserir cadastra um novo produto.
func (r *ProdutoRepository) Inserir(
ctx context.Context,
produto *model.Produto,
) error {
// Executa a inserção no banco.
resultado, err := r.db.ExecContext(ctx, `
INSERT INTO produtos (
nome,
preco_centavos,
estoque,
ativo
)
VALUES (?, ?, ?, ?)
`,
produto.Nome,
produto.PrecoCentavos,
produto.Estoque,
produto.Ativo,
)

if err != nil {
return fmt.Errorf("erro ao inserir produto: %w", err)
}

// Recupera o identificador gerado pelo SQLite.
id, err := resultado.LastInsertId()
if err != nil {
return fmt.Errorf("erro ao recuperar ID: %w", err)
}

// Atualiza o ID da struct original.
produto.ID = id

return nil
}

O método recebe o produto e executa um INSERT usando seus campos. Observe que a consulta é executada por r.db.ExecContext(), porque o gerenciador de conexões está armazenado dentro do repository. Os valores continuam sendo passados separadamente, seguindo a ordem dos placeholders definidos no comando SQL.


Depois da inserção, usamos LastInsertId() para recuperar o identificador gerado pelo SQLite. Como recebemos um ponteiro para a struct, podemos atribuir esse valor diretamente a produto.ID, fazendo com que a aplicação também conheça o identificador do registro cadastrado.


O repository não verifica se o nome está vazio ou se o preço faz sentido para a aplicação. Essas validações serão responsabilidade de outra camada, enquanto o banco continuará aplicando as restrições definidas em sua estrutura.


Buscando um produto pelo ID​


Para buscar um produto específico, podemos criar o método BuscarPorID(), que recebe seu identificador e retorna uma struct preenchida com os valores encontrados.

// BuscarPorID consulta um produto pelo identificador.
func (r *ProdutoRepository) BuscarPorID(
ctx context.Context,
id int64,
) (model.Produto, error) {
var produto model.Produto

// Executa a consulta pelo ID.
err := r.db.QueryRowContext(ctx, `
SELECT
id,
nome,
preco_centavos,
estoque,
ativo
FROM produtos
WHERE id = ?
`, id).Scan(
&produto.ID,
&produto.Nome,
&produto.PrecoCentavos,
&produto.Estoque,
&produto.Ativo,
)

if err != nil {
return model.Produto{}, fmt.Errorf(
"erro ao buscar produto %d: %w",
id,
err,
)
}

return produto, nil
}

O método QueryRowContext() executa a consulta e Scan() copia os valores retornados para os campos da struct. Como estamos buscando pela chave primária, esperamos receber no máximo um registro, então não precisamos percorrer várias linhas.


Se o produto não existir, Scan() retornará sql.ErrNoRows. O repository preserva esse erro por meio de %w, permitindo que outras partes da aplicação identifiquem essa condição usando errors.Is().


Quando a consulta funciona corretamente, retornamos a struct preenchida e um erro igual a nil. Dessa maneira, quem chama o repository recebe os dados organizados em um model.Produto, sem precisar conhecer os detalhes da consulta SQL.


Listando produtos​


Para consultar vários produtos, podemos criar um método Listar(), que executa um SELECT e converte cada linha retornada em uma struct. Os produtos serão armazenados em um slice, permitindo que a aplicação percorra os registros depois da consulta.

// Listar retorna os produtos cadastrados.
func (r *ProdutoRepository) Listar(
ctx context.Context,
) ([]model.Produto, error) {
// Consulta os produtos em ordem de ID.
rows, err := r.db.QueryContext(ctx, `
SELECT
id,
nome,
preco_centavos,
estoque,
ativo
FROM produtos
ORDER BY id ASC
`)

if err != nil {
return nil, fmt.Errorf("erro ao listar produtos: %w", err)
}

// Libera os recursos da consulta.
defer rows.Close()

// Slice que receberá os produtos.
produtos := make([]model.Produto, 0)

for rows.Next() {
var produto model.Produto

// Copia os valores da linha atual.
if err := rows.Scan(
&produto.ID,
&produto.Nome,
&produto.PrecoCentavos,
&produto.Estoque,
&produto.Ativo,
); err != nil {
return nil, fmt.Errorf("erro ao ler produto: %w", err)
}

produtos = append(produtos, produto)
}

// Verifica erros durante a leitura.
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("erro ao percorrer produtos: %w", err)
}

return produtos, nil
}

O método QueryContext() retorna um *sql.Rows, permitindo percorrer os registros com Next(). A cada iteração, criamos uma nova struct Produto, copiamos os valores com Scan() e adicionamos o resultado ao slice por meio de append().


Depois do laço, verificamos rows.Err() para identificar possíveis erros durante a leitura. Se nenhum produto for encontrado, o método retornará um slice vazio, porque uma consulta que não encontra registros não representa necessariamente uma falha.


Também usamos defer rows.Close() para liberar os recursos da consulta quando a função terminar. O método não fecha o gerenciador sql.DB, apenas encerra o acesso ao resultado que estava sendo percorrido.


Atualizando produtos​


Para modificar um produto existente, podemos criar o método Atualizar(), que recebe a struct contendo os novos valores e executa um UPDATE usando o identificador como condição.

// Atualizar modifica os dados de um produto.
func (r *ProdutoRepository) Atualizar(
ctx context.Context,
produto model.Produto,
) error {
// Atualiza o registro pelo identificador.
resultado, err := r.db.ExecContext(ctx, `
UPDATE produtos
SET
nome = ?,
preco_centavos = ?,
estoque = ?,
ativo = ?
WHERE id = ?
`,
produto.Nome,
produto.PrecoCentavos,
produto.Estoque,
produto.Ativo,
produto.ID,
)

if err != nil {
return fmt.Errorf("erro ao atualizar produto: %w", err)
}

// Verifica se algum registro foi encontrado.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}

if linhas == 0 {
return sql.ErrNoRows
}

return nil
}

O comando UPDATE modifica os campos informados em SET, enquanto WHERE id = ? restringe a alteração ao produto correspondente. Nesse exemplo, todos os campos informados no comando serão atualizados, mesmo que apenas um deles tenha sido modificado na aplicação.


Depois da execução, verificamos RowsAffected() para identificar se algum registro foi afetado. Quando o resultado é zero, retornamos sql.ErrNoRows, permitindo que a aplicação trate a ausência do produto como uma situação específica.


Esse método espera receber uma struct com todos os campos necessários para a atualização. Se quisermos atualizar apenas uma informação sem modificar as demais, podemos criar métodos específicos, como AtualizarEstoque() ou AtualizarPreco(), evitando substituir campos que não deveriam ser alterados.


Removendo produtos​


Para remover um produto, podemos criar o método Remover(), que recebe seu identificador e executa um DELETE com a condição correspondente.

// Remover exclui um produto pelo identificador.
func (r *ProdutoRepository) Remover(
ctx context.Context,
id int64,
) error {
// Remove o produto pelo ID.
resultado, err := r.db.ExecContext(ctx,
"DELETE FROM produtos WHERE id = ?",
id,
)

if err != nil {
return fmt.Errorf("erro ao remover produto: %w", err)
}

// Verifica se algum registro foi removido.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}

if linhas == 0 {
return sql.ErrNoRows
}

return nil
}

O método ExecContext() executa o comando DELETE, enquanto o parâmetro id indica qual produto deve ser removido. Depois disso, verificamos a quantidade de linhas afetadas para identificar se o registro realmente existia.


Observe que os métodos do repository seguem uma estrutura semelhante. Cada um recebe as informações necessárias, executa uma operação SQL e devolve um resultado ou erro. Essa padronização permite trabalhar com os dados sem espalhar consultas SQL pelo restante da aplicação.



Separando regras de negócio das consultas SQL​


Até agora, criamos um repository responsável por cadastrar, buscar, listar, atualizar e remover produtos. Entretanto, ainda precisamos definir quais informações podem ser cadastradas e quais condições devem ser atendidas antes de executar determinadas operações.


Essas condições fazem parte das regras de negócio, que representam as regras específicas do funcionamento da aplicação. Em um sistema de produtos, por exemplo, podemos exigir que o nome não esteja vazio, que o preço não seja negativo e que o estoque não possua uma quantidade menor que zero.


Embora o SQLite permita definir algumas dessas restrições diretamente na tabela, nem todas as regras pertencem ao banco. Algumas dependem do funcionamento da aplicação e podem envolver diferentes operações ou verificações.


Para separar essas responsabilidades, podemos criar uma camada chamada service, responsável por executar as regras de negócio e solicitar ao repository as operações necessárias para armazenar ou recuperar informações.

          Aplicação Go
|
| Solicita uma operação
v
Service
|
| Verifica regras de negócio
v
Repository
|
| Executa consultas SQL
v
database/sql
|
v
SQLite

Nesse modelo, a aplicação chama o service, que verifica as condições necessárias e encaminha a operação ao repository. O repository não precisa conhecer as regras específicas do sistema, enquanto o service não precisa conhecer os comandos SQL usados para acessar os registros.


Essa organização também permite modificar determinadas regras sem alterar as consultas. Se decidirmos que o preço mínimo de um produto deve ser diferente, por exemplo, podemos ajustar a validação no service, mantendo o comando de inserção do repository praticamente igual.


Criando o pacote service​


Vamos adicionar um diretório chamado service, responsável pelas regras de negócio dos produtos.

internal/
database/
sqlite.go

model/
produto.go

repository/
produto.go

service/
produto.go

Dentro desse diretório, vamos criar um arquivo produto.go, contendo uma struct que recebe o repository e oferece os métodos responsáveis pelas operações da aplicação.

// internal/service/produto.go

package service

import (
"context"
"fmt"
"strings"

"exemplo.com/loja/internal/model"
"exemplo.com/loja/internal/repository"
)

// ProdutoService concentra as regras dos produtos.
type ProdutoService struct {
repo *repository.ProdutoRepository
}

// NovoProdutoService recebe o repository.
func NovoProdutoService(
repo *repository.ProdutoRepository,
) *ProdutoService {
return &ProdutoService{
repo: repo,
}
}

A struct ProdutoService possui um campo chamado repo, que armazena uma referência ao repository responsável pelos produtos. O construtor NovoProdutoService() recebe essa dependência e devolve a estrutura configurada.


Observe que o service não recebe diretamente o *sql.DB. Ele depende do repository, que já possui os métodos necessários para executar as operações no banco. Essa separação evita que as regras de negócio precisem conhecer o driver SQLite ou os comandos SQL.


O construtor também permite que a instância do repository seja criada em outra parte da aplicação e entregue ao service. Essa maneira de fornecer uma dependência é conhecida como injeção de dependência, porque o service recebe o componente de que precisa, em vez de criá-lo internamente.


Validando os dados do produto​


Antes de cadastrar ou atualizar um produto, precisamos verificar se as informações atendem às regras definidas pela aplicação. Podemos concentrar essas verificações em uma função chamada validarProduto(), evitando repetir as mesmas condições em diferentes métodos.

// validarProduto verifica as informações obrigatórias.
func validarProduto(produto model.Produto) error {
// Impede nomes vazios ou formados apenas por espaços.
if strings.TrimSpace(produto.Nome) == "" {
return fmt.Errorf("o nome do produto é obrigatório")
}

// Impede preços negativos.
if produto.PrecoCentavos < 0 {
return fmt.Errorf("o preço não pode ser negativo")
}

// Impede quantidades negativas.
if produto.Estoque < 0 {
return fmt.Errorf("o estoque não pode ser negativo")
}

return nil
}

A função recebe um model.Produto e verifica suas informações antes que sejam enviadas ao banco. Usamos strings.TrimSpace() para remover espaços das extremidades do nome durante a validação, impedindo que um texto composto apenas por espaços seja considerado válido.


Também verificamos se o preço e o estoque possuem valores negativos. Se alguma regra não for atendida, retornamos um erro descrevendo o problema. Caso contrário, a função retorna nil, indicando que os dados passaram pelas verificações.


Essas regras também podem existir como restrições no SQLite. A diferença é que o service permite identificar problemas antes de executar comandos SQL e apresentar mensagens relacionadas ao funcionamento da aplicação, enquanto as restrições do banco continuam protegendo os dados armazenados.


Cadastrando produtos pelo service​


Agora podemos criar um método Cadastrar(), que recebe as informações do produto, verifica suas regras e solicita ao repository a inserção do registro.

// Cadastrar valida e cadastra um produto.
func (s *ProdutoService) Cadastrar(
ctx context.Context,
nome string,
precoCentavos int64,
estoque int64,
) (model.Produto, error) {
// Cria o produto com seus valores iniciais.
produto := model.Produto{
Nome: strings.TrimSpace(nome),
PrecoCentavos: precoCentavos,
Estoque: estoque,
Ativo: true,
}

// Verifica as regras de negócio.
if err := validarProduto(produto); err != nil {
return model.Produto{}, err
}

// Solicita a inserção ao repository.
if err := s.repo.Inserir(ctx, &produto); err != nil {
return model.Produto{}, err
}

return produto, nil
}

O método recebe o nome, o preço e a quantidade em estoque, criando uma struct com essas informações. Também define Ativo como true, porque queremos que os novos produtos comecem ativos. Depois disso, chamamos validarProduto() para verificar se os valores são permitidos.


Se ocorrer algum erro de validação, a função retorna antes de acessar o banco. Caso as informações sejam válidas, chamamos s.repo.Inserir(), que executa o comando SQL responsável pelo cadastro e atualiza o identificador da struct.


O método retorna o produto cadastrado, incluindo o ID gerado pelo SQLite. Dessa forma, a aplicação recebe um resultado completo sem precisar conhecer o comando INSERT ou o funcionamento de LastInsertId().


Buscando e listando produtos​


As operações de busca e listagem também podem passar pelo service. Mesmo que inicialmente não possuam muitas regras de negócio, essa separação mantém um caminho consistente para acessar os dados e permite adicionar verificações posteriormente.

// BuscarPorID consulta um produto.
func (s *ProdutoService) BuscarPorID(
ctx context.Context,
id int64,
) (model.Produto, error) {
// Verifica se o identificador é válido.
if id <= 0 {
return model.Produto{}, fmt.Errorf("ID inválido")
}

return s.repo.BuscarPorID(ctx, id)
}

// Listar consulta todos os produtos.
func (s *ProdutoService) Listar(
ctx context.Context,
) ([]model.Produto, error) {
return s.repo.Listar(ctx)
}

O método BuscarPorID() verifica se o identificador informado é maior que zero e encaminha a consulta ao repository. Já Listar() apenas chama o método correspondente, porque ainda não precisamos aplicar nenhuma regra adicional nessa operação.


Isso não significa que todo método do service precisa realizar uma validação. Algumas funções podem apenas encaminhar operações, principalmente quando queremos manter um ponto central para as regras da aplicação.


Também não precisamos criar uma camada service em qualquer projeto. Em aplicações muito pequenas, chamadas diretas ao repository podem ser suficientes. A separação começa a fazer mais sentido quando existem regras que precisam ser compartilhadas entre diferentes operações.


Atualizando produtos pelo service​


Para atualizar um produto, podemos criar um método que verifica os dados recebidos e solicita ao repository a atualização do registro correspondente.

// Atualizar valida e modifica um produto.
func (s *ProdutoService) Atualizar(
ctx context.Context,
produto model.Produto,
) error {
// Verifica o identificador.
if produto.ID <= 0 {
return fmt.Errorf("ID inválido")
}

// Remove espaços desnecessários do nome.
produto.Nome = strings.TrimSpace(produto.Nome)

// Verifica as regras do produto.
if err := validarProduto(produto); err != nil {
return err
}

// Solicita a atualização ao repository.
return s.repo.Atualizar(ctx, produto)
}

O método verifica se o produto possui um identificador válido e aplica as mesmas regras usadas durante o cadastro. Depois disso, chama s.repo.Atualizar(), que executa o comando SQL com os novos valores.


Observe que o service não precisa saber como o repository constrói o UPDATE. Ele apenas determina se a operação pode continuar e envia os dados que precisam ser persistidos.


Como nosso método atualiza todos os campos do produto, precisamos passar uma struct completa. Se quisermos alterar apenas o preço ou o estoque, podemos criar métodos específicos que modificam somente essas informações, evitando substituir valores que não deveriam mudar.


Removendo produtos pelo service​


A operação de remoção segue o mesmo princípio. Podemos verificar se o identificador é válido e solicitar ao repository que execute o comando SQL correspondente.

// Remover exclui um produto pelo identificador.
func (s *ProdutoService) Remover(
ctx context.Context,
id int64,
) error {
// Verifica se o identificador é válido.
if id <= 0 {
return fmt.Errorf("ID inválido")
}

// Solicita a remoção ao repository.
return s.repo.Remover(ctx, id)
}

Nesse exemplo, o service apenas valida o identificador e encaminha a remoção. Entretanto, poderíamos adicionar outras regras, como impedir a exclusão de produtos associados a pedidos ou exigir que o produto seja desativado em vez de removido fisicamente do banco.


Essas verificações fariam parte das regras de negócio, enquanto o repository continuaria responsável por executar as consultas necessárias.



Organização de um pequeno projeto com banco​


Agora podemos reunir as estruturas anteriores em um pequeno projeto Go que trabalha com SQLite. A aplicação terá um pacote responsável pela conexão, outro para representar os produtos, um repository para executar as consultas e um service para aplicar as regras de negócio.


Também vamos manter as migrations em um diretório separado, seguindo o mesmo mecanismo estudado anteriormente. Dessa forma, o código responsável por acessar o banco não precisa criar ou modificar tabelas durante cada operação.


A estrutura final será semelhante a esta.

meu-projeto/
main.go
go.mod
go.sum
dados.db

migrations/
00001_criar_produtos.sql

internal/
database/
sqlite.go

model/
produto.go

repository/
produto.go

service/
produto.go

O arquivo main.go representa o ponto de entrada da aplicação, sendo responsável por configurar as dependências e executar as operações principais. O diretório internal/database configura o acesso ao SQLite, enquanto internal/model armazena as estruturas que representam os produtos.


Já internal/repository concentra as consultas SQL, e internal/service implementa as regras de negócio. O diretório migrations mantém o histórico das alterações estruturais do banco, permitindo criar as tabelas sem misturar esse processo com as operações CRUD.


Essa organização cria uma separação clara entre as responsabilidades, mas não significa que todos os projetos Go precisam possuir exatamente esses diretórios. Em aplicações menores, podemos começar com menos pacotes e criar novas divisões conforme o código crescer.


Inicializando o módulo​


Para criar o projeto, começamos inicializando um módulo Go. O nome escolhido será exemplo.com/loja, que também será usado nos imports dos pacotes internos.

# Cria o diretório do projeto.
mkdir meu-projeto

# Entra no diretório.
cd meu-projeto

# Inicializa o módulo Go.
go mod init exemplo.com/loja

# Adiciona o driver SQLite.
go get modernc.org/sqlite

O comando go mod init cria o arquivo go.mod, que identifica o módulo e serve como prefixo para os caminhos dos pacotes internos. Já go get adiciona o driver SQLite como dependência do projeto, permitindo que o pacote database importe modernc.org/sqlite.


O nome exemplo.com/loja é apenas um identificador usado neste material. Em um projeto real, podemos escolher outro caminho de módulo, mas precisamos manter os imports internos correspondentes ao nome definido no go.mod.


Criando a migration inicial​


Como já estudamos migrations, vamos manter a criação da tabela em um arquivo SQL separado. Para isso, criamos o diretório migrations e adicionamos o arquivo 00001_criar_produtos.sql.

-- migrations/00001_criar_produtos.sql

-- +goose Up

-- Cria a tabela de produtos.
CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL CHECK (length(trim(nome)) > 0),
preco_centavos INTEGER NOT NULL CHECK (preco_centavos >= 0),
estoque INTEGER NOT NULL DEFAULT 0 CHECK (estoque >= 0),
ativo INTEGER NOT NULL DEFAULT 1 CHECK (ativo IN (0, 1))
);

-- +goose Down

-- Remove a tabela de produtos.
DROP TABLE produtos;

A migration cria a tabela produtos, que corresponde aos campos definidos na struct model.Produto. O banco também possui restrições que impedem nomes vazios ou formados apenas por espaços comuns, preços negativos, quantidades negativas e valores diferentes de 0 e 1 para o campo ativo.


Essas restrições não substituem as validações do service. Elas funcionam como uma proteção adicional, porque continuam sendo aplicadas mesmo quando um comando SQL é executado sem passar pelas regras da aplicação.


Para aplicar a migration, podemos usar o goose, que foi apresentado no tópico anterior.

# Aplica as migrations pendentes ao banco.
goose -dir migrations sqlite3 ./dados.db up

O comando executa as migrations ainda não aplicadas, criando a tabela produtos dentro do arquivo dados.db. Depois disso, a aplicação poderá acessar o banco usando o mesmo caminho informado na configuração da conexão.


Conectando as camadas no main.go​


Com os pacotes criados e a estrutura do banco disponível, precisamos conectar as diferentes partes da aplicação. Essa configuração será feita no arquivo main.go, que cria o gerenciador de conexões, entrega esse gerenciador ao repository e passa o repository para o service.

package main

import (
"context"
"fmt"
"log"
"time"

"exemplo.com/loja/internal/database"
"exemplo.com/loja/internal/repository"
"exemplo.com/loja/internal/service"
)

func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}

func executar() error {
// Cria um contexto com limite de cinco segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()

// Abre o banco SQLite.
db, err := database.AbrirSQLite(ctx, "dados.db")
if err != nil {
return err
}

// Fecha o banco quando a função terminar.
defer db.Close()

// Cria o repository usando o banco.
produtoRepo := repository.NovoProdutoRepository(db)

// Cria o service usando o repository.
produtoService := service.NovoProdutoService(produtoRepo)

// Cadastra um produto.
produto, err := produtoService.Cadastrar(
ctx,
"Teclado",
15990,
10,
)

if err != nil {
return err
}

fmt.Println("Produto cadastrado:", produto.ID)

// Busca o produto cadastrado.
produto, err = produtoService.BuscarPorID(ctx, produto.ID)
if err != nil {
return err
}

fmt.Println("Produto encontrado:", produto.Nome)

// Modifica os dados do produto.
produto.Nome = "Teclado mecânico"
produto.PrecoCentavos = 18990
produto.Estoque = 8

// Atualiza o produto no banco.
if err := produtoService.Atualizar(ctx, produto); err != nil {
return err
}

fmt.Println("Produto atualizado!")

// Lista os produtos cadastrados.
produtos, err := produtoService.Listar(ctx)
if err != nil {
return err
}

for _, p := range produtos {
fmt.Printf(
"ID: %d | Nome: %s | Preço: %d | Estoque: %d\n",
p.ID,
p.Nome,
p.PrecoCentavos,
p.Estoque,
)
}

// Remove o produto cadastrado.
if err := produtoService.Remover(ctx, produto.ID); err != nil {
return err
}

fmt.Println("Produto removido!")

return nil
}

A função executar() começa criando o contexto e abrindo o banco por meio de database.AbrirSQLite(). Depois disso, criamos o repository passando o *sql.DB já configurado e entregamos esse repository ao construtor do service. Essa sequência é responsável por conectar as dependências necessárias para que a aplicação funcione.


Observe que nenhuma consulta SQL aparece no main.go. Para cadastrar um produto, chamamos produtoService.Cadastrar(), que valida as informações e solicita ao repository a inserção. O mesmo acontece nas operações de busca, atualização, listagem e remoção.


Também não precisamos importar o pacote model diretamente nesse exemplo, porque a função Cadastrar() já retorna um valor do tipo model.Produto. Podemos armazenar esse resultado em uma variável e acessar seus campos normalmente, sem declarar explicitamente seu tipo.


O exemplo executa todas as operações CRUD em sequência, permitindo observar como os métodos trabalham juntos. A cada execução, um novo produto é cadastrado, consultado, atualizado, listado e removido, mantendo o banco disponível para outras operações.


Executando o projeto​


Depois de criar os arquivos e aplicar a migration, podemos executar a aplicação a partir do diretório principal do módulo.

# Executa a aplicação.
go run .

O comando go run . compila e executa o pacote main, incluindo os pacotes internos importados pelo projeto. O Go resolve as dependências com base no arquivo go.mod, enquanto o programa abre o SQLite e executa as operações implementadas.


Considerando um banco recém-criado, o resultado será semelhante a este.

Produto cadastrado: 1
Produto encontrado: Teclado
Produto atualizado!
ID: 1 | Nome: Teclado mecânico | Preço: 18990 | Estoque: 8
Produto removido!

O identificador pode mudar em outras execuções, porque sua geração depende do estado do banco. No exemplo, o produto é removido ao final, mas o SQLite não garante que identificadores anteriores nunca serão reaproveitados quando usamos apenas INTEGER PRIMARY KEY.


Também podemos compilar a aplicação para gerar um executável.

# Compila o projeto.
go build -o minha-aplicacao .

O comando cria um executável contendo o código Go e as dependências necessárias para executar o programa. O banco SQLite continua armazenado em um arquivo separado, porque suas informações precisam permanecer disponíveis e modificáveis depois da compilação.


Como usamos o caminho relativo "dados.db", a aplicação procurará esse arquivo no diretório de trabalho do processo. Se executarmos o programa a partir de outro local, poderemos acabar acessando ou criando um banco diferente. Em aplicações distribuídas, normalmente precisamos definir esse caminho de maneira mais controlada.



Como as camadas trabalham juntas​


Agora podemos observar o caminho percorrido pelas informações durante uma operação de cadastro.

                 main.go
|
| Cadastrar()
v
ProdutoService
|
| Valida os dados
v
ProdutoRepository
|
| INSERT INTO produtos
v
database/sql
|
v
Driver SQLite
|
v
dados.db

A função main() solicita o cadastro ao service, passando o nome, o preço e a quantidade em estoque. O service cria o modelo do produto, verifica suas regras e encaminha a inserção ao repository. O repository executa o comando SQL por meio do database/sql, que trabalha com o driver SQLite para armazenar o registro.


Depois que a operação termina, o repository devolve o resultado ao service, que retorna o produto cadastrado para a aplicação. Esse fluxo permite que cada camada tenha uma responsabilidade específica, evitando que o código responsável pelo funcionamento do programa precise conhecer os detalhes da persistência.


O mesmo princípio se aplica às operações de consulta. A aplicação solicita um produto pelo identificador, o service verifica os argumentos e o repository executa o SELECT, convertendo os valores retornados pelo banco para uma struct Produto.


Tratando erros entre as camadas​


Como os métodos retornam valores do tipo error, os erros podem ser encaminhados do repository para o service e depois para a função que solicitou a operação.


Imagine que a aplicação tente buscar um produto que não existe. O método BuscarPorID() do repository receberá sql.ErrNoRows por meio de Scan(), preservando esse erro com %w. O service poderá devolver o mesmo erro, permitindo que a aplicação decida como tratar essa situação.

// Busca um produto que pode não existir.
produto, err := produtoService.BuscarPorID(ctx, 999)

// Verifica se o registro não foi encontrado.
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("Produto não encontrado")
return nil
}

// Trata outros erros.
if err != nil {
return err
}

fmt.Println("Produto:", produto.Nome)

Nesse exemplo, precisamos importar os pacotes errors e database/sql. A função errors.Is() permite identificar sql.ErrNoRows mesmo quando ele foi envolvido por outras mensagens de erro, mantendo a diferença entre um registro inexistente e uma falha na consulta.


O mesmo cuidado pode ser aplicado a outras operações. Se uma validação do service falhar, por exemplo, o erro será retornado antes de acessar o banco. Se a falha acontecer durante a execução SQL, o repository devolverá o erro correspondente.


Essa separação permite que a aplicação decida como apresentar os problemas sem depender diretamente das mensagens internas do banco.


Evitando dependências circulares​


Quando organizamos uma aplicação em vários pacotes, precisamos considerar como eles dependem uns dos outros. No Go, um pacote pode importar outro pacote, mas não podemos criar uma dependência circular, em que dois pacotes importam um ao outro direta ou indiretamente.


Na estrutura que criamos, o repository importa o pacote model, enquanto o service importa model e repository. Já o pacote database não precisa importar nenhum desses componentes, porque sua responsabilidade é apenas configurar o banco.

main
|-- database
|-- repository
| |
| +-- model
|
+-- service
|
|-- repository
|
+-- model

Esse fluxo funciona porque as dependências seguem uma direção definida. O repository não precisa importar o service, assim como o model não precisa conhecer os pacotes responsáveis pelas consultas ou pelas regras de negócio.


Se criássemos uma situação em que repository importasse service e, ao mesmo tempo, service importasse repository, o Go rejeitaria a compilação por causa do ciclo de imports.


Uma maneira de evitar esse problema é manter as responsabilidades bem definidas. O model representa os dados, o repository conhece as consultas e o service conhece as regras de negócio. Quando uma dependência circular aparece, normalmente precisamos avaliar se alguma responsabilidade foi colocada no pacote errado.


Quando faz sentido usar interfaces?​


No exemplo, o service recebe diretamente um *repository.ProdutoRepository. Essa abordagem é simples e funciona bem para aprender como as camadas se relacionam. Entretanto, em projetos maiores, podemos querer que o service dependa apenas de um conjunto de métodos, sem conhecer a implementação concreta do repository.


No Go, podemos representar esse conjunto de métodos usando uma interface. Ela permite definir quais operações um componente precisa oferecer, sem determinar exatamente como essas operações serão implementadas.

// Define as operações de dados que o service precisa.
type ProdutoRepository interface {
Inserir(context.Context, *model.Produto) error
BuscarPorID(context.Context, int64) (model.Produto, error)
Listar(context.Context) ([]model.Produto, error)
Atualizar(context.Context, model.Produto) error
Remover(context.Context, int64) error
}

Essa interface descreve os métodos necessários para trabalhar com produtos. Uma estrutura que implementa todos esses métodos com as assinaturas correspondentes atende à interface automaticamente, sem precisar declarar explicitamente que a implementa.


Nesse caso, poderíamos modificar o service para receber a interface em vez de uma estrutura concreta. Isso permitiria trabalhar com diferentes implementações, como um repository SQLite ou uma implementação criada apenas para testes.


Entretanto, não precisamos criar interfaces apenas porque dividimos o código em pacotes. Em projetos pequenos, depender diretamente de uma estrutura concreta costuma ser suficiente. As interfaces passam a fazer mais sentido quando existe uma necessidade real de substituir implementações ou separar dependências de maneira mais flexível.


Cuidados com a organização​


Separar o projeto em pacotes não significa criar um diretório para cada função ou struct. Se uma aplicação possui poucas operações, uma organização muito fragmentada pode dificultar a leitura, porque será necessário navegar por vários arquivos para entender um comportamento simples.


Também precisamos evitar que a camada service se transforme em um local que concentra todas as responsabilidades da aplicação. Sua função é trabalhar com as regras de negócio e coordenar as operações necessárias, enquanto consultas SQL, configuração de conexão e representação dos dados continuam em seus respectivos pacotes.


Outro cuidado está nas operações que precisam executar vários comandos SQL em conjunto. Se uma regra de negócio depender de múltiplas alterações que devem ser confirmadas ou desfeitas como uma unidade, precisamos trabalhar com transações. Nesse caso, a organização das camadas também deve permitir que as operações relacionadas participem da mesma transação.


A estrutura que criamos pode crescer conforme novas funcionalidades forem adicionadas. Se futuramente precisarmos trabalhar com usuários, pedidos ou categorias, poderemos adicionar novos modelos, repositories e services sem modificar a responsabilidade dos pacotes existentes.


O objetivo dessa separação é manter o código organizado de acordo com o que cada parte faz, permitindo que as regras da aplicação evoluam sem espalhar consultas SQL e configurações de banco por diferentes arquivos.