Projeto final em Go: aplicação completa com módulos, testes, JSON, I/O e concorrência

Capítulo 18

Tempo estimado de leitura: 12 minutos

+ Exercício

Objetivo do projeto integrador

Neste capítulo você vai construir um projeto completo em Go, do zero, com uma aplicação de linha de comando que lê um diretório com arquivos JSON de “eventos”, valida e processa esses eventos em paralelo, gera um relatório agregado (em JSON) e escreve o resultado em disco. O projeto inclui arquitetura por pacotes, contratos via interfaces, tratamento consistente de erros, testes dos componentes principais, automação com go test, concorrência com cancelamento por contexto e empacotamento com go build.

Requisitos funcionais

  • Entrada: um diretório contendo arquivos .json, cada arquivo com uma lista de eventos.
  • Processamento: validar eventos, agregar métricas e registrar erros por arquivo.
  • Concorrência: processar arquivos em paralelo com limite de workers; cancelar tudo se houver erro “fatal” (por exemplo, JSON inválido acima de um limite).
  • Saída: gerar um arquivo report.json com totais e estatísticas; opcionalmente imprimir um resumo no stdout.
  • Testes: cobrir parsing/validação, agregação e o pipeline concorrente (com fakes/mocks via interfaces).
  • Automação: rodar go test ./... e garantir build com go build.

Requisitos não funcionais

  • Arquitetura em camadas: domínio (modelos e regras), aplicação (casos de uso), infraestrutura (I/O, filesystem) e CLI.
  • Erros com contexto: mensagens úteis e wrapping para rastreio.
  • Sem dependências externas obrigatórias (apenas biblioteca padrão).

Especificação do formato de dados

Cada arquivo JSON contém um array de eventos. Exemplo data/events_001.json:

[ { "id": "e1", "type": "click", "user_id": "u1", "value": 1, "timestamp": "2026-01-01T10:00:00Z" }, { "id": "e2", "type": "purchase", "user_id": "u2", "value": 99, "timestamp": "2026-01-01T10:01:00Z" } ]

Regras de validação (mínimo):

  • id, type, user_id obrigatórios e não vazios.
  • value deve ser >= 0.
  • timestamp deve ser parseável (RFC3339).
  • type permitido: click, view, purchase.

Arquitetura de pacotes e contratos

Estrutura sugerida (ajuste conforme seu padrão):

jsonreport/  go.mod  cmd/    jsonreport/      main.go  internal/    domain/      event.go      report.go      validate.go    app/      process.go    infra/      fs.go      jsonreader.go

Responsabilidades

  • internal/domain: tipos do domínio (Event, Report) e validação.
  • internal/app: caso de uso “Processar diretório e gerar relatório”, orquestração e concorrência.
  • internal/infra: implementação de leitura de arquivos, listagem de diretório e parsing JSON.
  • cmd/jsonreport: parsing de flags, criação de dependências e chamada do caso de uso.

Interfaces (contratos) para desacoplamento

Defina contratos no pacote de aplicação (ou em um subpacote ports) para facilitar testes:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

package app type FileLister interface { ListJSONFiles(dir string) ([]string, error) } type EventSource interface { ReadEvents(path string) ([]domain.Event, error) } type ReportWriter interface { WriteReport(path string, r domain.Report) error }

Com isso, o caso de uso não depende diretamente de filesystem/JSON; em testes você injeta fakes.

Passo a passo: implementando o domínio

1) Modelo de evento

package domain import "time" type Event struct { ID        string    `json:"id"` Type      string    `json:"type"` UserID    string    `json:"user_id"` Value     float64   `json:"value"` Timestamp time.Time `json:"timestamp"` }

2) Validação

Crie uma função de validação que retorne erro com contexto. Mantenha a validação no domínio para ser reutilizada em qualquer entrada (arquivo, rede, etc.).

package domain import ( "errors" "fmt" "time" ) var ErrInvalidEvent = errors.New("invalid event") func ValidateEvent(e Event) error { if e.ID == "" || e.Type == "" || e.UserID == "" { return fmt.Errorf("%w: missing required fields (id/type/user_id)", ErrInvalidEvent) } if e.Value < 0 { return fmt.Errorf("%w: value must be >= 0", ErrInvalidEvent) } switch e.Type { case "click", "view", "purchase": default: return fmt.Errorf("%w: unsupported type %q", ErrInvalidEvent, e.Type) } if e.Timestamp.IsZero() { return fmt.Errorf("%w: timestamp is required", ErrInvalidEvent) } _ = time.Second return nil }

