Pular para o conteúdo

ADR-0026 Estrutura do projeto

Norma da empresa para a estrutura dos repositórios — um workspace Bun com cada app em apps/, pastas por camada dentro de cada app, código só de servidor vedado em src/server/, variáveis de ambiente lidas com T3 Env, um só esquema de nomes de ficheiros e pull requests com squash, intitulados como Conventional Commits.

Decisão
Workspace Bun; pastas por camada; src/server/ vedadoConstruir a app
Estado
Aceite
Data
Âmbito
Predefinição da empresa para todas as novas apps

ADRs anteriores já fixam partes da estrutura de uma app, cada um onde precisou:

  • src/start.ts tem o middleware global de pedidos (ADR-0036, ADR-0010); src/router.tsx cria o router e o seu QueryClient (ADR-0012)
  • src/components/ui/ tem o shadcn/ui e src/styles/app.css o tema (ADR-0011); src/components/form/, chart/, virtual/ e markdown/ têm os nossos wrappers (ADR-0013, ADR-0020, ADR-0019, ADR-0021)
  • src/lib/markdown.ts e src/lib/highlight.ts são utilitários seguros no cliente (ADR-0021, ADR-0022); src/server/ai/ é só de servidor (ADR-0025)
  • Os testes ficam junto do seu código como *.test.ts, *.test.tsx e *.int.test.ts, com as specs end-to-end em e2e/ (ADR-0014); os workflows vivem em .github/workflows/ (ADR-0016)

Nenhum desenha o todo. Cada repositório novo preenche as lacunas à sua maneira — onde ficam as server functions, onde vivem as tabelas, onde ficam as rotas da API, que nome tem um ficheiro, o que diz uma mensagem de commit — pelo que passar de um produto para outro obriga a aprender um mapa novo. Três forças moldam a resposta:

  • O TanStack Start constrói o mesmo código duas vezes, para o servidor e para o browser (ADR-0002). A sua import protection faz falhar o build quando um ficheiro *.server.* chega ao bundle do browser, e consegue vedar pastas inteiras da mesma forma. Uma estrutura que guarda o código só de servidor numa pasta transforma um cliente de base de dados exposto num erro de build
  • As regras de negócio têm de ser testáveis sem HTTP (ADR-0014): as server functions e os handlers das rotas da API apenas fazem parse, autorizam e chamam módulos simples. A estrutura precisa de um lugar para esses módulos
  • Uma só imagem corre em todos os ambientes (ADR-0016): o Nodus injeta a configuração como variáveis de ambiente em runtime, e a app valida-as com Zod (ADR-0005). Tudo o que o Vite embute no build é igual em staging e em produção

Prós: nada para configurar; os scripts, o Dockerfile e o lockfile ficam lado a lado. Contras: um segundo deployable (um worker, uma app de administração) ou um pacote partilhado obriga mais tarde a mover todos os ficheiros para apps/, numa só alteração grande e propensa a conflitos.

Prós: todos os repositórios têm a mesma forma, quer tenham uma app quer cinco; uma segunda app ou um pacote partilhado é uma pasta nova, não uma migração. O Bun corre scripts em todo o workspace (bun run --filter), e uma só configuração de lint e uma de formatação na raiz cobrem todos os pacotes (ADR-0035). Contras: um package.json na raiz e --filter nos scripts mesmo quando só há uma app; o contexto do build Docker passa a ser a raiz do repositório.

A. Pastas por funcionalidade (src/features/<domain>/)

Seção intitulada “A. Pastas por funcionalidade (src/features/<domain>/)”

Prós: os componentes, as server functions e as queries de um domínio ficam juntos; apagar uma funcionalidade é apagar uma pasta. Contras: o código partilhado continua a precisar de pastas por camada, várias delas fixadas por ADRs anteriores, pelo que cada repositório corre dois sistemas; cada ficheiro novo começa com uma discussão sobre a que funcionalidade pertence; os ficheiros só de servidor ficam espalhados, e cada um tem de levar o sufixo .server.ts para ficar protegido.

B. Código junto da sua rota (pastas -components/ dentro de src/routes/)

Seção intitulada “B. Código junto da sua rota (pastas -components/ dentro de src/routes/)”

Prós: uma página e o código que só ela usa são vizinhos. Contras: o código usado por duas rotas não tem casa; src/routes/ enche-se de pastas que não são rotas.

