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
- Validação de parâmetros de entrada
- Formato de resposta de erro unificado
- Suporte a consulta paginada
- Cobertura de testes unitários > 70%
Design da Arquitetura do Sistema
Adota a clássica arquitetura de três camadas:
┌─────────────────────────────────────────────┐
│ 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
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
mkdir -p blog-api && cd blog-api
go mod init blog-api
Criar estrutura de diretórios:
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
// 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
// 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
// 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:
CreateArticleRequest: Todos os campos são obrigatórios ao criar, usa uma struct separadaUpdateArticleRequest: Usa ponteiros durante atualizações para distinguir "não enviado" de "valor vazio"ListRequest/Response: Formato de paginação unificado, reutilizado em todos os endpoints de listagem
Passo 3: Códigos de Erro Unificados
// 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:
- A struct
AppErrorcontém código e mensagem de erro, fácil para processamento unificado no frontend - Usa
varpara predefinir erros, evitando concatenação de strings em tempo de execução - Faixas de códigos de erro divididas por módulo: 1xxxx para geral, 2xxxx para relacionado a artigos
Passo 4: Camada de Acesso a Dados (Repository)
Interface de Armazenamento
// 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
// 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:
MemoryStorearmazena todos os dados, usasync.RWMutexpara segurança de concorrência- Os três métodos de repositório cada um retorna suas respectivas implementações de interface
atomic.Int64gera IDs auto-incrementados sem trava- A implementação em memória é conveniente para testes de desenvolvimento; pode ser substituída por implementação de banco de dados depois sem modificar o código da camada superior
Passo 5: Camada de Lógica de Negócio (Service)
Interface de Service
// 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
// 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:
- O método
Createfaz sequencialmente validação de parâmetros → verificação de recursos relacionados → construção de objeto → persistência Updateusa campos de ponteiro para atualizações parciais: modifica apenas campos que foram enviados- O método
Listtem proteção de valores padrão embutida para prevenir parâmetros inválidos - Todos os erros de nível inferior são uniformemente convertidos para
AppError, camadas superiores não precisam saber sobre implementações específicas de armazenamento
Implementação do Service de Categorias
// 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
// 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
go run cmd/server/main.go
Saída esperada:
✓ 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
// 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
// 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
go test ./internal/... -v -cover
Saída esperada:
=== 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:
- Desacoplamento por interface: Repository e Service interagem através de interfaces, facilitando testes unitários (mocking) e futura substituição de armazenamento
- Atualizações por ponteiro:
UpdateArticleRequestusa campos de ponteiro para distinguir valores zero de valores não-definidos - Erros unificados:
AppErrorencapsula códigos e mensagens de erro para tratamento unificado no frontend - Segurança de concorrência:
sync.RWMutexprotege 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:
- Análise de requisitos: Definimos limites funcionais e requisitos não-funcionais
- Estrutura de diretórios: Adotamos o layout padrão
cmd/internal/pkg - Modelos de dados: Definimos User, Category, Article e structs de requisição/resposta
- Camada Repository: Projetamos interfaces + implementação em memória, garantindo segurança de concorrência
- Camada Service: Implementamos lógica de negócio, validação de parâmetros, tratamento de erros
- Códigos de erro unificados:
AppErrorencapsula informações de erro para tratamento unificado no frontend - Testes unitários: Repository e Service cada um com testes cobrindo cenários normais e de erro
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:
- Criar tag
POST /tags - Consultar lista de tags
GET /tags - Contar o número de artigos associados a cada tag
Exercício 2: Implementar Funcionalidade de Busca de Artigos
Adicione um método Search ao ArticleService:
- Busca difusa por palavra-chave de título
- Filtrar por ID de categoria
- Filtrar por tag
- Suportar paginação
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:
- Todos os artigos são criados com sucesso
- IDs não possuem duplicatas
- Não há data races (use
go test -racepara detectar)
Próxima Lição: Projeto Completo (Parte 2) — Roteamento HTTP, Middleware e API Completa