3) Relatório e agregação

O relatório agrega contagens por tipo e soma de valores (por exemplo, total de compras). Você pode evoluir depois para percentis, top usuários etc.

package domain type Report struct { TotalFiles      int              `json:"total_files"` TotalEvents     int              `json:"total_events"` InvalidEvents   int              `json:"invalid_events"` ByType          map[string]int   `json:"by_type"` TotalValueByType map[string]float64 `json:"total_value_by_type"` Errors          []string         `json:"errors,omitempty"` }
package domain func NewReport() Report { return Report{ ByType: make(map[string]int), TotalValueByType: make(map[string]float64), } } func (r *Report) AddEvent(e Event) { r.TotalEvents++ r.ByType[e.Type]++ r.TotalValueByType[e.Type] += e.Value }

Passo a passo: infraestrutura (I/O + JSON)

4) Listagem de arquivos JSON

package infra import ( "fmt" "io/fs" "path/filepath" "strings" ) type OSFileLister struct{} func (OSFileLister) ListJSONFiles(dir string) ([]string, error) { var out []string walkFn := func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if strings.HasSuffix(strings.ToLower(d.Name()), ".json") { out = append(out, path) } return nil } if err := filepath.WalkDir(dir, walkFn); err != nil { return nil, fmt.Errorf("walk dir %q: %w", dir, err) } return out, nil }

5) Leitura e parsing de eventos

O leitor converte JSON em []domain.Event. Como timestamp no JSON é string, use um tipo auxiliar para parsear e depois mapear para o domínio.

package infra import ( "encoding/json" "fmt" "os" "time" "jsonreport/internal/domain" ) type jsonEvent struct { ID        string  `json:"id"` Type      string  `json:"type"` UserID    string  `json:"user_id"` Value     float64 `json:"value"` Timestamp string  `json:"timestamp"` } type JSONFileEventSource struct{} func (JSONFileEventSource) ReadEvents(path string) ([]domain.Event, error) { b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read file %q: %w", path, err) } var raw []jsonEvent if err := json.Unmarshal(b, &raw); err != nil { return nil, fmt.Errorf("unmarshal %q: %w", path, err) } out := make([]domain.Event, 0, len(raw)) for i, re := range raw { ts, err := time.Parse(time.RFC3339, re.Timestamp) if err != nil { return nil, fmt.Errorf("parse timestamp %q (index %d) in %q: %w", re.Timestamp, i, path, err) } out = append(out, domain.Event{ ID: re.ID, Type: re.Type, UserID: re.UserID, Value: re.Value, Timestamp: ts, }) } return out, nil }

6) Escrita do relatório

package infra import ( "encoding/json" "fmt" "os" "jsonreport/internal/domain" ) type JSONReportWriter struct{} func (JSONReportWriter) WriteReport(path string, r domain.Report) error { b, err := json.MarshalIndent(r, "", "  ") if err != nil { return fmt.Errorf("marshal report: %w", err) } if err := os.WriteFile(path, b, 0o644); err != nil { return fmt.Errorf("write report %q: %w", path, err) } return nil }

Passo a passo: caso de uso com concorrência e cancelamento

7) Configuração do processamento

O caso de uso recebe dependências por interface e parâmetros de execução. Inclua limite de workers e um limiar de erros fatais para demonstrar cancelamento.

package app import "time" type ProcessConfig struct { Workers          int FatalErrorLimit  int Timeout          time.Duration }

8) Implementação do pipeline concorrente

Estratégia:

  • Listar arquivos.
  • Criar context.WithTimeout (opcional) e context.WithCancel para cancelamento manual.
  • Enviar paths para um canal de jobs.
  • Workers leem eventos, validam e retornam resultados por canal.
  • Um agregador único consolida no domain.Report (evita locks complexos).
