Os artigos anteriores (Domain-Driven Design aplicado a projetos Go, Injeção de dependência sem frameworks, Design Patterns em Go: Repository, Factory e Strategy) construíram um repertório sólido: HTTP, banco de dados, autenticação, middlewares, arquitetura limpa e padrões de design. Este artigo une tudo isso em um projeto coeso — uma API RESTful de gerenciamento de tarefas, construída do zero e levada até o deploy.
O projeto seguirá uma estrutura profissional, com autenticação JWT, banco de dados PostgreSQL, testes automatizados, containerização com Docker e deploy em uma plataforma de nuvem.
Estrutura do projeto
taskapi/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── domain/
│ │ └── tarefa.go
│ ├── repository/
│ │ └── tarefa.go
│ ├── usecase/
│ │ └── tarefa/
│ │ ├── criar.go
│ │ ├── listar.go
│ │ ├── atualizar.go
│ │ └── deletar.go
│ ├── handler/
│ │ ├── tarefa.go
│ │ └── auth.go
│ ├── middleware/
│ │ ├── auth.go
│ │ └── cors.go
│ └── infra/
│ └── postgres/
│ └── tarefa_repo.go
├── migrations/
│ ├── 000001_criar_usuarios.up.sql
│ ├── 000001_criar_usuarios.down.sql
│ ├── 000002_criar_tarefas.up.sql
│ └── 000002_criar_tarefas.down.sql
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── go.mod
└── go.sum
O artigo mostra o domínio, as migrações, o repositório, o handler, a composição no main e o deploy. Quatro peças que aparecem na árvore e nos imports não são repetidas aqui, porque seguem padrões já mostrados: os casos de uso de internal/usecase/tarefa, no formato de Clean Architecture em Go: camadas e dependências; o middleware JWT, com JWT(segredo) e UsuarioIDDoContext, e o AuthHandler, como em Middlewares: logging, autenticação e CORS; e o repositório de usuários, no mesmo molde do de tarefas. Sem eles, o projeto não compila: o go build ./... aponta cada pacote que falta.
Domínio e entidades
// internal/domain/tarefa.go
package domain
import (
"errors"
"time"
)
var (
ErrTituloDeTarefaVazio = errors.New("título da tarefa não pode ser vazio")
ErrTarefaNaoEncontrada = errors.New("tarefa não encontrada")
ErrTarefaSemPermissao = errors.New("sem permissão para esta tarefa")
)
type PrioridadeTarefa int
const (
PrioridadeBaixa PrioridadeTarefa = 1
PrioridadeMedia PrioridadeTarefa = 2
PrioridadeAlta PrioridadeTarefa = 3
PrioridadeUrgente PrioridadeTarefa = 4
)
type Tarefa struct {
ID int64
UsuarioID int64
Titulo string
Descricao string
Concluida bool
Prioridade PrioridadeTarefa
Prazo *time.Time
CriadoEm time.Time
AtualizadoEm time.Time
}
func NovaTarefa(usuarioID int64, titulo, descricao string, prioridade PrioridadeTarefa, prazo *time.Time) (*Tarefa, error) {
if titulo == "" {
return nil, ErrTituloDeTarefaVazio
}
if prioridade < PrioridadeBaixa || prioridade > PrioridadeUrgente {
prioridade = PrioridadeMedia
}
agora := time.Now().UTC()
return &Tarefa{
UsuarioID: usuarioID,
Titulo: titulo,
Descricao: descricao,
Prioridade: prioridade,
Prazo: prazo,
CriadoEm: agora,
AtualizadoEm: agora,
}, nil
}
func (t *Tarefa) Concluir() {
t.Concluida = true
t.AtualizadoEm = time.Now().UTC()
}
func (t *Tarefa) PertenceAo(usuarioID int64) bool {
return t.UsuarioID == usuarioID
}
Migrações SQL
-- migrations/000001_criar_usuarios.up.sql
CREATE TABLE usuarios (
id BIGSERIAL PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
senha_hash TEXT NOT NULL,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- migrations/000002_criar_tarefas.up.sql
CREATE TABLE tarefas (
id BIGSERIAL PRIMARY KEY,
usuario_id BIGINT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, -- tarefa não existe sem o dono
titulo TEXT NOT NULL,
descricao TEXT NOT NULL DEFAULT '',
concluida BOOLEAN NOT NULL DEFAULT FALSE,
prioridade INT NOT NULL DEFAULT 2
CHECK(prioridade BETWEEN 1 AND 4),
prazo TIMESTAMPTZ,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tarefas_usuario_id ON tarefas(usuario_id);
CREATE INDEX idx_tarefas_concluida ON tarefas(usuario_id, concluida);
CREATE INDEX idx_tarefas_prazo ON tarefas(prazo) WHERE prazo IS NOT NULL;
Contrato do repositório
A interface fica em internal/repository, e o filtro usa ponteiros para distinguir "sem filtro" de um valor:
// internal/repository/tarefa.go
package repository
import (
"context"
"taskapi/internal/domain"
)
// FiltroTarefa: os ponteiros nil significam "sem filtro".
type FiltroTarefa struct {
Concluida *bool
Prioridade *int
Pagina int // começa em 1
Limite int
}
type TarefaRepository interface {
Criar(ctx context.Context, t *domain.Tarefa) error
BuscarPorID(ctx context.Context, id, usuarioID int64) (*domain.Tarefa, error)
Listar(ctx context.Context, usuarioID int64, f FiltroTarefa) ([]*domain.Tarefa, int64, error)
Atualizar(ctx context.Context, t *domain.Tarefa) error
Deletar(ctx context.Context, id, usuarioID int64) error
}
Repositório
// internal/infra/postgres/tarefa_repo.go
package postgres
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"taskapi/internal/domain"
"taskapi/internal/repository"
)
type tarefaRepo struct{ db *pgxpool.Pool }
func NovoTarefaRepo(db *pgxpool.Pool) repository.TarefaRepository {
return &tarefaRepo{db: db}
}
func (r *tarefaRepo) Criar(ctx context.Context, t *domain.Tarefa) error {
query := `
INSERT INTO tarefas (usuario_id, titulo, descricao, prioridade, prazo)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, criado_em, atualizado_em
`
return r.db.QueryRow(ctx, query,
t.UsuarioID, t.Titulo, t.Descricao, t.Prioridade, t.Prazo,
).Scan(&t.ID, &t.CriadoEm, &t.AtualizadoEm)
}
func (r *tarefaRepo) BuscarPorID(ctx context.Context, id, usuarioID int64) (*domain.Tarefa, error) {
query := `
SELECT id, usuario_id, titulo, descricao, concluida,
prioridade, prazo, criado_em, atualizado_em
FROM tarefas
WHERE id = $1 AND usuario_id = $2
`
var t domain.Tarefa
err := r.db.QueryRow(ctx, query, id, usuarioID).Scan(
&t.ID, &t.UsuarioID, &t.Titulo, &t.Descricao, &t.Concluida,
&t.Prioridade, &t.Prazo, &t.CriadoEm, &t.AtualizadoEm,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrTarefaNaoEncontrada
}
return &t, err
}
func (r *tarefaRepo) Listar(ctx context.Context, usuarioID int64, f repository.FiltroTarefa) ([]*domain.Tarefa, int64, error) {
// O handler já valida, mas o repositório não deve depender disso:
// página 0 viraria OFFSET negativo, recusado pelo PostgreSQL
if f.Pagina < 1 {
f.Pagina = 1
}
if f.Limite < 1 {
f.Limite = 20
}
query := `
SELECT id, usuario_id, titulo, descricao, concluida,
prioridade, prazo, criado_em, atualizado_em,
COUNT(*) OVER() AS total
FROM tarefas
WHERE usuario_id = $1
AND ($2::boolean IS NULL OR concluida = $2)
AND ($3::int IS NULL OR prioridade = $3)
ORDER BY prioridade DESC, criado_em DESC
LIMIT $4 OFFSET $5
`
rows, err := r.db.Query(ctx, query,
usuarioID, f.Concluida, f.Prioridade,
f.Limite, (f.Pagina-1)*f.Limite,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var tarefas []*domain.Tarefa
var total int64
for rows.Next() {
var t domain.Tarefa
if err := rows.Scan(
&t.ID, &t.UsuarioID, &t.Titulo, &t.Descricao, &t.Concluida,
&t.Prioridade, &t.Prazo, &t.CriadoEm, &t.AtualizadoEm, &total,
); err != nil {
return nil, 0, err
}
tarefas = append(tarefas, &t)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
// Depois da última página não há linhas, e o COUNT(*) OVER() não tem
// onde aparecer: o total viria 0. Nesse caso, ele precisa de consulta própria
if len(tarefas) == 0 && f.Pagina > 1 {
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM tarefas
WHERE usuario_id = $1
AND ($2::boolean IS NULL OR concluida = $2)
AND ($3::int IS NULL OR prioridade = $3)
`, usuarioID, f.Concluida, f.Prioridade).Scan(&total)
if err != nil {
return nil, 0, err
}
}
return tarefas, total, nil
}
func (r *tarefaRepo) Atualizar(ctx context.Context, t *domain.Tarefa) error {
query := `
UPDATE tarefas
SET titulo = $1, descricao = $2, concluida = $3,
prioridade = $4, prazo = $5, atualizado_em = NOW()
WHERE id = $6 AND usuario_id = $7
RETURNING atualizado_em
`
err := r.db.QueryRow(ctx, query,
t.Titulo, t.Descricao, t.Concluida,
t.Prioridade, t.Prazo, t.ID, t.UsuarioID,
).Scan(&t.AtualizadoEm)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrTarefaNaoEncontrada
}
return err
}
func (r *tarefaRepo) Deletar(ctx context.Context, id, usuarioID int64) error {
result, err := r.db.Exec(ctx,
"DELETE FROM tarefas WHERE id = $1 AND usuario_id = $2", id, usuarioID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return domain.ErrTarefaNaoEncontrada
}
return nil
}
Handler HTTP completo
// internal/handler/tarefa.go
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"taskapi/internal/domain"
"taskapi/internal/middleware"
"taskapi/internal/repository"
"taskapi/internal/usecase/tarefa"
)
type TarefaHandler struct {
criar *tarefa.CriarTarefa
listar *tarefa.ListarTarefas
atualizar *tarefa.AtualizarTarefa
deletar *tarefa.DeletarTarefa
}
func NovoTarefaHandler(
criar *tarefa.CriarTarefa,
listar *tarefa.ListarTarefas,
atualizar *tarefa.AtualizarTarefa,
deletar *tarefa.DeletarTarefa,
) *TarefaHandler {
return &TarefaHandler{
criar: criar, listar: listar,
atualizar: atualizar, deletar: deletar,
}
}
type criarTarefaReq struct {
Titulo string `json:"titulo"`
Descricao string `json:"descricao"`
Prioridade int `json:"prioridade"`
Prazo *time.Time `json:"prazo,omitempty"`
}
type atualizarTarefaReq struct {
Titulo string `json:"titulo"`
Descricao string `json:"descricao"`
Concluida bool `json:"concluida"`
Prioridade int `json:"prioridade"`
Prazo *time.Time `json:"prazo,omitempty"`
}
func (h *TarefaHandler) Criar(w http.ResponseWriter, r *http.Request) {
usuarioID := middleware.UsuarioIDDoContext(r.Context())
var req criarTarefaReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
responderErro(w, http.StatusBadRequest, "corpo inválido")
return
}
t, err := h.criar.Executar(r.Context(), tarefa.CriarInput{
UsuarioID: usuarioID,
Titulo: req.Titulo,
Descricao: req.Descricao,
Prioridade: domain.PrioridadeTarefa(req.Prioridade),
Prazo: req.Prazo,
})
if err != nil {
traduzirErro(w, err)
return
}
responderJSON(w, http.StatusCreated, t)
}
func (h *TarefaHandler) Listar(w http.ResponseWriter, r *http.Request) {
usuarioID := middleware.UsuarioIDDoContext(r.Context())
q := r.URL.Query()
pagina, _ := strconv.Atoi(q.Get("pagina"))
if pagina < 1 {
pagina = 1
}
limite, _ := strconv.Atoi(q.Get("limite"))
if limite < 1 || limite > 100 {
limite = 20
}
var concluida *bool
if v := q.Get("concluida"); v != "" {
b := v == "true"
concluida = &b
}
resultado, err := h.listar.Executar(r.Context(), tarefa.ListarInput{
UsuarioID: usuarioID,
Filtro: repository.FiltroTarefa{
Concluida: concluida,
Pagina: pagina,
Limite: limite,
},
})
if err != nil {
responderErro(w, http.StatusInternalServerError, "erro ao listar tarefas")
return
}
w.Header().Set("X-Total-Count", strconv.FormatInt(resultado.Total, 10))
responderJSON(w, http.StatusOK, resultado.Tarefas)
}
func (h *TarefaHandler) Atualizar(w http.ResponseWriter, r *http.Request) {
usuarioID := middleware.UsuarioIDDoContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
responderErro(w, http.StatusBadRequest, "id inválido")
return
}
var req atualizarTarefaReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
responderErro(w, http.StatusBadRequest, "corpo inválido")
return
}
t, err := h.atualizar.Executar(r.Context(), tarefa.AtualizarInput{
ID: id,
UsuarioID: usuarioID,
Titulo: req.Titulo,
Descricao: req.Descricao,
Concluida: req.Concluida,
Prioridade: domain.PrioridadeTarefa(req.Prioridade),
Prazo: req.Prazo,
})
if err != nil {
traduzirErro(w, err)
return
}
responderJSON(w, http.StatusOK, t)
}
func (h *TarefaHandler) Deletar(w http.ResponseWriter, r *http.Request) {
usuarioID := middleware.UsuarioIDDoContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
responderErro(w, http.StatusBadRequest, "id inválido")
return
}
if err := h.deletar.Executar(r.Context(), id, usuarioID); err != nil {
traduzirErro(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func traduzirErro(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrTarefaNaoEncontrada):
responderErro(w, http.StatusNotFound, err.Error())
case errors.Is(err, domain.ErrTituloDeTarefaVazio):
responderErro(w, http.StatusUnprocessableEntity, err.Error())
case errors.Is(err, domain.ErrTarefaSemPermissao):
responderErro(w, http.StatusForbidden, err.Error())
default:
responderErro(w, http.StatusInternalServerError, "erro interno")
}
}
func responderJSON(w http.ResponseWriter, status int, dados any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(dados)
}
func responderErro(w http.ResponseWriter, status int, msg string) {
responderJSON(w, status, map[string]string{"erro": msg})
}
main.go: composição completa
// cmd/api/main.go
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
_ "github.com/golang-migrate/migrate/v4/source/file"
"taskapi/internal/handler"
"taskapi/internal/infra/postgres"
"taskapi/internal/middleware"
"taskapi/internal/usecase/tarefa"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
logger.Error("DATABASE_URL não definida")
os.Exit(1)
}
jwtSegredo := os.Getenv("JWT_SECRET")
if jwtSegredo == "" {
logger.Error("JWT_SECRET não definida")
os.Exit(1)
}
// Banco de dados
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
logger.Error("falha ao conectar ao banco", "err", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
logger.Error("banco inacessível", "err", err)
os.Exit(1)
}
// Repositórios
tarefaRepo := postgres.NovoTarefaRepo(pool)
usuarioRepo := postgres.NovoUsuarioRepo(pool)
// Casos de uso
criarTarefa := tarefa.NovoCriarTarefa(tarefaRepo)
listarTarefas := tarefa.NovoListarTarefas(tarefaRepo)
atualizarTarefa := tarefa.NovoAtualizarTarefa(tarefaRepo)
deletarTarefa := tarefa.NovoDeletarTarefa(tarefaRepo)
// Handlers
tarefaHandler := handler.NovoTarefaHandler(criarTarefa, listarTarefas, atualizarTarefa, deletarTarefa)
authHandler := handler.NovoAuthHandler(usuarioRepo, jwtSegredo)
// Roteamento
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(30 * time.Second))
r.Get("/saude", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"status":"ok"}`)
})
r.Route("/api/v1", func(r chi.Router) {
r.Post("/auth/registro", authHandler.Registrar)
r.Post("/auth/login", authHandler.Login)
r.Group(func(r chi.Router) {
r.Use(middleware.JWT(jwtSegredo))
r.Route("/tarefas", func(r chi.Router) {
r.Get("/", tarefaHandler.Listar)
r.Post("/", tarefaHandler.Criar)
r.Put("/{id}", tarefaHandler.Atualizar)
r.Delete("/{id}", tarefaHandler.Deletar)
})
})
})
porta := os.Getenv("PORT")
if porta == "" {
porta = "8080"
}
srv := &http.Server{
Addr: ":" + porta,
Handler: r,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
logger.Info("API iniciada", "porta", porta)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("servidor encerrado com erro", "err", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("encerrando servidor...")
shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
logger.Error("erro no shutdown", "err", err)
}
logger.Info("servidor encerrado")
}
Dockerfile multistage
# Dockerfile
# A versão do Go da imagem precisa ser pelo menos a da diretiva go do go.mod
FROM golang:1.27-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s" \
-o taskapi ./cmd/api
FROM alpine:3.24
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/taskapi .
COPY --from=builder /app/migrations ./migrations
EXPOSE 8080
USER nobody:nobody
ENTRYPOINT ["./taskapi"]
docker-compose.yml para desenvolvimento
# docker-compose.yml
services:
api:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://usuario:senha@db:5432/taskapi?sslmode=disable
JWT_SECRET: dev-secret-troque-em-producao
depends_on:
db:
condition: service_healthy
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: usuario
POSTGRES_PASSWORD: senha
POSTGRES_DB: taskapi
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U usuario -d taskapi"]
interval: 5s
timeout: 5s
retries: 5
migrate:
image: migrate/migrate
volumes:
- ./migrations:/migrations
command: -path=/migrations -database=postgres://usuario:senha@db:5432/taskapi?sslmode=disable up
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
Makefile
# As linhas de comando começam com TAB; com espaços, o make para com "missing separator"
.PHONY: dev build test lint migrate-up migrate-down docker-up docker-down
dev:
air -c .air.toml # hot reload com air
build:
CGO_ENABLED=0 go build -o bin/taskapi ./cmd/api
test:
go test -race -count=1 -cover ./...
test-integration:
go test -race -count=1 -tags=integration ./...
lint:
golangci-lint run ./...
migrate-up:
migrate -path migrations -database "${DATABASE_URL}" up
migrate-down:
migrate -path migrations -database "${DATABASE_URL}" down 1
docker-up:
docker compose up -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f api
Deploy no Fly.io
# Instalar flyctl
curl -L https://fly.io/install.sh | sh
# Login e criação da app
fly auth login
fly launch --name taskapi --region gru
# Banco PostgreSQL gerenciado
fly postgres create --name taskapi-db --region gru
fly postgres attach taskapi-db
# Segredos
fly secrets set JWT_SECRET="$(openssl rand -hex 32)"
# Deploy
fly deploy
# Logs em tempo real
fly logs
# fly.toml — gerado pelo fly launch, ajuste conforme necessário
app = "taskapi"
primary_region = "gru"
[build]
dockerfile = "Dockerfile"
[env]
PORT = "8080"
[http_service]
internal_port = 8080
force_https = true
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
method = "GET"
path = "/saude"
timeout = "5s"
[[vm]]
memory = "256mb"
cpu_kind = "shared"
cpus = 1
Uma API completa junta as peças dos artigos anteriores: domínio com regras, migrações versionadas, repositório com consultas que filtram pelo dono, handler que traduz erros em códigos HTTP, composição no main, imagem Docker em dois estágios e deploy. O que separa um projeto de exemplo de um projeto que roda está nos detalhes medidos aqui: imports que sobram e impedem a compilação, um total de paginação que zera depois da última página, uma imagem de build mais velha que o go.mod, um Makefile indentado com espaços. Um go build ./..., um docker build e uma chamada real a cada rota, rodados antes do primeiro deploy, encontram todos eles.
Fontes e leituras recomendadas
-
Fly.io — Go deployment — Guia oficial de deploy de aplicações Go. https://fly.io/docs/languages-and-frameworks/golang/
-
golang-migrate — Ferramenta de migrações de banco de dados. https://github.com/golang-migrate/migrate
-
Chi router — Documentação e exemplos do Chi. https://github.com/go-chi/chi
-
pgx v5 — Driver PostgreSQL para Go. https://github.com/jackc/pgx
-
Air — Live reload — Hot reload para desenvolvimento Go. https://github.com/air-verse/air
-
Docker multistage builds — Guia oficial de builds multistage. https://docs.docker.com/build/building/multi-stage/
Exercícios
Exercício 1
Um usuário com 3 tarefas pede GET /tarefas?pagina=3&limite=2, uma página além da última. A primeira versão do repositório respondia lista vazia e total: 0, e o front-end escondia a paginação, como se o usuário não tivesse tarefas. Por que o total vinha zerado?
Ver resposta
✓ Resposta: O total vinha de COUNT(*) OVER(), uma função de janela que acrescenta a contagem a cada linha do resultado. É uma forma eficiente de obter a página e o total numa consulta só, mas o valor só existe se houver ao menos uma linha: com o OFFSET além do fim, nenhuma linha volta, e o total fica com o valor inicial, zero. Num teste contra o PostgreSQL, a página 1 com limite 2 trouxe 2 itens e total 3, e a página 3 trouxe 0 itens e total 0. A versão do artigo detecta o caso, sem linhas e com página maior que 1, e faz uma contagem separada com os mesmos filtros; no mesmo teste, a página 3 passou a informar total 3. A alternativa é sempre contar numa consulta própria, mais simples de ler e com um custo a mais por requisição.
Exercício 2
O handler já garante pagina >= 1 antes de chamar o caso de uso. Ainda assim, a versão do artigo repete a verificação dentro do repositório. Isso é redundância desnecessária?
Ver resposta
✓ Resposta: O repositório é usado por quem estiver do outro lado da interface, e o handler HTTP é só um deles: um job de exportação, uma CLI ou um teste podem chamá-lo direto. Com Pagina: 0, o cálculo (f.Pagina-1)*f.Limite dá um deslocamento negativo, e num teste o PostgreSQL recusou a consulta com OFFSET must not be negative (SQLSTATE 2201X). O erro não vaza dados nem corrompe nada, mas transforma uma entrada trivial num 500. A verificação no repositório custa duas linhas e faz a função ser correta por si só, sem depender de quem a chama, o que é especialmente importante numa camada pensada para ser reutilizada. Depois da correção, a mesma chamada devolveu a primeira página normalmente.
Exercício 3
O repositório da primeira versão deste artigo importava "fmt" e "time" sem usá-los. Num editor com goimports, ninguém notaria; copiado para um editor simples, o projeto não compila. Qual é a mensagem, e por que o Go é tão rígido nisso?
Ver resposta
✓ Resposta: O compilador recusa o pacote com "fmt" imported and not used e, depois de removido o primeiro, com "time" imported and not used: num teste, os dois apareceram em sequência, porque o compilador para no primeiro erro de cada arquivo. A regra é deliberada. Um import sem uso pode parecer inofensivo, mas executa o init do pacote, aumenta o binário e o tempo de compilação, e esconde que o código mudou e o import ficou para trás. Ferramentas como o goimports e o gopls removem esses imports ao salvar, e por isso eles quase nunca chegam ao repositório. Em código copiado de um texto, como este, chegam: compilar todo exemplo antes de publicar evita o problema na origem.
Exercício 4
O go.mod do projeto, criado com uma versão recente do Go, declara go 1.27, e o Dockerfile da primeira versão usava FROM golang:1.22-alpine. O docker build falha logo no go mod download. Por quê?
Ver resposta
✓ Resposta: Desde o Go 1.21, a diretiva go do go.mod é um requisito mínimo: um toolchain mais antigo não compila o módulo. Fora do Docker, o Go baixaria sozinho a versão necessária, mas as imagens oficiais definem GOTOOLCHAIN=local, que proíbe esse download. Medido com o Go 1.27, um módulo que exige uma versão maior que a instalada, com GOTOOLCHAIN=local, falha com go.mod requires go >= 1.99 (running go 1.27.1; GOTOOLCHAIN=local); com a imagem de 1.22 e um módulo de 1.27, a mensagem é a mesma, com os números trocados. A correção é usar uma imagem com a mesma versão da diretiva, como golang:1.27-alpine, e manter as duas juntas: o CI pode conferir que a versão do FROM corresponde à do go.mod. A imagem final, alpine, também envelhece; a 3.19 da primeira versão já tinha saído do suporte.
Exercício 5
Quando o usuário 2 pede a tarefa 1, que pertence ao usuário 1, a API responde 404, e não 403. Num teste, BuscarPorID(1, 2) e Deletar(1, 2) devolveram tarefa não encontrada. Por que essa escolha, e onde ela é garantida?
Ver resposta
✓ Resposta: Responder 403 confirmaria que a tarefa 1 existe e pertence a outra pessoa, e um atacante poderia percorrer os IDs para mapear quantas tarefas existem e quais estão ativas. Com 404, a resposta é a mesma para "não existe" e "não é sua", e nada vaza. A garantia está na consulta, e não numa verificação depois: WHERE id = $1 AND usuario_id = $2 faz o banco nem devolver a linha de outro usuário, e o DELETE com a mesma condição não afeta nada, o que o RowsAffected() == 0 traduz em ErrTarefaNaoEncontrada. Buscar só pelo ID e conferir o dono no código também funcionaria, mas basta esquecer a conferência em uma das rotas para abrir um acesso indevido, a falha conhecida como IDOR. Com o filtro na consulta, esquecer é mais difícil, porque o repositório nem oferece um método sem o usuário.