Projeto Completo (Parte 1)

Projeto Completo (Parte 1)

Construindo um projeto Go completo do zero — sistema de API Blog.

Nesta lição construiremos um Sistema de Gerenciamento de Posts do Blog (Blog API), cobrindo análise de requisitos, design de diretório, modelos de dados, lógica de negócio, tratamento de erros e testes unitários. A próxima lição completará as partes de roteamento HTTP e middleware.


Requisitos do Projeto

Lista de Funcionalidades

Módulo Funcionalidades Descrição
Artigos Criar, consultar, listar, atualizar, excluir Cobertura CRUD completa
Categorias Criar, consultar, listar Artigos pertencem a categorias
Tags Criar, consultar, listar Artigos podem ser associados a múltiplas tags
Usuários Registrar, consultar Versão simplificada, sem autenticação de login

Requisitos Não-Funcionais


Design da Arquitetura do Sistema

Adota a clássica arquitetura de três camadas:

TEXT
┌─────────────────────────────────────────────┐
│              Handler (Camada HTTP)            │
│   Receber requisição → Analisar params → Chamar Service → Retornar │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│             Service (Camada de Negócio)         │
│   Lógica de negócio → Validar params → Chamar Repository     │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│           Repository (Camada de Dados)            │
│   Acesso a dados → Armazenamento em memória / Banco de dados │
└─────────────────────────────────────────────┘

Cada camada depende apenas da camada abaixo, desacoplada através de interfaces para facilitar testes e substituição de implementação.


Estrutura de Diretórios do Projeto

TEXT
blog-api/
├── cmd/
│   └── server/
│       └── main.go            # Ponto de entrada do programa
├── internal/
│   ├── handler/               # Handlers HTTP (implementado na Lição 30)
│   │   ├── article.go
│   │   ├── category.go
│   │   └── response.go
│   ├── service/               # Camada de lógica de negócio
│   │   ├── article.go
│   │   ├── category.go
│   │   └── service.go
│   ├── repository/            # Camada de acesso a dados
│   │   ├── article.go
│   │   ├── category.go
│   │   └── store.go
│   └── model/                 # Modelos de dados
│       ├── article.go
│       ├── category.go
│       └── user.go
├── pkg/
│   └── errcode/               # Códigos de erro unificados
│       └── errcode.go
├── go.mod
└── README.md

Responsabilidades dos diretórios:

Diretório Responsabilidade Visibilidade
cmd/ Ponto de entrada do programa, cada subdiretório corresponde a um executável Público
internal/ Código principal do projeto, pacotes externos não podem importar Privado
pkg/ Pacotes utilitários reutilizáveis por projetos externos Público

Passo 1: Inicializar o Projeto

BASH
mkdir -p blog-api && cd blog-api
go mod init blog-api

Criar estrutura de diretórios:

BASH
mkdir -p cmd/server
mkdir -p internal/model internal/repository internal/service internal/handler
mkdir -p pkg/errcode

Passo 2: Definir Modelos de Dados

Modelo de Usuário

GO
// internal/model/user.go
package model

import "time"

