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
Ligações
Apoia-se em
- 0002Framework de aplicação
- 0003Destino de deploy
- 0005Validação de entradas
- 0008ORM / camada de queries
- 0009Autenticação
- 0010Limitação de pedidos
- 0011Estilos
- 0012Obtenção de dados e estado
- 0013Formulários
- 0014Testes
- 0015Observabilidade
- 0016CI/CD
- 0017Reverse proxy e TLS
- 0019Listas virtuais
- 0020Gráficos
- 0021Markdown
- 0022Realce de sintaxe
- 0023Debounce e throttle
- 0025SDK de IA
- 0035Runtime e toolchain
- 0036Camada de API
Contexto
Seção intitulada “Contexto”ADRs anteriores já fixam partes da estrutura de uma app, cada um onde precisou:
src/start.tstem o middleware global de pedidos (ADR-0036, ADR-0010);src/router.tsxcria o router e o seuQueryClient(ADR-0012)src/components/ui/tem o shadcn/ui esrc/styles/app.csso tema (ADR-0011);src/components/form/,chart/,virtual/emarkdown/têm os nossos wrappers (ADR-0013, ADR-0020, ADR-0019, ADR-0021)src/lib/markdown.tsesrc/lib/highlight.tssã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.tsxe*.int.test.ts, com as specs end-to-end eme2e/(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
Opções consideradas
Seção intitulada “Opções consideradas”Forma do repositório
Seção intitulada “Forma do repositório”1. Um pacote por repositório
Seção intitulada “1. Um pacote por repositório”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.
2. Um workspace Bun desde o primeiro commit
Seção intitulada “2. Um workspace Bun desde o primeiro commit”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.
Organização do código dentro de uma app
Seção intitulada “Organização do código dentro de uma app”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.
C. Pastas por camada
Seção intitulada “C. Pastas por camada”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.
Manter o código de servidor fora do browser
Seção intitulada “Manter o código de servidor fora do browser”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.
ii. O sufixo mais a pasta src/server/
Seção intitulada “ii. O sufixo mais a pasta src/server/”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.
Ler as variáveis de ambiente
Seção intitulada “Ler as variáveis de ambiente”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.
b. T3 Env (@t3-oss/env-core 0.13.11)
Seção intitulada “b. T3 Env (@t3-oss/env-core 0.13.11)”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.
Mensagens de commit
Seção intitulada “Mensagens de commit”I. Livres
Seção intitulada “I. Livres”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.
Decisão
Seção intitulada “Decisão”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).
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
| Caminho | Para que serve | Definido 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.yml | Verifica 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.yml | Falha 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.ts | As 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.tsx | Teste 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.tsx | Um 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.tsx | Renderiza 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.tsx | Tal 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.ts | getInvoices, 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.ts | Lê 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.ts | O único highlighter e a sua lista explícita de linguagens. | 0022Realce de sintaxe |
apps/web/src/lib/markdown.ts | A política de links e imagens que todos os renders de Markdown partilham. | 0021Markdown |
apps/web/src/lib/utils.ts | O 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/$.ts | Server 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.tsx | A estrutura do documento: <html>, as tags do head, os providers e a error boundary. | Esta decisão |
apps/web/src/routes/_authed.tsx | Layout sem caminho: verifica a sessão uma vez para todas as rotas em _authed/. | Esta decisão |
apps/web/src/routes/index.tsx | A 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.ts | Teste unitário, junto do ficheiro que testa. | 0014Testes |
apps/web/src/schemas/invoices.ts | Os 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.ts | O fornecedor de modelos que este produto escolheu no seu próprio ADR. | 0025SDK de IA |
apps/web/src/server/ai/chat.ts | O 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.ts | Teste 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.ts | Os 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.ts | O 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.ts | As tabelas do Better Auth, escritas pelo auth generate. Regenerar, nunca editar. | 0009Autenticação |
apps/web/src/server/db/schema/index.ts | Reexporta todas as tabelas para o cliente e para o Drizzle Kit. | Esta decisão |
apps/web/src/server/db/schema/invoices.ts | A tabela invoices. Os nomes das tabelas são plurais, como o URL. | Esta decisão |
apps/web/src/server/db/client.ts | O 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.ts | Todas 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.ts | Teste de integração que chama o serviço diretamente. | 0014Testes |
apps/web/src/server/services/invoices.ts | As regras das faturas, partilhadas pelas server functions e pelos handlers da API. | Esta decisão |
apps/web/src/server/auth.ts | A instância do Better Auth: adaptador Drizzle, armazenamento secundário em Valkey e plugins. | 0009Autenticação |
apps/web/src/server/env.ts | Todas 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.ts | Helpers 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.ts | O 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.css | O ponto de entrada do Tailwind e os tokens do tema; a única folha de estilos global. | 0011Estilos |
apps/web/src/router.tsx | Cria o router e o seu QueryClient. | 0012Obtenção de dados e estado |
apps/web/src/routeTree.gen.ts | Escrito pelo plugin do router em cada dev e build. Entra no commit, nunca é editado. | Esta decisão |
apps/web/src/start.ts | Middleware 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/.env | Segredos e definições locais, carregados automaticamente pelo Bun. Ignorado pelo Git; parte-se do .env.example. | Esta decisão |
apps/web/.env.example | Todas as variáveis que o env.ts lê, com valores de exemplo seguros. Entra no commit. | Esta decisão |
apps/web/bunfig.toml | Definições do bun test: root = "src", para que o e2e/ nunca seja recolhido, e cobertura em lcov. | 0014Testes |
apps/web/components.json | As definições da CLI do shadcn/ui: o estilo Base UI e os aliases @/. | 0011Estilos |
apps/web/Dockerfile | Constró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.ts | Aponta o Drizzle Kit para src/server/db/schema/ e drizzle/. | 0008ORM / camada de queries |
apps/web/instrumentation.ts | Arranca 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.ts | Corre o e2e/ contra o build de produção, com Postgres e Valkey em contentores. | 0014Testes |
apps/web/tsconfig.json | Estrito, com o alias @/* para src/*. | Esta decisão |
apps/web/vite.config.ts | O 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 |
.gitignore | Ignora node_modules/, o output dos builds e os ficheiros .env. | Esta decisão |
.oxfmtrc.json | Definições de formatação para todos os pacotes. | 0035Runtime e toolchain |
.oxlintrc.json | Regras 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.md | Onde 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.lock | O único lockfile. Em texto, por isso é revisto nos diffs; o bun ci falha quando está desatualizado. | 0035Runtime e toolchain |
compose.yaml | Postgres e Valkey locais, com as mesmas tags de imagem da produção. | 0003Destino de deploy |
package.json | A 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óprioDockerfile, imagem epackage.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.jsoncompackageManager(a versão do Bun),workspaces.packagese o catálogo de versões emworkspaces.catalog, ebun.lock(ADR-0035);compose.yamlpara Postgres e Valkey locais (ADR-0003),.github/workflows/,docs/decisions/eAGENTS.md. O.oxlintrc.jsone o.oxfmtrc.jsonna 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 emoverrides, nunca uma configuração própria. Ovite.config.tsde cada app tem o seu build, e o seubunfig.tomlas 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 emserver/: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 handlersschemas/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 debunserver/,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.tsfica só para um ficheiro só de servidor que tenha de viver noutro lado, e.client.tspara 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 emfunctions/<domain>.tse um handler da API emserver/api/v1/<domain>.tsfazem 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>.tsmapeia métodos para os handlers emserver/api/v1/<domain>.ts;server/api/http.tsguarda o que todos os handlers partilham —readJson,readQuerye o tipoApiHandler.routes/api/auth/$.tsentrega/api/auth/*ao Better Auth (ADR-0009), e os webhooks vivem emroutes/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). Oclient.tsé o único ficheiro que se liga.schema/guarda um ficheiro de tabelas por domínio, mais oauth.ts, que a CLI do Better Auth escreve e ninguém edita;schema/index.tsreexporta-os todos, erelations.tsguarda a única chamada adefineRelations(). Odrizzle.config.tslêschema/e escreve as migrações emdrizzle/, na raiz da app -
As variáveis de ambiente são lidas uma só vez, com T3 Env.
server/env.tsdeclara todas as variáveis de servidor comcreateEnvde@t3-oss/env-core, fixado numa versão exata enquanto for pré-1.0, e exportaenv; nenhum outro ficheiro lê oprocess.env. Lê oprocess.env, não oimport.meta.env, porque o Vite embute oimport.meta.envno 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 paralib/env.ts, criado só quando existe algum, comclientPrefix: "VITE_"eruntimeEnv: 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.examplelista 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.tsxexportaInvoiceTable. Sem default exports, exceto onde uma ferramenta exige um, como num ficheiro de configuração - Os hooks são
use-<name>.tse exportamuse<Name> - Os nomes de domínio são plurais e correspondem ao URL:
/invoices,functions/invoices.ts, a tabelainvoices - Os sufixos ficam reservados para o que as ferramentas leem:
.test.tse.test.tsxcorrem combun run test,.int.test.tscombun run test:int(ADR-0014);.spec.tsmarca as specs Playwright eme2e/, fora da pastasrc/onde obun testprocura;.server.tse.client.tsativam a import protection;.gen.tsmarca um ficheiro gerado. Não há sufixos de papel como.service.ts: a pasta já diz o papel
- Ficheiros e pastas em kebab-case:
-
As rotas usam a sintaxe de ficheiros do router, em pastas:
invoices/$invoiceId.tsx, nãoinvoices.$invoiceId.tsx. Um layout sem caminho começa por_(_authed.tsxguarda 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. Emroutes/só vivem ficheiros de rota -
Os imports usam o alias
@/para tudo o que está fora da pasta atual (@/components/ui/button), tal como ocomponents.jsondo 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>, comofeat(invoices): export to CSV- Tipos:
featefixpara o que os utilizadores notam;perf,refactor,test,docs,style,build,ci,choreerevertpara 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 usadeps !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ítuloschore(deps): …oufix(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
maincom 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-titleon:pull_request:types: [opened, edited, synchronize, reopened]permissions:contents: readjobs:title:runs-on: ubuntu-lateststeps:- name: Title is a Conventional Commitenv: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-]+\))?!?: .+' - Tipos:
-
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.mdna 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.
Consequências
Seção intitulada “Consequências”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 mainlê-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:
workspacesnopackage.jsonda raiz,--filternos 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.

