Vamos juntar tudo. Construiremos uma API de Tarefas com:
- CRUD de tasks (criar, listar, buscar, atualizar, deletar).
- Validação.
- Persistência em Postgres.
- Logs estruturados.
- Testes.
Estrutura do projeto
todo-api/
├── cmd/api/main.go
├── internal/
│ ├── task/
│ │ ├── task.go # entidade + validação + erros
│ │ ├── service.go # caso de uso
│ │ └── repository.go # interface
│ ├── postgres/
│ │ └── task_repo.go # implementação
│ ├── http/
│ │ ├── handler.go
│ │ └── router.go
│ └── config/config.go
├── migrations/0001_create_tasks.sql
├── go.mod
└── go.sum
Domínio: internal/task/task.go
go
package task
import (
"errors"
"strings"
"time"
)
// Erros de domínio. Camadas de cima usam errors.Is para checar.
var (
ErrNotFound = errors.New("tarefa não encontrada")
ErrTitleRequired = errors.New("título é obrigatório")
ErrTitleTooLong = errors.New("título excede 200 caracteres")
)
// Status válidos para uma tarefa.
type Status string
const (
StatusPending Status = "pending"
StatusDone Status = "done"
)
// Entidade do domínio. Sem nada de banco aqui (sem tags `db:`, sem nada).
type Task struct {
ID int64
Title string
Description string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
// Input para criação. Separado da entidade por boas razões:
// 1) input vem de fora (não tem ID nem timestamps).
// 2) podemos validar input sem mexer na entidade.
type CreateInput struct {
Title string
Description string
}
// Validate centraliza regras de validação. Método sem dependências externas
// = fácil de testar.
func (i CreateInput) Validate() error {
title := strings.TrimSpace(i.Title)
if title == "" {
return ErrTitleRequired
}
if len(title) > 200 {
return ErrTitleTooLong
}
return nil
}Repositório (interface): internal/task/repository.go
go
package task
import "context"
// Repository é a porta de saída do nosso caso de uso.
// Quem implementa isso é detalhe (Postgres, Mongo, memória...).
type Repository interface {
Save(ctx context.Context, t Task) (Task, error)
FindByID(ctx context.Context, id int64) (Task, error)
List(ctx context.Context, limit, offset int) ([]Task, error)
Update(ctx context.Context, t Task) (Task, error)
Delete(ctx context.Context, id int64) error
}Serviço (caso de uso): internal/task/service.go
go
package task
import (
"context"
"log/slog"
"strings"
"time"
)
type Service struct {
repo Repository
logger *slog.Logger
}
func NewService(repo Repository, logger *slog.Logger) *Service {
return &Service{repo: repo, logger: logger}
}
// Create encapsula a regra de negócio de criação.
func (s *Service) Create(ctx context.Context, in CreateInput) (Task, error) {
if err := in.Validate(); err != nil {
// log no nível debug — erro de validação não é "incidente"
s.logger.DebugContext(ctx, "validação falhou na criação de task",
"err", err)
return Task{}, err
}
t := Task{
Title: strings.TrimSpace(in.Title),
Description: in.Description,
Status: StatusPending,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
saved, err := s.repo.Save(ctx, t)
if err != nil {
// erro de infra — log no nível error
s.logger.ErrorContext(ctx, "falha ao salvar task", "err", err)
return Task{}, err
}
s.logger.InfoContext(ctx, "task criada", "task_id", saved.ID)
return saved, nil
}
func (s *Service) Get(ctx context.Context, id int64) (Task, error) {
return s.repo.FindByID(ctx, id)
}
func (s *Service) List(ctx context.Context, limit, offset int) ([]Task, error) {
// saneamento de paginação. Limit absurdo = vetor de DoS.
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
return s.repo.List(ctx, limit, offset)
}
// MarkDone exemplifica regra de negócio "de verdade":
// não basta mexer no banco, há uma transição de estado.
func (s *Service) MarkDone(ctx context.Context, id int64) (Task, error) {
t, err := s.repo.FindByID(ctx, id)
if err != nil {
return Task{}, err
}
if t.Status == StatusDone {
return t, nil // idempotente: já está feito, não é erro
}
t.Status = StatusDone
t.UpdatedAt = time.Now().UTC()
updated, err := s.repo.Update(ctx, t)
if err != nil {
s.logger.ErrorContext(ctx, "falha ao atualizar task", "err", err)
return Task{}, err
}
s.logger.InfoContext(ctx, "task concluída", "task_id", updated.ID)
return updated, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
return s.repo.Delete(ctx, id)
}Postgres adapter: internal/postgres/task_repo.go
go
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"todo-api/internal/task"
)
type TaskRepo struct {
db *pgxpool.Pool
}
func NewTaskRepo(db *pgxpool.Pool) *TaskRepo {
return &TaskRepo{db: db}
}
func (r *TaskRepo) Save(ctx context.Context, t task.Task) (task.Task, error) {
query := `
INSERT INTO tasks (title, description, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
err := r.db.QueryRow(ctx, query,
t.Title, t.Description, t.Status, t.CreatedAt, t.UpdatedAt,
).Scan(&t.ID)
if err != nil {
return task.Task{}, fmt.Errorf("salvar task: %w", err)
}
return t, nil
}
func (r *TaskRepo) FindByID(ctx context.Context, id int64) (task.Task, error) {
var t task.Task
query := `SELECT id, title, description, status, created_at, updated_at
FROM tasks WHERE id = $1`
err := r.db.QueryRow(ctx, query, id).Scan(
&t.ID, &t.Title, &t.Description, &t.Status, &t.CreatedAt, &t.UpdatedAt,
)
// Traduzimos o "não encontrado" do pgx para o erro do nosso domínio.
// Isso é o que mantém a regra de "infra não vaza para cima".
if errors.Is(err, pgx.ErrNoRows) {
return task.Task{}, task.ErrNotFound
}
if err != nil {
return task.Task{}, fmt.Errorf("buscar task: %w", err)
}
return t, nil
}
func (r *TaskRepo) List(ctx context.Context, limit, offset int) ([]task.Task, error) {
query := `SELECT id, title, description, status, created_at, updated_at
FROM tasks ORDER BY created_at DESC LIMIT $1 OFFSET $2`
rows, err := r.db.Query(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("listar tasks: %w", err)
}
defer rows.Close()
var out []task.Task
for rows.Next() {
var t task.Task
if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Status,
&t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
func (r *TaskRepo) Update(ctx context.Context, t task.Task) (task.Task, error) {
query := `UPDATE tasks SET title=$1, description=$2, status=$3, updated_at=$4
WHERE id=$5`
cmd, err := r.db.Exec(ctx, query, t.Title, t.Description, t.Status,
t.UpdatedAt, t.ID)
if err != nil {
return task.Task{}, fmt.Errorf("atualizar task: %w", err)
}
if cmd.RowsAffected() == 0 {
return task.Task{}, task.ErrNotFound
}
return t, nil
}
func (r *TaskRepo) Delete(ctx context.Context, id int64) error {
cmd, err := r.db.Exec(ctx, `DELETE FROM tasks WHERE id=$1`, id)
if err != nil {
return fmt.Errorf("deletar task: %w", err)
}
if cmd.RowsAffected() == 0 {
return task.ErrNotFound
}
return nil
}HTTP handler: internal/http/handler.go
go
package http
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"todo-api/internal/task"
)
type TaskHandler struct {
svc *task.Service
}
func NewTaskHandler(svc *task.Service) *TaskHandler {
return &TaskHandler{svc: svc}
}
// DTOs separados das entidades. Isso isola o contrato externo do interno.
type createTaskRequest struct {
Title string `json:"title"`
Description string `json:"description"`
}
type taskResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toResponse(t task.Task) taskResponse {
return taskResponse{
ID: t.ID,
Title: t.Title,
Description: t.Description,
Status: string(t.Status),
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "json inválido")
return
}
t, err := h.svc.Create(r.Context(), task.CreateInput{
Title: req.Title,
Description: req.Description,
})
if err != nil {
// mapeamos erros de domínio para HTTP. Esse é o "tradutor".
switch {
case errors.Is(err, task.ErrTitleRequired),
errors.Is(err, task.ErrTitleTooLong):
writeError(w, http.StatusUnprocessableEntity, err.Error())
default:
writeError(w, http.StatusInternalServerError, "erro interno")
}
return
}
writeJSON(w, http.StatusCreated, toResponse(t))
}
func (h *TaskHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "id inválido")
return
}
t, err := h.svc.Get(r.Context(), id)
if errors.Is(err, task.ErrNotFound) {
writeError(w, http.StatusNotFound, "task não encontrada")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "erro interno")
return
}
writeJSON(w, http.StatusOK, toResponse(t))
}
// helpers
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}Router e main.go
go
// internal/http/router.go
package http
import "net/http"
func NewRouter(h *TaskHandler) http.Handler {
mux := http.NewServeMux() // Go 1.22+ tem path params nativos!
mux.HandleFunc("POST /tasks", h.Create)
mux.HandleFunc("GET /tasks/{id}", h.Get)
// adicionar List, Update, Delete segue o mesmo padrão
return mux
}go
// cmd/api/main.go
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
httpapi "todo-api/internal/http"
pg "todo-api/internal/postgres"
"todo-api/internal/task"
)
func main() {
// 1. Logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// 2. Banco
ctx := context.Background()
dsn := os.Getenv("DATABASE_URL")
db, err := pgxpool.New(ctx, dsn)
if err != nil {
logger.Error("falha ao conectar no banco", "err", err)
os.Exit(1)
}
defer db.Close()
// 3. Wire (montagem das peças). É aqui — e SÓ aqui — que o "concreto"
// encontra a "interface". Olha como tudo se encaixa:
repo := pg.NewTaskRepo(db) // implementa task.Repository
svc := task.NewService(repo, logger) // recebe a interface
handler := httpapi.NewTaskHandler(svc)
router := httpapi.NewRouter(handler)
// 4. Servidor com timeouts. Servidor sem timeout é convite a desastre.
srv := &http.Server{
Addr: ":8080",
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
logger.Info("servidor escutando", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
logger.Error("servidor parou", "err", err)
os.Exit(1)
}
}Migration: migrations/0001_create_tasks.sql
sql
CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);Teste do service
go
// internal/task/service_test.go
package task_test
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"todo-api/internal/task"
)
type fakeRepo struct {
saved task.Task
err error
}
func (f *fakeRepo) Save(_ context.Context, t task.Task) (task.Task, error) {
if f.err != nil {
return task.Task{}, f.err
}
t.ID = 1
f.saved = t
return t, nil
}
func (f *fakeRepo) FindByID(context.Context, int64) (task.Task, error) {
return task.Task{}, nil
}
func (f *fakeRepo) List(context.Context, int, int) ([]task.Task, error) {
return nil, nil
}
func (f *fakeRepo) Update(_ context.Context, t task.Task) (task.Task, error) {
return t, nil
}
func (f *fakeRepo) Delete(context.Context, int64) error { return nil }
func TestService_Create_Success(t *testing.T) {
repo := &fakeRepo{}
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) // logger silencioso no teste
svc := task.NewService(repo, logger)
out, err := svc.Create(context.Background(), task.CreateInput{
Title: "comprar pão",
})
if err != nil {
t.Fatalf("erro inesperado: %v", err)
}
if out.ID != 1 || out.Status != task.StatusPending {
t.Fatalf("resultado inesperado: %+v", out)
}
}
func TestService_Create_EmptyTitle(t *testing.T) {
svc := task.NewService(&fakeRepo{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
_, err := svc.Create(context.Background(), task.CreateInput{Title: " "})
if !errors.Is(err, task.ErrTitleRequired) {
t.Fatalf("esperava ErrTitleRequired, veio: %v", err)
}
}Pronto. Tem:
- CRUD (Create + Get; Update/Delete seguem o mesmo padrão).
- Validação (no
Validate()do input, antes de tocar no banco). - Persistência (Postgres com
pgx). - Logs estruturados (slog em JSON).
- Testes (com fake repo, sem precisar subir banco).
E o mais importante: trocar o Postgres por outro banco mexe em UM pacote só. O service não fica sabendo.