M02·08Projeto guiado

CAPÍTULO 08

Projeto guiado

API de Tarefas completa com CRUD, validação, PostgreSQL, logs estruturados e testes — tudo junto.

Por Thiago Souza22 min de leituraAtualizado em 2026-05

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.