Prós: uma só regra para todos os ficheiros — a pasta diz o que o código faz; corresponde aos caminhos que os ADRs anteriores fixaram; todo o código só de servidor fica em src/server/, que uma só regra de import protection veda. Contras: um domínio espalha-se por várias pastas: components/invoices/, functions/invoices.ts, schemas/invoices.ts, server/services/invoices.ts e assim por diante.

i. Só o sufixo .server.ts (a predefinição do Start)

Seção intitulada “i. Só o sufixo .server.ts (a predefinição do Start)”

Prós: sem configuração. Contras: cada ficheiro só de servidor tem de se lembrar do sufixo; um que se esqueça vai para o bundle do browser sem aviso.

Prós: tudo o que está em src/server/ fica vedado pelo sítio onde vive; o sufixo continua a cobrir o raro ficheiro só de servidor que fique noutro lado. Contras: uma linha de configuração do plugin, que tem de repetir a predefinição do Start porque uma lista própria a substitui.

a. Um schema Zod à mão (schema.parse(process.env), como mostra o guia do Start)

Seção intitulada “a. Um schema Zod à mão (schema.parse(process.env), como mostra o guia do Start)”

Prós: sem dependência. Contras: cada app volta a resolver os mesmos detalhes: strings vazias nos ficheiros .env falham schemas numéricos e saltam valores por omissão, nada impede que um segredo seja lido em código do browser, e uma variável pública sem o prefixo VITE_ fica undefined no browser sem aviso.

Prós: uma camada fina sobre os nossos schemas Zod (Standard Schema) que resolve exatamente esses detalhes: emptyStringAsUndefined, um clientPrefix imposto nos tipos e em runtime, e um erro quando código do browser lê uma variável de servidor. O próprio gerador de apps do TanStack oferece-o como add-on de ambiente. Contras: pré-1.0, por isso fixado numa versão exata; mais uma dependência para o que seriam, de outra forma, uma dúzia de linhas.

Prós: nada para aprender nem verificar. Contras: o histórico em main não se pode percorrer nem filtrar por tipo de alteração; pessoas, agentes de código e o Renovate escrevem cada um no seu estilo, e nada assinala a alteração sobre a qual alguém tem de agir antes de um deploy.

II. Conventional Commits em todos os commits, verificados por um hook commit-msg

Seção intitulada “II. Conventional Commits em todos os commits, verificados por um hook commit-msg”

Prós: todos os commits de todos os branches têm tipo; uma mensagem errada é apanhada antes do push. Contras: o commitlint e um hook em cada repositório; git commit --no-verify salta-o; com squash, os commits do branch nunca chegam a main, pelo que a maior parte do que verifica é deitada fora.

III. Conventional Commits no título do pull request, com squash

Seção intitulada “III. Conventional Commits no título do pull request, com squash”

Prós: um commit com tipo por pull request em main; um só passo de CI, sem dependências, verifica-o, e ninguém o pode saltar; os commits do branch ficam livres para trabalho em curso. Contras: main guarda o commit do squash, não os passos de um branch; o merge tem de usar o título, o que no GitHub é uma definição do repositório.

Um workspace Bun com cada app em apps/, pastas por camada dentro de cada app e todo o código só de servidor em src/server/, vedado pela import protection. Esta é a norma da empresa para todas as novas apps. (Alterado em 2026-11-13: este ADR escolheu primeiro um workspace pnpm com o Vite+; o ADR-0035 passou a toolchain para o Bun.)

O exemplo abaixo é um produto chamado billing com um domínio, invoices (faturas).

Um repositório de produto novo

Selecione um ficheiro ou uma pasta para ver para que serve. Os ícones a cor marcam caminhos que uma decisão anterior já fixou.

  • billing
  • .github
  • workflows
  • ci.yml
  • pr-title.yml
  • apps
  • web
  • drizzle
  • e2e
  • sign-in.spec.ts
  • public
  • src
  • components
  • chart
  • form
  • invoices
  • invoice-table.test.tsx
  • invoice-table.tsx
  • markdown
  • markdown.tsx
  • ui
  • button.tsx
  • virtual
  • functions
  • invoices.ts
  • hooks
  • use-invoice-filters.ts
  • lib
  • highlight.ts
  • markdown.ts
  • utils.ts
  • routes
  • _authed
  • invoices
  • $invoiceId.tsx
  • index.tsx
  • api
  • auth
  • $.ts
  • v1
  • invoices.ts
  • __root.tsx
  • _authed.tsx
  • index.tsx
  • schemas
  • invoices.test.ts
  • invoices.ts
  • server
  • ai
  • adapter.ts
  • chat.ts
  • api
  • v1
  • invoices.int.test.ts
  • invoices.ts
  • http.ts
  • db
  • schema
  • auth.ts
  • index.ts
  • invoices.ts
  • client.ts
  • relations.ts
  • services
  • invoices.int.test.ts
  • invoices.ts
  • auth.ts
  • env.ts
  • telemetry.ts
  • valkey.ts
  • styles
  • app.css
  • router.tsx
  • routeTree.gen.ts
  • start.ts
  • .env
  • .env.example
  • bunfig.toml
  • components.json
  • Dockerfile
  • drizzle.config.ts
  • instrumentation.ts
  • package.json
  • playwright.config.ts
  • tsconfig.json
  • vite.config.ts
  • docs
  • decisions
  • packages
  • .gitignore
  • .oxfmtrc.json
  • .oxlintrc.json
  • AGENTS.md
  • bun.lock
  • compose.yaml
  • package.json