// User modelo de usuário
type User struct {
	ID        int64     `json:"id"`
	Username  string    `json:"username"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
}

Modelo de Categoria

GO
// internal/model/category.go
package model

import "time"

// Category categoria de artigo
type Category struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

Modelo de Artigo

GO
// internal/model/article.go
package model

import "time"

// Article modelo de artigo
type Article struct {
	ID         int64     `json:"id"`
	Title      string    `json:"title"`
	Content    string    `json:"content"`
	AuthorID   int64     `json:"author_id"`
	CategoryID int64     `json:"category_id"`
	Tags       []string  `json:"tags"`
	Status     int8      `json:"status"` // 0=rascunho 1=publicado
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

// CreateArticleRequest requisição para criar um artigo
type CreateArticleRequest struct {
	Title      string   `json:"title"`
	Content    string   `json:"content"`
	AuthorID   int64    `json:"author_id"`
	CategoryID int64    `json:"category_id"`
	Tags       []string `json:"tags"`
}

// UpdateArticleRequest requisição para atualizar um artigo
type UpdateArticleRequest struct {
	Title      *string  `json:"title,omitempty"`
	Content    *string  `json:"content,omitempty"`
	CategoryID *int64   `json:"category_id,omitempty"`
	Tags       []string `json:"tags,omitempty"`
	Status     *int8    `json:"status,omitempty"`
}

// ListRequest requisição de consulta paginada
type ListRequest struct {
	Page     int `json:"page"`
	PageSize int `json:"page_size"`
}

// ListResponse resposta de consulta paginada
type ListResponse struct {
	Items      interface{} `json:"items"`
	Total      int64       `json:"total"`
	Page       int         `json:"page"`
	PageSize   int         `json:"page_size"`
	TotalPages int         `json:"total_pages"`
}

Análise do código:


Passo 3: Códigos de Erro Unificados

GO
// pkg/errcode/errcode.go
package errcode

import "fmt"

// AppError erro da aplicação
type AppError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// Error implementa a interface error
func (e *AppError) Error() string {
	return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}

// New cria um erro da aplicação
func New(code int, message string) *AppError {
	return &AppError{Code: code, Message: message}
}

// Códigos de erro predefinidos
var (
	ErrNotFound        = New(10001, "Recurso não encontrado")
	ErrInvalidParam    = New(10002, "Parâmetros inválidos")
	ErrDuplicate       = New(10003, "Recurso já existe")
	ErrInternal        = New(10004, "Erro interno")
	ErrArticleNotFound = New(20001, "Artigo não encontrado")
	ErrCategoryNotFound = New(20002, "Categoria não encontrada")
	ErrUserNotFound    = New(20003, "Usuário não encontrado")
	ErrTitleRequired   = New(20004, "Título não pode ser vazio")
	ErrContentRequired = New(20005, "Conteúdo não pode ser vazio")
)

Análise do código:


Passo 4: Camada de Acesso a Dados (Repository)

Interface de Armazenamento

GO
// internal/repository/store.go
package repository

import "blog-api/internal/model"

// Store interface de armazenamento unificado (programação orientada a interfaces)
type Store interface {
	ArticleRepo() ArticleRepository
	CategoryRepo() CategoryRepository
	UserRepo() UserRepository
}

// ArticleRepository interface de acesso a dados de artigos
type ArticleRepository interface {
	Create(article *model.Article) error
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) ([]*model.Article, int64, error)
	Update(article *model.Article) error
	Delete(id int64) error
}

// CategoryRepository interface de acesso a dados de categorias
type CategoryRepository interface {
	Create(category *model.Category) error
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

// UserRepository interface de acesso a dados de usuários
type UserRepository interface {
	Create(user *model.User) error
	GetByID(id int64) (*model.User, error)
}

Implementação em Memória

GO
// internal/repository/memory_store.go
package repository

import (
	"fmt"
	"sync"
	"sync/atomic"

	"blog-api/internal/model"
)

// MemoryStore implementação de armazenamento em memória
type MemoryStore struct {
	articles   map[int64]*model.Article
	categories map[int64]*model.Category
	users      map[int64]*model.User
	mu         sync.RWMutex
	nextID     atomic.Int64
}

// NewMemoryStore cria armazenamento em memória
func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		articles:   make(map[int64]*model.Article),
		categories: make(map[int64]*model.Category),
		users:      make(map[int64]*model.User),
	}
}

func (s *MemoryStore) next() int64 {
	return s.nextID.Add(1)
}

// ArticleRepo retorna o repositório de artigos
func (s *MemoryStore) ArticleRepo() ArticleRepository {
	return &memoryArticleRepo{store: s}
}

// CategoryRepo retorna o repositório de categorias
func (s *MemoryStore) CategoryRepo() CategoryRepository {
	return &memoryCategoryRepo{store: s}
}

// UserRepo retorna o repositório de usuários
func (s *MemoryStore) UserRepo() UserRepository {
	return &memoryUserRepo{store: s}
}

// ---- Implementação do Repositório de Artigos ----

type memoryArticleRepo struct {
	store *MemoryStore
}

func (r *memoryArticleRepo) Create(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	article.ID = r.store.next()
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) GetByID(id int64) (*model.Article, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	article, ok := r.store.articles[id]
	if !ok {
		return nil, fmt.Errorf("artigo %d não encontrado", id)
	}
	return article, nil
}

func (r *memoryArticleRepo) List(page, pageSize int) ([]*model.Article, int64, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	// Coletar todos os artigos
	all := make([]*model.Article, 0, len(r.store.articles))
	for _, a := range r.store.articles {
		all = append(all, a)
	}

	total := int64(len(all))
	start := (page - 1) * pageSize
	if start >= len(all) {
		return []*model.Article{}, total, nil
	}
	end := start + pageSize
	if end > len(all) {
		end = len(all)
	}

	return all[start:end], total, nil
}

func (r *memoryArticleRepo) Update(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[article.ID]; !ok {
		return fmt.Errorf("artigo %d não encontrado", article.ID)
	}
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) Delete(id int64) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[id]; !ok {
		return fmt.Errorf("artigo %d não encontrado", id)
	}
	delete(r.store.articles, id)
	return nil
}

// ---- Implementação do Repositório de Categorias ----

type memoryCategoryRepo struct {
	store *MemoryStore
}

func (r *memoryCategoryRepo) Create(category *model.Category) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	category.ID = r.store.next()
	r.store.categories[category.ID] = category
	return nil
}

func (r *memoryCategoryRepo) GetByID(id int64) (*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	cat, ok := r.store.categories[id]
	if !ok {
		return nil, fmt.Errorf("categoria %d não encontrada", id)
	}
	return cat, nil
}

func (r *memoryCategoryRepo) List() ([]*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	all := make([]*model.Category, 0, len(r.store.categories))
	for _, c := range r.store.categories {
		all = append(all, c)
	}
	return all, nil
}

// ---- Implementação do Repositório de Usuários ----

type memoryUserRepo struct {
	store *MemoryStore
}

func (r *memoryUserRepo) Create(user *model.User) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	user.ID = r.store.next()
	r.store.users[user.ID] = user
	return nil
}

func (r *memoryUserRepo) GetByID(id int64) (*model.User, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	user, ok := r.store.users[id]
	if !ok {
		return nil, fmt.Errorf("usuário %d não encontrado", id)
	}
	return user, nil
}

Análise do código:


Passo 5: Camada de Lógica de Negócio (Service)

Interface de Service

GO
// internal/service/service.go
package service

import "blog-api/internal/model"

// ArticleService interface de negócio de artigos
type ArticleService interface {
	Create(req *model.CreateArticleRequest) (*model.Article, error)
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) (*model.ListResponse, error)
	Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error)
	Delete(id int64) error
}

// CategoryService interface de negócio de categorias
type CategoryService interface {
	Create(name string) (*model.Category, error)
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

Implementação do Service de Artigos

GO
// internal/service/article.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type articleService struct {
	store repository.Store
}

// NewArticleService cria um service de artigos
func NewArticleService(store repository.Store) ArticleService {
	return &articleService{store: store}
}

// Create cria um artigo
func (s *articleService) Create(req *model.CreateArticleRequest) (*model.Article, error) {
	// Validação de parâmetros
	if strings.TrimSpace(req.Title) == "" {
		return nil, errcode.ErrTitleRequired
	}
	if strings.TrimSpace(req.Content) == "" {
		return nil, errcode.ErrContentRequired
	}

	// Verificar se o autor existe
	_, err := s.store.UserRepo().GetByID(req.AuthorID)
	if err != nil {
		return nil, errcode.ErrUserNotFound
	}

	// Verificar se a categoria existe
	_, err = s.store.CategoryRepo().GetByID(req.CategoryID)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}

	// Construir objeto do artigo
	now := time.Now()
	article := &model.Article{
		Title:      req.Title,
		Content:    req.Content,
		AuthorID:   req.AuthorID,
		CategoryID: req.CategoryID,
		Tags:       req.Tags,
		Status:     0, // Rascunho padrão
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	// Persistir
	if err := s.store.ArticleRepo().Create(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// GetByID recupera um artigo por ID
func (s *articleService) GetByID(id int64) (*model.Article, error) {
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}
	return article, nil
}

// List retorna lista paginada de artigos
func (s *articleService) List(page, pageSize int) (*model.ListResponse, error) {
	// Valores padrão dos parâmetros
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 || pageSize > 100 {
		pageSize = 10
	}

	articles, total, err := s.store.ArticleRepo().List(page, pageSize)
	if err != nil {
		return nil, errcode.ErrInternal
	}

	// Calcular total de páginas
	totalPages := int(total) / pageSize
	if int(total)%pageSize > 0 {
		totalPages++
	}

	return &model.ListResponse{
		Items:      articles,
		Total:      total,
		Page:       page,
		PageSize:   pageSize,
		TotalPages: totalPages,
	}, nil
}

// Update atualiza um artigo
func (s *articleService) Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error) {
	// Consultar artigo existente
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}

	// Atualizar campos conforme necessário (atualizar apenas campos não-vazios que foram enviados)
	if req.Title != nil {
		if strings.TrimSpace(*req.Title) == "" {
			return nil, errcode.ErrTitleRequired
		}
		article.Title = *req.Title
	}
	if req.Content != nil {
		if strings.TrimSpace(*req.Content) == "" {
			return nil, errcode.ErrContentRequired
		}
		article.Content = *req.Content
	}
	if req.CategoryID != nil {
		// Verificar se a categoria existe
		_, err := s.store.CategoryRepo().GetByID(*req.CategoryID)
		if err != nil {
			return nil, errcode.ErrCategoryNotFound
		}
		article.CategoryID = *req.CategoryID
	}
	if req.Tags != nil {
		article.Tags = req.Tags
	}
	if req.Status != nil {
		article.Status = *req.Status
	}
	article.UpdatedAt = time.Now()

	// Persistir
	if err := s.store.ArticleRepo().Update(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// Delete exclui um artigo
func (s *articleService) Delete(id int64) error {
	_, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return errcode.ErrArticleNotFound
	}

	if err := s.store.ArticleRepo().Delete(id); err != nil {
		return errcode.ErrInternal
	}

	return nil
}

Análise do código:

Implementação do Service de Categorias

GO
// internal/service/category.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type categoryService struct {
	store repository.Store
}

// NewCategoryService cria um service de categorias
func NewCategoryService(store repository.Store) CategoryService {
	return &categoryService{store: store}
}

// Create cria uma categoria
func (s *categoryService) Create(name string) (*model.Category, error) {
	if strings.TrimSpace(name) == "" {
		return nil, errcode.New(10002, "Nome da categoria não pode ser vazio")
	}

	category := &model.Category{
		Name:      name,
		CreatedAt: time.Now(),
	}

	if err := s.store.CategoryRepo().Create(category); err != nil {
		return nil, errcode.ErrInternal
	}

	return category, nil
}

// GetByID recupera uma categoria por ID
func (s *categoryService) GetByID(id int64) (*model.Category, error) {
	cat, err := s.store.CategoryRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}
	return cat, nil
}

// List retorna todas as categorias
func (s *categoryService) List() ([]*model.Category, error) {
	cats, err := s.store.CategoryRepo().List()
	if err != nil {
		return nil, errcode.ErrInternal
	}
	return cats, nil
}

Passo 6: Ponto de Entrada do Programa

GO
// cmd/server/main.go
package main

import (
	"fmt"
	"log"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/internal/service"
)

func main() {
	// Inicializar armazenamento
	store := repository.NewMemoryStore()

	// Inicializar services
	articleSvc := service.NewArticleService(store)
	categorySvc := service.NewCategoryService(store)

	// Popular dados de teste
	seedData(store, articleSvc, categorySvc)

	// Verificar lógica de negócio
	runDemo(articleSvc, categorySvc)
}

// seedData popula dados de teste
func seedData(store *repository.MemoryStore, articleSvc service.ArticleService, categorySvc service.CategoryService) {
	// Criar usuário
	user := &model.User{
		Username: "alice",
		Email:    "alice@example.com",
	}
	if err := store.UserRepo().Create(user); err != nil {
		log.Fatalf("Falha ao criar usuário: %v", err)
	}
	fmt.Printf("✓ Usuário criado: ID=%d, Username=%s\n", user.ID, user.Username)

	// Criar categorias
	goCategory, _ := categorySvc.Create("Linguagem Go")
	pythonCategory, _ := categorySvc.Create("Python")
	fmt.Printf("✓ Categoria criada: ID=%d, Nome=%s\n", goCategory.ID, goCategory.Name)
	fmt.Printf("✓ Categoria criada: ID=%d, Nome=%s\n", pythonCategory.ID, pythonCategory.Name)

	// Criar artigos
	article1, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Introdução à Concorrência em Go",
		Content:    "Este artigo introduz o uso básico de goroutines e channels...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "concorrência", "goroutine"},
	})
	if err != nil {
		log.Fatalf("Falha ao criar artigo: %v", err)
	}
	fmt.Printf("✓ Artigo criado: ID=%d, Título=%s\n", article1.ID, article1.Title)

	article2, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Padrões de Design de Interface Go",
		Content:    "Um bom design de interface é chave para programas Go...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "interface", "padrões-de-design"},
	})
	if err != nil {
		log.Fatalf("Falha ao criar artigo: %v", err)
	}
	fmt.Printf("✓ Artigo criado: ID=%d, Título=%s\n", article2.ID, article2.Title)
}

// runDemo demonstra funcionalidades principais
func runDemo(articleSvc service.ArticleService, categorySvc service.CategoryService) {
	fmt.Println("\n========== Demo de Funcionalidades ==========")

	// Consultar artigo único
	article, err := articleSvc.GetByID(3)
	if err != nil {
		log.Printf("Falha ao consultar artigo: %v", err)
	} else {
		fmt.Printf("\n📄 Consultar artigo: %s\n", article.Title)
		fmt.Printf("   Conteúdo: %s\n", article.Content)
		fmt.Printf("   Tags: %v\n", article.Tags)
	}

	// Consulta paginada
	fmt.Println("\n📋 Lista de artigos (página 1, 10 por página):")
	list, err := articleSvc.List(1, 10)
	if err != nil {
		log.Printf("Falha ao consultar lista: %v", err)
	} else {
		articles := list.Items.([]*model.Article)
		for _, a := range articles {
			fmt.Printf("   [%d] %s (tags: %v)\n", a.ID, a.Title, a.Tags)
		}
		fmt.Printf("   Total %d artigos, %d páginas\n", list.Total, list.TotalPages)
	}

	// Atualizar artigo
	newTitle := "Concorrência Avançada em Go"
	newStatus := int8(1)
	updated, err := articleSvc.Update(3, &model.UpdateArticleRequest{
		Title:  &newTitle,
		Status: &newStatus,
	})
	if err != nil {
		log.Printf("Falha ao atualizar artigo: %v", err)
	} else {
		fmt.Printf("\n✏️  Artigo atualizado: %s (status: %d)\n", updated.Title, updated.Status)
	}

	// Excluir artigo
	err = articleSvc.Delete(4)
	if err != nil {
		log.Printf("Falha ao excluir artigo: %v", err)
	} else {
		fmt.Println("\n🗑️  Artigo ID=4 excluído")
	}

	// Verificar consulta após exclusão
	_, err = articleSvc.GetByID(4)
	if err != nil {
		fmt.Printf("   Consultar artigo excluído: %v ✓\n", err)
	}

	// Lista de categorias
	categories, _ := categorySvc.List()
	fmt.Println("\n📂 Lista de categorias:")
	for _, c := range categories {
		fmt.Printf("   [%d] %s\n", c.ID, c.Name)
	}

	// Demo de tratamento de erros
	fmt.Println("\n❌ Demo de tratamento de erros:")

	// Título vazio
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "teste",
	})
	fmt.Printf("   Título vazio: %v\n", err)

	// Autor inexistente
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:      "teste",
		Content:    "teste",
		AuthorID:   999,
		CategoryID: 2,
	})
	fmt.Printf("   Autor inexistente: %v\n", err)

	// Consultar artigo inexistente
	_, err = articleSvc.GetByID(999)
	fmt.Printf("   Artigo inexistente: %v\n", err)
}

Executando o Demo

BASH
go run cmd/server/main.go

Saída esperada:

TEXT
✓ Usuário criado: ID=1, Username=alice
✓ Categoria criada: ID=2, Nome=Linguagem Go
✓ Categoria criada: ID=3, Nome=Python
✓ Artigo criado: ID=4, Título=Introdução à Concorrência em Go
✓ Artigo criado: ID=5, Título=Padrões de Design de Interface Go

========== Demo de Funcionalidades ==========

📄 Consultar artigo: Introdução à Concorrência em Go
   Conteúdo: Este artigo introduz o uso básico de goroutines e channels...
   Tags: [go concorrência goroutine]

📋 Lista de artigos (página 1, 10 por página):
   [4] Introdução à Concorrência em Go (tags: [go concorrência goroutine])
   [5] Padrões de Design de Interface Go (tags: [go interface padrões-de-design])
   Total 2 artigos, 1 páginas

✏️  Artigo atualizado: Concorrência Avançada em Go (status: 1)

🗑️  Artigo ID=5 excluído
   Consultar artigo excluído: code=20001, message=Artigo não encontrado ✓

📂 Lista de categorias:
   [2] Linguagem Go
   [3] Python

❌ Demo de tratamento de erros:
   Título vazio: code=20004, message=Título não pode ser vazio
   Autor inexistente: code=20003, message=Usuário não encontrado
   Artigo inexistente: code=20001, message=Artigo não encontrado

Passo 7: Testes Unitários

Testes da Camada de Repository

GO
// internal/repository/memory_store_test.go
package repository

import (
	"testing"
	"time"

	"blog-api/internal/model"
)

// Função auxiliar: criar armazenamento de teste
func newTestStore() *MemoryStore {
	return NewMemoryStore()
}

func TestArticleRepository_Create(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:     "Artigo de Teste",
		Content:   "Conteúdo de Teste",
		AuthorID:  1,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	err := repo.Create(article)
	if err != nil {
		t.Fatalf("Falha ao criar artigo: %v", err)
	}

	// ID deve ser auto-atribuído
	if article.ID == 0 {
		t.Fatal("ID do artigo não deve ser 0")
	}
}

func TestArticleRepository_GetByID(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// Criar artigo
	article := &model.Article{
		Title:   "Artigo de Teste",
		Content: "Conteúdo de Teste",
	}
	_ = repo.Create(article)

	// Consultar
	got, err := repo.GetByID(article.ID)
	if err != nil {
		t.Fatalf("Falha ao consultar artigo: %v", err)
	}
	if got.Title != "Artigo de Teste" {
		t.Errorf("Esperado título %q, obtido %q", "Artigo de Teste", got.Title)
	}

	// Consultar artigo inexistente
	_, err = repo.GetByID(999)
	if err == nil {
		t.Error("Consultar artigo inexistente deve retornar erro")
	}
}

func TestArticleRepository_List(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// Criar 15 artigos
	for i := 0; i < 15; i++ {
		_ = repo.Create(&model.Article{
			Title:   "Artigo",
			Content: "Conteúdo",
		})
	}

	// Primeira página
	articles, total, err := repo.List(1, 10)
	if err != nil {
		t.Fatalf("Falha ao consultar lista: %v", err)
	}
	if total != 15 {
		t.Errorf("Esperado total 15, obtido %d", total)
	}
	if len(articles) != 10 {
		t.Errorf("Esperado 10 itens, obtido %d", len(articles))
	}

	// Segunda página
	articles, _, _ = repo.List(2, 10)
	if len(articles) != 5 {
		t.Errorf("Esperado 5 itens, obtido %d", len(articles))
	}

	// Página fora do intervalo
	articles, _, _ = repo.List(100, 10)
	if len(articles) != 0 {
		t.Errorf("Fora do intervalo deve retornar vazio, obtido %d itens", len(articles))
	}
}

func TestArticleRepository_Update(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "Título Original",
		Content: "Conteúdo Original",
	}
	_ = repo.Create(article)

	// Atualizar
	article.Title = "Novo Título"
	err := repo.Update(article)
	if err != nil {
		t.Fatalf("Falha na atualização: %v", err)
	}

	// Verificar
	got, _ := repo.GetByID(article.ID)
	if got.Title != "Novo Título" {
		t.Errorf("Esperado %q, obtido %q", "Novo Título", got.Title)
	}
}

func TestArticleRepository_Delete(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "A Ser Excluído",
		Content: "Conteúdo",
	}
	_ = repo.Create(article)

	// Excluir
	err := repo.Delete(article.ID)
	if err != nil {
		t.Fatalf("Falha na exclusão: %v", err)
	}

	// Verificar exclusão
	_, err = repo.GetByID(article.ID)
	if err == nil {
		t.Error("Consultar artigo excluído deve retornar erro")
	}
}

Testes da Camada de Service

GO
// internal/service/article_test.go
package service

import (
	"testing"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

// Função auxiliar: criar service com dados de teste
func newTestArticleService() (ArticleService, repository.Store) {
	store := repository.NewMemoryStore()

	// Criar usuário de teste
	_ = store.UserRepo().Create(&model.User{
		Username: "testuser",
		Email:    "test@example.com",
	})

	// Criar categoria de teste
	_ = store.CategoryRepo().Create(&model.Category{
		Name: "Go",
	})

	return NewArticleService(store), store
}

func TestArticleService_Create_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, err := svc.Create(&model.CreateArticleRequest{
		Title:      "Artigo de Teste",
		Content:    "Conteúdo de Teste",
		AuthorID:   1,
		CategoryID: 2,
		Tags:       []string{"teste"},
	})
	if err != nil {
		t.Fatalf("Falha ao criar artigo: %v", err)
	}
	if article.Title != "Artigo de Teste" {
		t.Errorf("Esperado %q, obtido %q", "Artigo de Teste", article.Title)
	}
	if article.Status != 0 {
		t.Errorf("Status padrão deve ser 0, obtido %d", article.Status)
	}
}

func TestArticleService_Create_EmptyTitle(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "Conteúdo",
	})

	// Verificação de tipo para checar código de erro
	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Esperado AppError, obtido %T", err)
	}
	if appErr.Code != errcode.ErrTitleRequired.Code {
		t.Errorf("Esperado código de erro %d, obtido %d", errcode.ErrTitleRequired.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidAuthor(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "Artigo",
		Content:    "Conteúdo",
		AuthorID:   999,
		CategoryID: 2,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Esperado AppError, obtido %T", err)
	}
	if appErr.Code != errcode.ErrUserNotFound.Code {
		t.Errorf("Esperado código de erro %d, obtido %d", errcode.ErrUserNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidCategory(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "Artigo",
		Content:    "Conteúdo",
		AuthorID:   1,
		CategoryID: 999,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Esperado AppError, obtido %T", err)
	}
	if appErr.Code != errcode.ErrCategoryNotFound.Code {
		t.Errorf("Esperado código de erro %d, obtido %d", errcode.ErrCategoryNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Update_PartialUpdate(t *testing.T) {
	svc, _ := newTestArticleService()

	// Primeiro criar um artigo
	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "Título Original",
		Content:    "Conteúdo Original",
		AuthorID:   1,
		CategoryID: 2,
	})

	// Atualizar apenas o título
	newTitle := "Novo Título"
	updated, err := svc.Update(article.ID, &model.UpdateArticleRequest{
		Title: &newTitle,
	})
	if err != nil {
		t.Fatalf("Falha na atualização: %v", err)
	}
	if updated.Title != "Novo Título" {
		t.Errorf("Esperado %q, obtido %q", "Novo Título", updated.Title)
	}
	if updated.Content != "Conteúdo Original" {
		t.Errorf("Conteúdo não deve ser modificado, obtido %q", updated.Content)
	}
}

func TestArticleService_Update_NotFound(t *testing.T) {
	svc, _ := newTestArticleService()

	newTitle := "teste"
	_, err := svc.Update(999, &model.UpdateArticleRequest{
		Title: &newTitle,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("Esperado AppError, obtido %T", err)
	}
	if appErr.Code != errcode.ErrArticleNotFound.Code {
		t.Errorf("Esperado código de erro %d, obtido %d", errcode.ErrArticleNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Delete_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "A Ser Excluído",
		Content:    "Conteúdo",
		AuthorID:   1,
		CategoryID: 2,
	})

	err := svc.Delete(article.ID)
	if err != nil {
		t.Fatalf("Falha na exclusão: %v", err)
	}

	// Verificar exclusão
	_, err = svc.GetByID(article.ID)
	if err == nil {
		t.Error("Consultar artigo excluído deve retornar erro")
	}
}

func TestArticleService_List_Pagination(t *testing.T) {
	svc, _ := newTestArticleService()

	// Criar 25 artigos
	for i := 0; i < 25; i++ {
		_, _ = svc.Create(&model.CreateArticleRequest{
			Title:      "Artigo",
			Content:    "Conteúdo",
			AuthorID:   1,
			CategoryID: 2,
		})
	}

	// Primeira página
	resp, err := svc.List(1, 10)
	if err != nil {
		t.Fatalf("Falha na consulta: %v", err)
	}
	if resp.Total != 25 {
		t.Errorf("Esperado total 25, obtido %d", resp.Total)
	}
	if resp.TotalPages != 3 {
		t.Errorf("Esperado total de páginas 3, obtido %d", resp.TotalPages)
	}

	// Tratamento de valor padrão
	resp, _ = svc.List(0, 0)
	if resp.Page != 1 {
		t.Errorf("page=0 deve ser padrão 1, obtido %d", resp.Page)
	}
	if resp.PageSize != 10 {
		t.Errorf("pageSize=0 deve ser padrão 10, obtido %d", resp.PageSize)
	}
}

Executando Testes

BASH
go test ./internal/... -v -cover

Saída esperada:

TEXT
=== RUN   TestArticleRepository_Create
--- PASS: TestArticleRepository_Create (0.00s)
=== RUN   TestArticleRepository_GetByID
--- PASS: TestArticleRepository_GetByID (0.00s)
=== RUN   TestArticleRepository_List
--- PASS: TestArticleRepository_List (0.00s)
=== RUN   TestArticleRepository_Update
--- PASS: TestArticleRepository_Update (0.00s)
=== RUN   TestArticleRepository_Delete
--- PASS: TestArticleRepository_Delete (0.00s)
=== RUN   TestArticleService_Create_Success
--- PASS: TestArticleService_Create_Success (0.00s)
=== RUN   TestArticleService_Create_EmptyTitle
--- PASS: TestArticleService_Create_EmptyTitle (0.00s)
=== RUN   TestArticleService_Create_InvalidAuthor
--- PASS: TestArticleService_Create_InvalidAuthor (0.00s)
=== RUN   TestArticleService_Create_InvalidCategory
--- PASS: TestArticleService_Create_InvalidCategory (0.00s)
=== RUN   TestArticleService_Update_PartialUpdate
--- PASS: TestArticleService_Update_PartialUpdate (0.00s)
=== RUN   TestArticleService_Update_NotFound
--- PASS: TestArticleService_Update_NotFound (0.00s)
=== RUN   TestArticleService_Delete_Success
--- PASS: TestArticleService_Delete_Success (0.00s)
=== RUN   TestArticleService_List_Pagination
--- PASS: TestArticleService_List_Pagination (0.00s)
PASS
coverage: 82.4% of statements
ok      blog-api/internal/repository    0.002s
ok      blog-api/internal/service       0.002s

Resumo do Código

Esta lição completou a parte principal da Blog API:

Camada Arquivo Responsabilidade
Model internal/model/*.go Definições de estrutura de dados, corpos de requisição/resposta
Repository internal/repository/*.go Interfaces de acesso a dados + implementação em memória
Service internal/service/*.go Lógica de negócio, validação de parâmetros, conversão de erros
Error pkg/errcode/errcode.go Definições de códigos de erro unificados
Entry cmd/server/main.go Ponto de entrada do programa, inicialização, demo

Decisões de design principais:

  1. Desacoplamento por interface: Repository e Service interagem através de interfaces, facilitando testes unitários (mocking) e futura substituição de armazenamento
  2. Atualizações por ponteiro: UpdateArticleRequest usa campos de ponteiro para distinguir valores zero de valores não-definidos
  3. Erros unificados: AppError encapsula códigos e mensagens de erro para tratamento unificado no frontend
  4. Segurança de concorrência: sync.RWMutex protege dados em memória, operações de leitura usam trava de leitura, operações de escrita usam trava de escrita

❓ Perguntas Frequentes

P1: Por que a camada Service não depende diretamente de implementações concretas de Repository?

Service depende de interfaces em vez de implementações concretas, então testes unitários podem injetar implementações mock sem armazenamento real. Além disso, se depois você quiser substituir o armazenamento em memória por MySQL, basta criar um novo mysqlArticleRepo implementando a interface ArticleRepository — o código da camada Service não precisa mudar.

P2: Por que UpdateArticleRequest usa campos de ponteiro?

Em Go, o valor zero de string é uma string vazia "". Com campos normais, você não consegue distinguir entre "usuário não enviou título" e "usuário enviou título vazio". Com *string, nil significa não enviado, e não-nil significa que há um valor. Este é um padrão comum no design de APIs Go.

P3: O sync.RWMutex do armazenamento em memória pode se tornar um gargalo de desempenho?

É suficiente para o cenário de demonstração deste tutorial. Se leituras superam muito as escritas (consultas de lista de artigos superam muito criações/atualizações), a trava de leitura do RWMutex é compartilhada — múltiplas operações de leitura podem executar concorrentemente, e apenas operações de escrita são mutuamente exclusivas. Para produção, use banco de dados — pools de conexões são inerentemente seguros para concorrência.

P4: Como melhorar a cobertura de testes?

Atualmente em 80%+. Para cobertura maior, você pode adicionar: testes de valor limite (títulos muito longos, caracteres especiais), testes de segurança de concorrência (múltiplas goroutines criando artigos simultaneamente), e casos de teste completos para o service de categorias.


📖 Resumo

Nesta lição construímos um esqueleto completo de projeto Go do zero:

A próxima lição completará a camada Handler HTTP, integrando roteamento e middleware para expor a lógica de negócio como uma API RESTful.


📝 Exercícios

Exercício 1: Adicionar Gerenciamento Independente de Tags

Implemente Repository e Service independentes para Tags, suportando:

Exercício 2: Implementar Funcionalidade de Busca de Artigos

Adicione um método Search ao ArticleService:

Dica: Para implementação em memória, itere o mapa e filtre. Correspondência sem distinção de maiúsculas/minúsculas é obrigatória.

Exercício 3: Escrever Testes de Segurança de Concorrência

Escreva um caso de teste que use 100 goroutines para chamar simultaneamente ArticleService.Create, verificando:


Próxima Lição: Projeto Completo (Parte 2) — Roteamento HTTP, Middleware e API Completa

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%