🗺️ 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:

  1. Contratos que divergem em silêncio. O backend renomeia nome para titulo, os testes do backend passam, e o frontend só descobre em produção, quando a tela mostra undefined.
  2. 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”.
  3. 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/api depende de packages/contracts e 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-typescript gera 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:

  • db como nome de host. Dentro da rede do Compose, os serviços se encontram pelo nome. A API conecta em db:5432, não em localhost. localhost dentro de um container é o próprio container.
  • depends_on com condition: service_healthy. Sem o healthcheck, o depends_on só 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: o infra-docker/docker-compose.yml sobe PostgreSQL 16 e a API Spring Boot com exatamente os dois cuidados da Seção 4: healthcheck com pg_isready e depends_on com condition: service_healthy. A API conecta em jdbc: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 Tarefa na 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.