package app import ( "context" "fmt" "sync" "jsonreport/internal/domain" ) type Processor struct { Lister FileLister Source EventSource Writer ReportWriter } type fileResult struct { path         string events       []domain.Event err          error invalidCount int } func (p Processor) ProcessDir(ctx context.Context, inDir, outPath string, cfg ProcessConfig) (domain.Report, error) { if cfg.Workers <= 0 { cfg.Workers = 4 } if cfg.FatalErrorLimit <= 0 { cfg.FatalErrorLimit = 1 } files, err := p.Lister.ListJSONFiles(inDir) if err != nil { return domain.Report{}, err } r := domain.NewReport() r.TotalFiles = len(files) ctx, cancel := context.WithCancel(ctx) defer cancel() jobs := make(chan string) results := make(chan fileResult) var wg sync.WaitGroup worker := func() { defer wg.Done() for path := range jobs { select { case <-ctx.Done(): return default: } evs, err := p.Source.ReadEvents(path) res := fileResult{path: path, events: evs, err: err} if err == nil { for _, e := range evs { if verr := domain.ValidateEvent(e); verr != nil { res.invalidCount++ } } } select { case results <- res: case <-ctx.Done(): return } } } wg.Add(cfg.Workers) for i := 0; i < cfg.Workers; i++ { go worker() } go func() { defer close(jobs) for _, f := range files { select { case jobs <- f: case <-ctx.Done(): return } } }() go func() { wg.Wait() close(results) }() fatalErrors := 0 for res := range results { if res.err != nil { r.Errors = append(r.Errors, fmt.Sprintf("%s: %v", res.path, res.err)) fatalErrors++ if fatalErrors >= cfg.FatalErrorLimit { cancel() } continue } r.InvalidEvents += res.invalidCount for _, e := range res.events { if domain.ValidateEvent(e) == nil { r.AddEvent(e) } } } if err := ctx.Err(); err != nil && fatalErrors >= cfg.FatalErrorLimit { return r, fmt.Errorf("processing canceled after fatal errors: %w", err) } if err := p.Writer.WriteReport(outPath, r); err != nil { return r, err } return r, nil }

Nota importante: o worker valida eventos para contar inválidos; o agregador valida novamente antes de agregar. Em um refinamento, você pode validar uma vez e enviar apenas eventos válidos + contagem, mas manter assim deixa o fluxo explícito para estudo.

Passo a passo: CLI (cmd) e composição das dependências

9) main.go com flags

package main import ( "context" "flag" "fmt" "os" "time" "jsonreport/internal/app" "jsonreport/internal/infra" ) func main() { inDir := flag.String("in", "./data", "input directory with json files") out := flag.String("out", "./report.json", "output report path") workers := flag.Int("workers", 4, "number of workers") fatal := flag.Int("fatal", 1, "fatal error limit to cancel") timeout := flag.Duration("timeout", 0, "timeout (e.g. 10s); 0 disables") flag.Parse() ctx := context.Background() if *timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, *timeout) defer cancel() } proc := app.Processor{ Lister: infra.OSFileLister{}, Source: infra.JSONFileEventSource{}, Writer: infra.JSONReportWriter{}, } cfg := app.ProcessConfig{ Workers: *workers, FatalErrorLimit: *fatal, Timeout: *timeout, } r, err := proc.ProcessDir(ctx, *inDir, *out, cfg) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) } fmt.Printf("files=%d events=%d invalid=%d\n", r.TotalFiles, r.TotalEvents, r.InvalidEvents) if err != nil { os.Exit(1) } }

Testes: cobrindo componentes principais

10) Teste de validação (domínio)

package domain_test import ( "testing" "time" "jsonreport/internal/domain" ) func TestValidateEvent(t *testing.T) { now := time.Now().UTC() tests := []struct { name string e    domain.Event wantErr bool }{ {"ok", domain.Event{ID: "1", Type: "click", UserID: "u", Value: 0, Timestamp: now}, false}, {"missing id", domain.Event{Type: "click", UserID: "u", Value: 0, Timestamp: now}, true}, {"bad type", domain.Event{ID: "1", Type: "x", UserID: "u", Value: 0, Timestamp: now}, true}, {"negative value", domain.Event{ID: "1", Type: "view", UserID: "u", Value: -1, Timestamp: now}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := domain.ValidateEvent(tt.e) if (err != nil) != tt.wantErr { t.Fatalf("err=%v wantErr=%v", err, tt.wantErr) } }) } }

11) Teste de agregação (domínio)

