🏗️ Cap 01: Arquitetura Full Stack Moderna: Monorepos (Turborepo), Tipagem Compartilhada e Docker Compose
🗺️ Mapa Arquitetural do Tópico
flowchart TD
MONO["Monorepo Corporativo (Turborepo / pnpm)"]
MONO --> APPS["apps/"]
MONO --> PKGS["packages/"]
APPS --> WEB["apps/web (Frontend React / Angular)"]
APPS --> API["apps/api (Backend Node.js / Spring Boot)"]
PKGS --> TYPES["packages/contracts (DTOs e Schemas Zod Compartilhados)"]
PKGS --> UI["packages/ui (Design System)"]
style MONO fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
style WEB fill:#e8f5e9,stroke:#4caf50,stroke-width:1px
style API fill:#fff3e0,stroke:#ff9800,stroke-width:1px
style TYPES fill:#ede7f6,stroke:#673ab7,stroke-width:2px
🧩 1. O problema: frontend e backend que não se conhecem
Uma aplicação full stack tem pelo menos duas partes que evoluem em ritmos diferentes: o frontend (React, Angular, Vue) e o backend (Express, NestJS, Spring Boot, FastAPI). Quando cada uma vive em um repositório próprio e a única coisa que as liga é um documento descrevendo a API, três problemas aparecem cedo:
- Contratos que divergem em silêncio. O backend renomeia
nomeparatitulo, os testes do backend passam, e o frontend só descobre em produção, quando a tela mostraundefined. - Ambientes diferentes em cada máquina. Um desenvolvedor tem PostgreSQL 14, outro tem o 16, o servidor de CI tem outro. O clássico “na minha máquina funciona”.
- Mudanças que exigem vários PRs coordenados. Uma alteração que mexe no banco, na API e na tela vira três PRs em três repositórios, que precisam entrar na ordem certa.
Este capítulo trata das três ferramentas que atacam esses problemas: o monorepo (um repositório para as partes), a tipagem compartilhada (um contrato que o compilador verifica dos dois lados) e o Docker Compose (o mesmo ambiente para todos).
📦 2. Monorepo: várias aplicações, um repositório
Um monorepo não é “jogar tudo numa pasta”. Ele tem uma estrutura com dois tipos de projeto:
| Pasta | O que guarda | Exemplos |
|---|---|---|
apps/ |
Coisas que rodam: cada uma gera um executável, um site ou um container | apps/web, apps/api, apps/mobile |
packages/ |
Coisas que são importadas: bibliotecas internas compartilhadas | packages/contracts, packages/ui, packages/config |
O gerenciador de pacotes liga as pastas com workspaces. No pnpm, um arquivo na raiz declara quais pastas fazem parte do monorepo:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Com isso, apps/api pode declarar "@tecloja/contracts": "workspace:*" no próprio package.json e importar o pacote como se viesse do npm. Na prática é um link para a pasta local: qualquer mudança em packages/contracts aparece na hora em quem o usa.
2.1 Turborepo: o orquestrador
Com várias aplicações, rodar build e test em todas fica lento. O Turborepo (ou o Nx) resolve com duas ideias:
- Grafo de dependências: ele sabe que
apps/apidepende depackages/contractse constrói o pacote antes da aplicação. Tarefas independentes rodam em paralelo. - Cache por conteúdo: se os arquivos de entrada de uma tarefa não mudaram, ele devolve o resultado da execução anterior em vez de rodar de novo. No CI, um PR que só mexe no frontend não recompila o backend.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["build"] },
"dev": { "cache": false, "persistent": true }
}
}
O ^build significa “antes de construir este pacote, construa as dependências dele”. outputs diz o que guardar no cache.
🔗 3. Tipagem compartilhada: um contrato, dois lados
A peça que mais rende num monorepo full stack é o pacote de contratos: um lugar único que define o formato dos dados trocados entre frontend e backend. Com o Zod, o mesmo schema serve para duas coisas: validar em tempo de execução e gerar o tipo TypeScript.
// packages/contracts/src/tarefa.ts — a ÚNICA definição do contrato
import { z } from "zod";
export const TarefaSchema = z.object({
id: z.number().int().positive(),
titulo: z.string().min(3).max(120),
concluida: z.boolean(),
});
export const NovaTarefaSchema = TarefaSchema.omit({ id: true, concluida: true });
export type Tarefa = z.infer<typeof TarefaSchema>;
export type NovaTarefa = z.infer<typeof NovaTarefaSchema>;
O backend valida a entrada com o schema, e o frontend usa só o tipo:
// apps/api — valida a entrada com o MESMO schema que o frontend usa
import { NovaTarefaSchema, type Tarefa } from "@tecloja/contracts";
let proximoId = 1;
export function criarTarefa(corpo: unknown): Tarefa {
const dados = NovaTarefaSchema.parse(corpo); // lança ZodError se o JSON vier errado
return { id: proximoId++, titulo: dados.titulo, concluida: false };
}
// apps/web — o compilador acusa se o backend mudar o contrato
import type { NovaTarefa, Tarefa } from "@tecloja/contracts";
export async function enviarTarefa(nova: NovaTarefa): Promise<Tarefa> {
const resposta = await fetch("/api/tarefas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(nova),
});
return (await resposta.json()) as Tarefa;
}
Se alguém renomear titulo para nome no schema, o build do frontend quebra no mesmo PR. O erro que antes aparecia em produção agora aparece no editor. O capítulo 04 aprofunda o Zod e mostra o equivalente em Python com Pydantic.
💡 E quando o backend não é TypeScript (Spring Boot, FastAPI)? Aí o contrato comum passa a ser o OpenAPI: o backend publica a especificação, e uma ferramenta como
openapi-typescriptgera os tipos do frontend a partir dela. O princípio é o mesmo: uma fonte de verdade e tipos gerados, nunca escritos à mão nos dois lados.
🐳 4. Docker Compose: o mesmo ambiente para todos
O terceiro problema (versões diferentes em cada máquina) se resolve descrevendo a infraestrutura como código. O Docker Compose sobe banco, API e frontend com um comando, sempre nas mesmas versões:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: tarefasdb
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d tarefasdb"]
interval: 5s
retries: 5
api:
build: ./apps/api
depends_on:
db:
condition: service_healthy # só sobe quando o banco ACEITA conexões
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/tarefasdb
ports:
- "8080:8080"
Dois detalhes deste arquivo evitam erros clássicos:
dbcomo nome de host. Dentro da rede do Compose, os serviços se encontram pelo nome. A API conecta emdb:5432, não emlocalhost.localhostdentro de um container é o próprio container.depends_oncomcondition: service_healthy. Sem ohealthcheck, odepends_onsó espera o container iniciar, não o banco ficar pronto. A API sobe, tenta conectar e cai nos primeiros segundos.
⚖️ 5. Comparação: como organizar o código
| Critério | Polyrepo (um repo por app) | Monorepo com workspaces | Monorepo com Turborepo/Nx |
|---|---|---|---|
| Contrato compartilhado | Publicar pacote no npm a cada mudança | Import direto da pasta | Import direto da pasta |
| Mudança que atravessa front e back | Vários PRs coordenados | Um PR | Um PR |
| Tempo de CI com muitas apps | Cada repo roda só o seu | Roda tudo sempre | Roda só o que mudou (cache) |
| Permissões por equipe | Simples (um repo por equipe) | Exige CODEOWNERS |
Exige CODEOWNERS |
| Stacks diferentes (Java + TS) | Natural | Possível, sem ganho de tipos | Possível; o Nx tem plugins para várias linguagens |
| Quando escolher | Equipes e produtos independentes | Projeto pequeno ou médio, uma stack | Várias apps e pacotes, CI lento |
Não existe resposta universal. Para um produto com uma equipe e frontend e backend em TypeScript, o monorepo com tipagem compartilhada costuma valer a pena. Para serviços de equipes diferentes, em linguagens diferentes, o polyrepo com contrato OpenAPI é mais simples de manter.
⚠️ 6. Armadilhas comuns
| Sintoma | Causa | Correção |
|---|---|---|
| A API cai ao subir com “connection refused” | depends_on sem healthcheck: a API não espera o banco ficar pronto |
healthcheck no banco + condition: service_healthy |
A API não acha o banco em localhost:5432 |
Dentro do Compose, localhost é o próprio container |
Use o nome do serviço (db:5432) |
| O bundle do frontend ficou enorme | packages/contracts importou código de servidor (Prisma, fs) |
O pacote de contratos só pode ter schemas e tipos, nada de runtime de servidor |
| Erro “dependência circular” no build | packages/a importa packages/b, que importa packages/a |
Extrair a parte comum para um terceiro pacote |
| O cache do Turborepo devolve um build errado | A tarefa lê uma variável de ambiente que o cache desconhece | Declarar a variável em env da tarefa no turbo.json |
| Senha do banco no repositório | Credenciais escritas direto no docker-compose.yml |
Usar um arquivo .env fora do git e ${VARIAVEL} no Compose |
🗂️ 7. No Catálogo de Projetos
fullstack_tarefas_05_arquitetura: oinfra-docker/docker-compose.ymlsobe PostgreSQL 16 e a API Spring Boot com exatamente os dois cuidados da Seção 4:healthcheckcompg_isreadyedepends_oncomcondition: service_healthy. A API conecta emjdbc:postgresql://db:5432/tarefasdb, usando o nome do serviço.- A série Lista de Tarefas inteira mostra a ideia de fonte única da verdade sem monorepo: uma única API (a parte 01) atende todos os clientes das partes 02 a 10 (Svelte, Swing, Android, Vue, React, Angular, JavaFX, Compose). Cada cliente reimplementa o tipo
Tarefana sua linguagem, que é exatamente o custo que a tipagem compartilhada evita quando as stacks são iguais. - Lacuna do Catálogo: nenhum projeto ainda usa Turborepo com
packages/contracts. Os exemplos e exercícios deste capítulo cobrem essa parte.