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.jsoncom 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 comgo 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_idobrigatórios e não vazios.valuedeve ser >= 0.timestampdeve ser parseável (RFC3339).typepermitido: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.goResponsabilidades
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:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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) econtext.WithCancelpara 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/jsonreportExecutar:
./bin/jsonreport -in ./data -out ./report.json -workers 4 -fatal 1Crité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/jsonreportgera o binário. - Com um diretório
./datacontendo JSONs válidos, o programa gerareport.jsoncom 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=4Trecho 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=0Verificaçã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.