package domain_test import ( "testing" "time" "jsonreport/internal/domain" ) func TestReportAddEvent(t *testing.T) { r := domain.NewReport() now := time.Now().UTC() r.AddEvent(domain.Event{ID: "1", Type: "click", UserID: "u1", Value: 1, Timestamp: now}) r.AddEvent(domain.Event{ID: "2", Type: "click", UserID: "u2", Value: 2, Timestamp: now}) if r.TotalEvents != 2 { t.Fatalf("TotalEvents=%d", r.TotalEvents) } if r.ByType["click"] != 2 { t.Fatalf("ByType[click]=%d", r.ByType["click"]) } if r.TotalValueByType["click"] != 3 { t.Fatalf("TotalValueByType[click]=%v", r.TotalValueByType["click"]) } }

12) Teste do caso de uso com fakes (app)

Crie fakes simples para simular arquivos e controlar erros, sem tocar no filesystem real.

package app_test import ( "context" "errors" "testing" "time" "jsonreport/internal/app" "jsonreport/internal/domain" ) type fakeLister struct{ files []string; err error } func (f fakeLister) ListJSONFiles(dir string) ([]string, error) { return f.files, f.err } type fakeSource struct{ byPath map[string][]domain.Event; errByPath map[string]error } func (f fakeSource) ReadEvents(path string) ([]domain.Event, error) { if err := f.errByPath[path]; err != nil { return nil, err } return f.byPath[path], nil } type fakeWriter struct{ wrote bool; err error } func (f *fakeWriter) WriteReport(path string, r domain.Report) error { f.wrote = true; return f.err } func TestProcessDir_CancelsOnFatalErrors(t *testing.T) { now := time.Now().UTC() l := fakeLister{files: []string{"a.json", "b.json"}} s := fakeSource{ byPath: map[string][]domain.Event{ "b.json": {{ID: "1", Type: "click", UserID: "u", Value: 1, Timestamp: now}}, }, errByPath: map[string]error{ "a.json": errors.New("boom"), }, } w := &fakeWriter{} p := app.Processor{Lister: l, Source: s, Writer: w} cfg := app.ProcessConfig{Workers: 2, FatalErrorLimit: 1} _, err := p.ProcessDir(context.Background(), "in", "out.json", cfg) if err == nil { t.Fatalf("expected error") } if w.wrote { t.Fatalf("writer should not be called when canceled before finishing") } }

Se você preferir que o relatório seja escrito mesmo com cancelamento, ajuste a regra no caso de uso e atualize o teste. O importante é ter um comportamento definido e testado.

Automação do fluxo com go test

13) Comandos recomendados

  • Rodar todos os testes: go test ./...
  • Rodar com race detector (útil no pipeline concorrente): go test -race ./...
  • Rodar com cobertura: go test -cover ./...

Empacotamento com go build

14) Build do binário

Na raiz do módulo:

go build -o bin/jsonreport ./cmd/jsonreport

Executar:

./bin/jsonreport -in ./data -out ./report.json -workers 4 -fatal 1

Critérios de aceitação e verificação

Checklist de aceitação

  • O comando go test ./... passa sem falhas.
  • O comando go test -race ./... passa (não deve haver data races no agregador).
  • O comando go build -o bin/jsonreport ./cmd/jsonreport gera o binário.
  • Com um diretório ./data contendo JSONs válidos, o programa gera report.json com campos: total_files, total_events, invalid_events, by_type, total_value_by_type.
  • Com um JSON inválido e -fatal 1, o processamento é cancelado e o programa retorna código de saída diferente de zero.

Saídas esperadas (exemplos)

Execução bem-sucedida:

$ ./bin/jsonreport -in ./data -out ./report.json -workers 4 -fatal 2 files=3 events=120 invalid=4

Trecho esperado de report.json:

{ "total_files": 3, "total_events": 120, "invalid_events": 4, "by_type": { "click": 70, "view": 40, "purchase": 10 }, "total_value_by_type": { "click": 70, "view": 0, "purchase": 999 } }

Execução com cancelamento por erro fatal:

$ ./bin/jsonreport -in ./data -out ./report.json -workers 4 -fatal 1 error: processing canceled after fatal errors: context canceled files=3 events=0 invalid=0

Verificação adicional: confirme que o arquivo de saída não foi escrito (ou foi escrito parcialmente) conforme a regra que você definiu e testou.

Agora responda o exercício sobre o conteúdo:

No pipeline concorrente do processamento de arquivos, qual é a principal razão para usar um agregador único que consolida os resultados no Report?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Um único agregador consolida os resultados em um ponto central, evitando atualizações concorrentes no Report e reduzindo a chance de data races, sem exigir sincronização complexa em múltiplos workers.

Capa do Ebook gratuito Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos
100%

Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos

Novo curso

18 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.