billing

./

Um repositório por produto, um workspace Bun desde o primeiro commit. Corre-se tudo daqui com bun; bun run --filter @billing/web <script> visa uma só app.

Definido por

Esta decisão

Ver como tabela
CaminhoPara que serveDefinido por
./Um repositório por produto, um workspace Bun desde o primeiro commit. Corre-se tudo daqui com bun; bun run --filter @billing/web <script> visa uma só app.Esta decisão
.github/Configuração do forge.Esta decisão
.github/workflows/O único conjunto de workflows portável, corrido tanto pelo GitHub Actions como pelo Forgejo Actions. Nunca adicionar .forgejo/workflows/.0016CI/CD
.github/workflows/ci.ymlVerifica e testa cada commit e depois constrói uma imagem imutável por app, que o Nodus faz deploy.0016CI/CD
.github/workflows/pr-title.ymlFalha um pull request cujo título não seja um Conventional Commit. O título torna-se o commit de squash em main.Esta decisão
apps/Uma pasta por cada coisa que se faz deploy. Cada app constrói a sua própria imagem Docker; uma segunda app, como um worker, fica ao lado de web/.Esta decisão
apps/web/A app TanStack Start: a UI, as server functions e as rotas da API, num só contentor. O seu package.json chama-se @billing/web.0002Framework de aplicação
apps/web/drizzle/Migrações escritas pelo drizzle-kit generate. Entram no commit e na revisão, nunca são editadas à mão; o CI e o deploy aplicam-nas.0008ORM / camada de queries
apps/web/e2e/Specs Playwright para cada percurso crítico, corridas contra o build de produção.0014Testes
apps/web/e2e/sign-in.spec.tsAs specs E2E terminam em .spec.ts e ficam fora de src/, a única pasta onde o bun test procura.0014Testes
apps/web/public/Ficheiros servidos tal como estão a partir da raiz do site, como favicon.ico e robots.txt. Nada que precise de build.Esta decisão
apps/web/src/O código da aplicação, em pastas por camada. A pasta diz o que um ficheiro faz; o nome do ficheiro diz a que domínio pertence.Esta decisão
apps/web/src/components/Componentes React. Os wrappers partilhados ficam nas suas próprias pastas; os componentes do produto ficam numa pasta por domínio.Esta decisão
apps/web/src/components/chart/Os nossos wrappers finos sobre o TanStack Charts, cada um com a sua tabela gémea.0020Gráficos
apps/web/src/components/form/Campos de formulário ligados ao TanStack Form e ao Field do shadcn/ui.0013Formulários
apps/web/src/components/invoices/Os componentes de um domínio, numa pasta com o seu nome.Esta decisão
apps/web/src/components/invoices/invoice-table.test.tsxTeste de componente, junto do componente, corrido pelo bun test em happy-dom. O foco, o teclado e o ponteiro são testados em e2e/.0014Testes
apps/web/src/components/invoices/invoice-table.tsxUm componente por ficheiro, com o seu nome: invoice-table.tsx exporta InvoiceTable. Só exports com nome.Esta decisão
apps/web/src/components/markdown/O único componente Markdown que todos os ecrãs usam.0021Markdown
apps/web/src/components/markdown/markdown.tsxRenderiza texto de modelos e de utilizadores com o HTML em bruto desligado.0021Markdown
apps/web/src/components/ui/Componentes shadcn/ui sobre Base UI, escritos pela CLI do shadcn e nossos para editar.0011Estilos
apps/web/src/components/ui/button.tsxTal como o shadcn add button o escreveu, depois editado à vontade.0011Estilos
apps/web/src/components/virtual/Wrappers sobre o TanStack Virtual para listas longas e grelhas grandes.0019Listas virtuais
apps/web/src/functions/Server functions (createServerFn), um ficheiro por domínio. Cada uma faz parse do input com um schema partilhado, verifica o acesso e chama um serviço; sem regras de negócio aqui.Esta decisão
apps/web/src/functions/invoices.tsgetInvoices, createInvoice e o resto do domínio. Pode ser importado pelos componentes: o browser recebe stubs RPC.Esta decisão
apps/web/src/hooks/Hooks React partilhados por vários componentes. use-<name>.ts exporta use<Name>.Esta decisão
apps/web/src/hooks/use-invoice-filters.tsLê e escreve os filtros da lista no URL.Esta decisão
apps/web/src/lib/Utilitários seguros no cliente, sem React e sem imports do servidor.Esta decisão
apps/web/src/lib/highlight.tsO único highlighter e a sua lista explícita de linguagens.0022Realce de sintaxe
apps/web/src/lib/markdown.tsA política de links e imagens que todos os renders de Markdown partilham.0021Markdown
apps/web/src/lib/utils.tsO cn() do shadcn/ui. Um utilitário usado por um só ficheiro fica nesse ficheiro.Esta decisão
apps/web/src/routes/Rotas por ficheiro. Um ficheiro de rota tem o seu loader, as suas guardas e a sua página, composta a partir de components/; nada mais vive aqui.Esta decisão
apps/web/src/routes/_authed/Páginas que exigem sessão iniciada. O prefixo _ não acrescenta segmento ao URL.Esta decisão
apps/web/src/routes/_authed/invoices//invoices e tudo o que está por baixo.Esta decisão
apps/web/src/routes/_authed/invoices/$invoiceId.tsx/invoices/:invoiceId. Os params são camelCase e dizem o que contêm: $invoiceId, não $id.Esta decisão
apps/web/src/routes/_authed/invoices/index.tsx/invoices, a lista. A paginação, a ordenação e os filtros vivem no URL.Esta decisão
apps/web/src/routes/api/Server routes para tudo o que é consumido por HTTP. Os ficheiros de rota só ligam; os handlers vivem em server/api/.0036Camada de API
apps/web/src/routes/api/auth/Os endpoints do Better Auth.Esta decisão
apps/web/src/routes/api/auth/$.tsServer route splat: passa cada pedido /api/auth/* ao auth.handler(request).0009Autenticação
apps/web/src/routes/api/v1/A API versionada. Quem a chama autentica-se com um token, nunca com o cookie de sessão.0036Camada de API
apps/web/src/routes/api/v1/invoices.ts/api/v1/invoices: liga GET e POST aos handlers em server/api/v1/invoices.ts.Esta decisão
apps/web/src/routes/__root.tsxA estrutura do documento: <html>, as tags do head, os providers e a error boundary.Esta decisão
apps/web/src/routes/_authed.tsxLayout sem caminho: verifica a sessão uma vez para todas as rotas em _authed/.Esta decisão
apps/web/src/routes/index.tsxA página inicial, /.Esta decisão
apps/web/src/schemas/Schemas Zod partilhados por formulários, server functions e rotas da API. Seguros no cliente.0005Validação de entradas
apps/web/src/schemas/invoices.test.tsTeste unitário, junto do ficheiro que testa.0014Testes
apps/web/src/schemas/invoices.tsOs schemas de input e de filtros do domínio, derivados das tabelas com drizzle-orm/zod onde as formas coincidem.Esta decisão
apps/web/src/server/Código só de servidor. A import protection faz falhar o build se algo daqui chegar ao bundle do browser.Esta decisão
apps/web/src/server/ai/O lado do servidor do AI SDK.0025SDK de IA
apps/web/src/server/ai/adapter.tsO fornecedor de modelos que este produto escolheu no seu próprio ADR.0025SDK de IA
apps/web/src/server/ai/chat.tsO handler do chat e as suas tools.0025SDK de IA
apps/web/src/server/api/Os handlers da API que os ficheiros de rota em routes/api/ chamam.0036Camada de API
apps/web/src/server/api/v1/Um ficheiro de handlers por recurso; a versão está no caminho.Esta decisão
apps/web/src/server/api/v1/invoices.int.test.tsTeste de integração que chama os handlers com um Request e um caller, contra Postgres e Valkey reais.0014Testes
apps/web/src/server/api/v1/invoices.tsOs handlers de /api/v1/invoices: validados com os schemas partilhados, a chamar o mesmo serviço que as server functions.Esta decisão
apps/web/src/server/api/http.tsO que todos os handlers partilham: readJson, readQuery e o tipo ApiHandler.Esta decisão
apps/web/src/server/db/Tudo o que fala com o Postgres.0008ORM / camada de queries
apps/web/src/server/db/schema/Tabelas Drizzle, um ficheiro por domínio. O drizzle.config.ts lê esta pasta.Esta decisão
apps/web/src/server/db/schema/auth.tsAs tabelas do Better Auth, escritas pelo auth generate. Regenerar, nunca editar.0009Autenticação
apps/web/src/server/db/schema/index.tsReexporta todas as tabelas para o cliente e para o Drizzle Kit.Esta decisão
apps/web/src/server/db/schema/invoices.tsA tabela invoices. Os nomes das tabelas são plurais, como o URL.Esta decisão
apps/web/src/server/db/client.tsO pool Bun.SQL, envolvido pelo traceSql, e o cliente Drizzle; o único ficheiro que se liga.0008ORM / camada de queries
apps/web/src/server/db/relations.tsTodas as relações numa só chamada a defineRelations(), para que os joins entre domínios tenham um só lugar.Esta decisão
apps/web/src/server/services/Regras de negócio, um ficheiro por domínio: funções simples que recebem as suas dependências (base de dados, relógio, clientes) como parâmetros.Esta decisão
apps/web/src/server/services/invoices.int.test.tsTeste de integração que chama o serviço diretamente.0014Testes
apps/web/src/server/services/invoices.tsAs regras das faturas, partilhadas pelas server functions e pelos handlers da API.Esta decisão
apps/web/src/server/auth.tsA instância do Better Auth: adaptador Drizzle, armazenamento secundário em Valkey e plugins.0009Autenticação
apps/web/src/server/env.tsTodas as variáveis de servidor, declaradas uma vez com o createEnv do T3 Env e lidas do process.env em runtime. Nenhum outro ficheiro lê o process.env.Esta decisão
apps/web/src/server/telemetry.tsHelpers de spans: trace e traceServerFn para o src/start.ts, traceSql para o cliente Postgres, e withSpan para as chamadas de armazenamento e email. Cada um sai quando o Bun ou o Start rastrearem esse passo sozinhos.0015Observabilidade
apps/web/src/server/valkey.tsO cliente ioredis partilhado pelos limites de pedidos e pelas sessões.0010Limitação de pedidos
apps/web/src/styles/Estilos globais.Esta decisão
apps/web/src/styles/app.cssO ponto de entrada do Tailwind e os tokens do tema; a única folha de estilos global.0011Estilos
apps/web/src/router.tsxCria o router e o seu QueryClient.0012Obtenção de dados e estado
apps/web/src/routeTree.gen.tsEscrito pelo plugin do router em cada dev e build. Entra no commit, nunca é editado.Esta decisão
apps/web/src/start.tsMiddleware global de pedidos, corrido antes de cada página, server function e chamada /api/*: o span e o ID do pedido, limites de pedidos, a verificação CSRF, sessões, e o caller da API para /api/v1/*; e um span para cada server function.0010Limitação de pedidos
apps/web/.envSegredos e definições locais, carregados automaticamente pelo Bun. Ignorado pelo Git; parte-se do .env.example.Esta decisão
apps/web/.env.exampleTodas as variáveis que o env.ts lê, com valores de exemplo seguros. Entra no commit.Esta decisão
apps/web/bunfig.tomlDefinições do bun test: root = "src", para que o e2e/ nunca seja recolhido, e cobertura em lcov.0014Testes
apps/web/components.jsonAs definições da CLI do shadcn/ui: o estilo Base UI e os aliases @/.0011Estilos
apps/web/DockerfileConstrói a imagem desta app: construída com Bun, corrida na imagem distroless do Bun com um utilizador sem privilégios de root. O contexto do build é a raiz do repositório, para que o lockfile esteja ao alcance.0016CI/CD
apps/web/drizzle.config.tsAponta o Drizzle Kit para src/server/db/schema/ e drizzle/.0008ORM / camada de queries
apps/web/instrumentation.tsArranca o OpenTelemetry e envolve o fetch. A imagem corre-o com bun --preload, antes do servidor, do worker e do migrator.0015Observabilidade
apps/web/package.json@billing/web, com os scripts dev, build, test, test:int e test:e2e.0035Runtime e toolchain
apps/web/playwright.config.tsCorre o e2e/ contra o build de produção, com Postgres e Valkey em contentores.0014Testes
apps/web/tsconfig.jsonEstrito, com o alias @/* para src/*.Esta decisão
apps/web/vite.config.tsO plugin do Start com a sua import protection, e o preset bun do Nitro.0035Runtime e toolchain
docs/Documentação do produto.Esta decisão
docs/decisions/Os ADRs do próprio produto, no formato deste site: desvios a estas predefinições e escolhas deixadas a cada produto, como o fornecedor de IA.Esta decisão
packages/Código importado por duas ou mais apps, uma pasta por pacote, com o nome @billing/<name>. Começa vazia: o código muda-se para cá quando uma segunda app precisa dele.Esta decisão
.gitignoreIgnora node_modules/, o output dos builds e os ficheiros .env.Esta decisão
.oxfmtrc.jsonDefinições de formatação para todos os pacotes.0035Runtime e toolchain
.oxlintrc.jsonRegras de lint para todos os pacotes. Um pacote que precise de regras diferentes recebe aqui uma entrada em overrides, nunca uma configuração própria.0035Runtime e toolchain
AGENTS.mdOnde ficam as coisas, que comandos correr e a stack, para pessoas e agentes de código. Copiado do modelo de AGENTS.md do site.Esta decisão
bun.lockO único lockfile. Em texto, por isso é revisto nos diffs; o bun ci falha quando está desatualizado.0035Runtime e toolchain
compose.yamlPostgres e Valkey locais, com as mesmas tags de imagem da produção.0003Destino de deploy
package.jsonA raiz do workspace: privada, sem código de app. O packageManager fixa o Bun; o workspaces lista apps/* e packages/* e guarda o catálogo de versões.0035Runtime e toolchain

Em concreto:

  • Um repositório por produto, um workspace Bun desde o primeiro commit. apps/<name>/ guarda cada deployable — a app TanStack Start é apps/web/ — com o seu próprio Dockerfile, imagem e package.json, com o nome @<product>/<name>. packages/<name>/ guarda código que duas ou mais apps importam e começa vazia: o código muda-se para lá quando uma segunda app precisa dele, não antes

  • A raiz guarda o workspace, não código de app: um package.json com packageManager (a versão do Bun), workspaces.packages e o catálogo de versões em workspaces.catalog, e bun.lock (ADR-0035); compose.yaml para Postgres e Valkey locais (ADR-0003), .github/workflows/, docs/decisions/ e AGENTS.md. O .oxlintrc.json e o .oxfmtrc.json na raiz têm as regras de lint e formatação de todos os pacotes; um pacote que precise de regras de lint diferentes recebe lá uma entrada em overrides, nunca uma configuração própria. O vite.config.ts de cada app tem o seu build, e o seu bunfig.toml as suas definições de testes (ADR-0014)

  • Dentro de uma app, as pastas são camadas. A pasta diz o que um ficheiro faz; o nome do ficheiro diz a que domínio pertence. Os imports descem esta tabela, e só os handlers das server functions e os ficheiros das rotas da API em routes/api/ entram em server/:

    Pasta Guarda Importa de
    routes/ Ficheiros de rota: loader, guardas e a página, composta a partir de components/ components/, hooks/, functions/, schemas/, lib/
    components/ Wrappers partilhados nas suas próprias pastas; componentes do produto em components/<domain>/ components/, hooks/, functions/, schemas/, lib/
    hooks/ Hooks React partilhados por vários componentes functions/, schemas/, lib/
    functions/ Server functions, um ficheiro por domínio schemas/, lib/; server/ só dentro dos handlers
    schemas/ Schemas Zod partilhados por formulários, server functions e rotas da API (ADR-0005) lib/
    lib/ Utilitários seguros no cliente, sem React e sem imports do servidor lib/
    server/ Código só de servidor: db/, services/, api/, ai/, jobs/, mail/, storage/, auth.ts, env.ts, telemetry.ts, valkey.ts; a única pasta que importa de bun server/, schemas/, lib/
  • src/server/ está vedado. O build do browser recusa a pasta inteira, além do sufixo *.server.*, pelo que um import só de servidor que chegue ao bundle do browser faz falhar o build, e avisa em dev:

    apps/web/vite.config.ts
    tanstackStart({
    importProtection: {
    // Uma lista própria substitui a predefinição do Start, por isso a regra do sufixo é repetida.
    client: { files: ["**/*.server.*", "**/server/**"] },
    },
    }),

    O sufixo .server.ts fica só para um ficheiro só de servidor que tenha de viver noutro lado, e .client.ts para código só de browser que o servidor nunca pode carregar

  • Os handlers mantêm-se finos (ADR-0014). As regras de negócio vivem em server/services/<domain>.ts, como funções simples que recebem as suas dependências como parâmetros. Uma server function em functions/<domain>.ts e um handler da API em server/api/v1/<domain>.ts fazem parse do input com o schema partilhado, verificam o acesso e chamam o serviço

  • As rotas da API são server routes que só fazem a ligação (ADR-0036). routes/api/v1/<domain>.ts mapeia métodos para os handlers em server/api/v1/<domain>.ts; server/api/http.ts guarda o que todos os handlers partilham — readJson, readQuery e o tipo ApiHandler. routes/api/auth/$.ts entrega /api/auth/* ao Better Auth (ADR-0009), e os webhooks vivem em routes/api/webhooks/. O middleware global de pedidos do Start — primeiro o rate limiting (ADR-0010) — corre antes de todos eles:

    apps/web/src/routes/api/v1/invoices.ts
    import { createFileRoute } from "@tanstack/react-router";
    import { createInvoice, listInvoices } from "@/server/api/v1/invoices";
    export const Route = createFileRoute("/api/v1/invoices")({
    server: { handlers: { GET: listInvoices, POST: createInvoice } },
    });
  • A base de dados vive em server/db/ (ADR-0008). O client.ts é o único ficheiro que se liga. schema/ guarda um ficheiro de tabelas por domínio, mais o auth.ts, que a CLI do Better Auth escreve e ninguém edita; schema/index.ts reexporta-os todos, e relations.ts guarda a única chamada a defineRelations(). O drizzle.config.ts lê schema/ e escreve as migrações em drizzle/, na raiz da app

  • As variáveis de ambiente são lidas uma só vez, com T3 Env. server/env.ts declara todas as variáveis de servidor com createEnv de @t3-oss/env-core, fixado numa versão exata enquanto for pré-1.0, e exporta env; nenhum outro ficheiro lê o process.env. Lê o process.env, não o import.meta.env, porque o Vite embute o import.meta.env no build e uma só imagem corre em todo o lado (ADR-0016):

    apps/web/src/server/env.ts
    import { createEnv } from "@t3-oss/env-core";
    import * as z from "zod";
    export const env = createEnv({
    server: {
    APP_ORIGIN: z.url(),
    CLIENT_IP_HEADER: z.string().min(1),
    DATABASE_URL: z.url(),
    VALKEY_URL: z.url(),
    BETTER_AUTH_SECRET: z.string().min(32),
    },
    runtimeEnv: process.env,
    emptyStringAsUndefined: true,
    });

    Uma configuração errada faz falhar o primeiro pedido que carrega o env. A sonda /readyz é esse pedido, porque chega ao Postgres e ao Valkey (ADR-0015), pelo que a instância nunca recebe tráfego (ADR-0017). Os valores públicos de que o browser precisa vão para lib/env.ts, criado só quando existe algum, com clientPrefix: "VITE_" e runtimeEnv: import.meta.env; ficam embutidos na imagem, por isso nada que mude entre ambientes pertence ali — esses valores chegam do servidor através de um loader. O .env.example lista todas as variáveis e entra no commit; o .env é ignorado pelo Git

  • Nomes:

    • Ficheiros e pastas em kebab-case: invoice-table.tsx, use-invoice-filters.ts. A CLI do shadcn/ui escreve da mesma forma
    • Um componente por ficheiro, com o seu nome, como export com nome: invoice-table.tsx exporta InvoiceTable. Sem default exports, exceto onde uma ferramenta exige um, como num ficheiro de configuração
    • Os hooks são use-<name>.ts e exportam use<Name>
    • Os nomes de domínio são plurais e correspondem ao URL: /invoices, functions/invoices.ts, a tabela invoices
    • Os sufixos ficam reservados para o que as ferramentas leem: .test.ts e .test.tsx correm com bun run test, .int.test.ts com bun run test:int (ADR-0014); .spec.ts marca as specs Playwright em e2e/, fora da pasta src/ onde o bun test procura; .server.ts e .client.ts ativam a import protection; .gen.ts marca um ficheiro gerado. Não há sufixos de papel como .service.ts: a pasta já diz o papel
  • As rotas usam a sintaxe de ficheiros do router, em pastas: invoices/$invoiceId.tsx, não invoices.$invoiceId.tsx. Um layout sem caminho começa por _ (_authed.tsx guarda todas as rotas em _authed/), uma pasta (group)/ organiza rotas sem acrescentar um segmento ao URL, e os params são camelCase e dizem o que contêm: $invoiceId, não $id. Em routes/ só vivem ficheiros de rota

  • Os imports usam o alias @/ para tudo o que está fora da pasta atual (@/components/ui/button), tal como o components.json do shadcn/ui o configura; os imports relativos ficam dentro de uma pasta

  • Os pull requests entram com squash, e o título é uma mensagem Conventional Commits 1.0.0, porque se torna o único commit em main: <type>(<scope>)!: <summary>, como feat(invoices): export to CSV

    • Tipos: feat e fix para o que os utilizadores notam; perf, refactor, test, docs, style, build, ci, chore e revert para o resto. É o conjunto convencional do commitlint, que pessoas e agentes já conhecem
    • O scope é opcional: um domínio (invoices) ou uma app ou pacote (web). O Renovate usa deps
    • ! assinala uma alteração sobre a qual alguém de fora do pull request tem de agir: uma rota da API pública removida ou alterada (ADR-0036), uma nova variável de ambiente obrigatória que o Nodus tem de definir antes do deploy, ou o passo de contração de uma migração (ADR-0016)
    • Os commits num branch são livres; o squash substitui-os
    • O squash é o único estilo de merge que o repositório permite. No GitHub, a mensagem do commit de squash fica definida para usar por omissão o título do pull request; o Forgejo já usa o título. O Renovate fica com semanticCommits: "enabled" em vez de deixado a detetar a convenção, para que os seus pull requests tenham títulos chore(deps): … ou fix(deps): … desde o primeiro
    • Um só passo de workflow verifica o título. Volta a correr quando o título é editado, é uma verificação obrigatória em main com as outras (ADR-0016) e lê o título de uma variável de ambiente, porque um título é input não confiável e nunca pode ser expandido dentro do script:
    .github/workflows/pr-title.yml
    name: pr-title
    on:
    pull_request:
    types: [opened, edited, synchronize, reopened]
    permissions:
    contents: read
    jobs:
    title:
    runs-on: ubuntu-latest
    steps:
    - name: Title is a Conventional Commit
    env:
    TITLE: ${{ github.event.pull_request.title }}
    run: printf '%s\n' "$TITLE" | grep -Eq '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .+'
  • As decisões do produto vivem no produto: docs/decisions/NNNN-short-slug.md, no formato de ADR deste site. Um desvio a estas predefinições, ou uma escolha que um ADR deixa a cada produto, é escrito lá

  • AGENTS.md na raiz, copiado do modelo de AGENTS.md, indica os comandos, a stack e estas regras, para que pessoas e agentes de código partam do mesmo mapa

Os fatores decisivos: um só mapa para todos os produtos, os caminhos que os ADRs anteriores já escolheram e uma fronteira de servidor que o build impõe em vez de uma que a revisão tem de detetar. Aceitamos um domínio espalhado por várias pastas e o custo de um workspace em repositórios que só alguma vez terão uma app.

Mais fácil:

  • Passar de um produto para outro: as server functions, as tabelas, as rotas da API e os testes estão no mesmo sítio em todos os repositórios
  • Um import só de servidor que chegue ao browser faz falhar o build, em vez de enviar um cliente de base de dados ou um segredo
  • Um segundo deployable ou um pacote partilhado é uma pasta nova, não uma reestruturação
  • Uma variável de ambiente em falta ou mal formada trava uma instância na verificação de prontidão, com todos os problemas listados, em vez de falhar a meio de um pedido
  • Os agentes de código seguem as mesmas regras que as pessoas, escritas uma vez em AGENTS.md
  • main lê-se como um registo de alterações: uma linha com tipo por pull request, com ! no que tem de ser tratado antes de um deploy

Mais difícil:

  • Um domínio toca várias pastas, pelo que adicionar ou remover uma funcionalidade obriga a visitar cada uma. Procurar pelo nome do domínio encontra-as todas, porque todos os ficheiros o levam
  • O custo do workspace em repositórios com uma só app: workspaces no package.json da raiz, --filter nos scripts da raiz e um Dockerfile cujo contexto de build é a raiz do repositório
  • A lista da import protection repete a predefinição do Start; uma atualização do Start que mude essa predefinição tem de ser verificada contra ela
  • O T3 Env é pré-1.0: fixado numa versão exata e atualizado de forma deliberada, como as bibliotecas do ADR-0023 ao ADR-0025
  • Um pull request tem um só tipo, por isso alterações sem relação vão em pull requests separados; os commits individuais de um branch não ficam em main

Do que abdicamos: da proximidade das pastas por funcionalidade e da simplicidade de um só pacote. Aceite — o mesmo mapa em todos os repositórios, e uma fronteira de servidor que o build impõe, valem mais algumas pastas para abrir.