Case study

Anatomy of a full-stack platform.

What stands out in the engineering of a real front end and back end: API-first, layered caching, defense in depth and tests that check performance.

A forma do sistemaThe shape of the system

Esta nota analisa um projeto real dividido em front end e back end, sem citar o nome. O produto é uma plataforma de listagem: perfis com fotos e vídeos, planos pagos que definem a posição na lista, moderação e painel do dono do perfil. O que interessa aqui não é o produto, e sim as decisões de engenharia.This note analyzes a real project split into a front end and a back end, without naming it. The product is a listing platform: profiles with photos and videos, paid plans that set the position in the list, moderation and an owner panel. What matters here is not the product, but the engineering decisions.

CamadaLayerEscolhasChoices
Back endBack endJava 25 (virtual threads), Spring Boot 4.1, Spring Security 7, Hibernate 7, PostgreSQL 18, Maven
Front endFront endNext.js 16 (App Router, ISR, saída standalone), React 19, TypeScript 6 estrito, Tailwind 4, Zustand, zod
StorageStorageCloudflare R2 pela API S3, em dois buckets (público e privado)Cloudflare R2 through the S3 API, in two buckets (public and private)
QualidadeQualityJUnit + Testcontainers, Vitest, Playwright + axe, Lighthouse CI, GitHub Actions
NavegadorBrowserFront endNext.js: SSR, ISR e BFFNext.js: SSR, ISR and BFFAPISpring Boot, RESTPostgreSQLnavegador chama a API direto (dados públicos e do usuário)the browser calls the API directly (public and user data)Storage + CDNupload e download de arquivos direto no storage, por URL pré-assinadafile upload and download straight to storage, via presigned URL
Duas aplicações separadas. O arquivo pesado nunca passa pela API.Two separate applications. Heavy files never go through the API.

Duas decisões de forma já dizem muito: o back end é um monólito (um único serviço, simples de operar e de testar), e as duas aplicações vivem em repositórios e contêineres separados, ligados por uma rede Docker. Elas precisam estar no mesmo site (por exemplo, app.exemplo.com e api.exemplo.com) para que os cookies funcionem; isso está documentado como requisito de implantação.Two decisions about shape already say a lot: the back end is a monolith (a single service, simple to run and test), and the two applications live in separate repositories and containers, joined by a Docker network. They must be on the same site (for example app.example.com and api.example.com) for cookies to work; that is documented as a deployment requirement.

API-first de verdadeGenuinely API-first

“API-first” aqui não é slogan. Toda ação do usuário é um endpoint, e o front end só renderiza o que a API devolve. Isso aparece em detalhes concretos:“API-first” here is not a slogan. Every user action is an endpoint, and the front end only renders what the API returns. It shows in concrete details:

  • Contrato gerado. Os tipos TypeScript do front vêm do documento OpenAPI do back end (openapi-typescript). Se o contrato muda, o compilador do front acusa.Generated contract. The front end’s TypeScript types come from the back end’s OpenAPI document (openapi-typescript). If the contract changes, the front end’s compiler complains.
  • Poucos endpoints, feitos sob medida. Uma tela que mistura várias tabelas ganha um endpoint de leitura com a forma exata do que será exibido (por exemplo, um “overview” da pessoa logada: conta, perfil, assinatura e avatar numa só chamada). Menos viagens de rede, menos lógica no cliente.Few endpoints, tailor-made. A screen that mixes several tables gets one read endpoint with the exact shape to display (for example an “overview” of the logged-in person: account, profile, subscription and avatar in one call). Fewer round trips, less client logic.
  • O front não decide regras. A página de planos habilita ou desabilita botões conforme campos que a API calcula (canPurchasePaidPlan, status de verificação). Nem a ordem da listagem é refeita no cliente.The front end does not decide rules. The plans page enables or disables buttons from fields the API computes (canPurchasePaidPlan, verification status). Not even the listing order is redone on the client.
  • DTOs na fronteira. Controllers recebem e devolvem records imutáveis; entidades JPA nunca saem. Respostas são montadas por uma fábrica estática from(entity).DTOs at the boundary. Controllers take and return immutable records; JPA entities never leave. Responses are built by a static from(entity) factory.
  • Um só modelo de erro. Todo erro tem o mesmo JSON (timestamp, status, error, message, path, fields), e fields mapeia campo para mensagem. No front, uma única classe de erro espelha esse corpo.One error model. Every error has the same JSON (timestamp, status, error, message, path, fields), and fields maps field to message. On the front end, a single error class mirrors that body.
  • Convenções fixas: /api/v1, IDs UUID v7 (ordenáveis por tempo e sem sequência exposta), horários em UTC, PATCH parcial em que null significa “não mudou”, operações repetíveis tratadas como idempotentes.Fixed conventions: /api/v1, UUID v7 IDs (time-ordered, with no exposed sequence), UTC timestamps, partial PATCH where null means “unchanged”, retryable operations treated as idempotent.
  • A documentação é parte do código. Há uma especificação do produto, um API_DESIGN.md e um HEADERS.md, e a regra é atualizá-los na mesma mudança que altera o comportamento.Documentation is part of the code. There is a product spec, an API_DESIGN.md and a HEADERS.md, and the rule is to update them in the same change that alters behavior.
Uma sutileza de segurança no contrato: login, cadastro e recuperação de senha devolvem respostas genéricas, que não revelam se um e-mail ou usuário existe. Isso evita a enumeração de contas.A security subtlety in the contract: login, signup and password recovery return generic answers that never reveal whether an email or username exists. That prevents account enumeration.

Cache em camadasLayered caching

O cache é o ponto mais notável do projeto: são cinco camadas, cada uma com prazo e regra de invalidação próprios, e uma lista explícita do que não entra em cache.Caching is the project’s most notable aspect: five layers, each with its own lifetime and invalidation rule, and an explicit list of what is not cached.

NavegadorBrowserassets com hash; /api/listing: 30 s + stale-while-revalidatehashed assets; /api/listing: 30 s + stale-while-revalidatePáginas (ISR)Pages (ISR)HTML regenerado a cada 60 sHTML regenerated every 60 sCache de dados do NextNext data cacherespostas da API com tags: 60 s a 10 minAPI responses with tags: 60 s to 10 minAPI (Caffeine)lista de cards 60 s; planos 10 min; limpos por eventocard list 60 s; plans 10 min; cleared by eventsBancoDatabaseuma consulta por recorte por janela de tempoone query per slice per time window
Cada camada tem seu prazo. Quanto mais perto do usuário, mais barata a resposta; quanto mais perto do banco, mais fresca.Each layer has its own lifetime. The closer to the user, the cheaper the answer; the closer to the database, the fresher.

No back end (Caffeine)On the back end (Caffeine)

  • Três caches em memória, cada um com tempo de vida e tamanho máximo: a lista de cards da listagem (1 minuto), o catálogo de planos e os planos públicos (10 minutos como rede de segurança).Three in-memory caches, each with a lifetime and a maximum size: the listing’s card list (1 minute), the plan catalog and the public plans (10 minutes as a safety net).
  • Invalidação por evento: um pagamento, uma aprovação, rejeição ou suspensão, uma exclusão de conta ou uma ação do admin limpam o cache na hora. O prazo é só o plano B.Event-driven invalidation: a payment, an approval, rejection or suspension, an account deletion or an admin action clears the cache right away. The lifetime is only plan B.
  • A chave guarda só o recorte (país, estado ou cidade), com a lista sem embaralhar. A semente do visitante, os filtros e a página são aplicados em memória, a cada requisição. Assim, resultados de visitantes ou filtros diferentes nunca se misturam.The key holds only the slice (country, state or city), with the list unshuffled. The visitor’s seed, the filters and the page are applied in memory on each request. That way results for different visitors or filters never mix.
  • Orçamento de memória calculado: cerca de 0,7 a 1 KB por card, então ~20 a 30 MB para 30 mil anunciantes, reconstruídos uma vez por minuto. O documento ainda diz o que fazer se crescer (guardar um índice de IDs e um mapa de cards).Memory budget worked out: about 0.7 to 1 KB per card, so ~20 to 30 MB for 30 thousand advertisers, rebuilt once a minute. The document even says what to do if it grows (keep an ID index and a card map).

O que de propósito não é cacheadoWhat is deliberately not cached

  • Perfil público e mídias: há muitos caminhos de escrita a invalidar e as URLs assinadas expiram.Public profile and media: there are many write paths to invalidate and signed URLs expire.
  • Disponibilidade de nome de usuário (precisa ser exata) e a checagem de admin (revogar acesso vale na hora).Username availability (must be exact) and the admin check (revoking access takes effect immediately).

No front end (Next.js)On the front end (Next.js)

  • ISR: a home e as páginas de cidade e de perfil são geradas e reaproveitadas por 60 s. Como a página não lê cookies nem parâmetros no servidor, um HTML serve todos os visitantes.ISR: the home page and the city and profile pages are generated and reused for 60 s. Since the page reads no cookies or parameters on the server, one HTML serves every visitor.
  • Cache de dados com tags (listing, profile:usuario, plans…): quando o dono troca as fotos, uma rota autenticada, de mesma origem, expira só as tags daquele perfil.Data cache with tags (listing, profile:username, plans…): when the owner changes photos, an authenticated same-origin route expires only that profile’s tags.
  • Filtros na URL, mas fora do servidor: o HTML em cache é sempre a primeira página sem filtro; o navegador carrega as páginas filtradas por um endpoint com Cache-Control: public, max-age=30, stale-while-revalidate=30. Os rastreadores veem páginas estáveis.Filters in the URL, but off the server: the cached HTML is always the first unfiltered page; the browser loads filtered pages through an endpoint with Cache-Control: public, max-age=30, stale-while-revalidate=30. Crawlers see stable pages.

Repare no padrão de maturidade: cada cache documenta o que guarda, por quanto tempo, quem o limpa e quando revisitar (por exemplo, “revisitar quando as mídias passarem a usar URLs de CDN”).Note the maturity pattern: every cache documents what it holds, for how long, who clears it and when to revisit (for example “revisit when media moves to CDN URLs”).

Uma listagem justa e estávelA fair, stable listing

A ordem da lista segue uma regra única, em um componente só (ListingOrder): primeiro os perfis verificados com plano pago (do plano mais alto ao mais baixo), depois os verificados gratuitos, por fim os não verificados. Dentro de cada nível, quem informa preço vem antes, e o restante é embaralhado.The list order follows one rule, in a single component (ListingOrder): first verified profiles on a paid plan (highest plan first), then verified free ones, then unverified. Within each level, those who state a price come first, and the rest is shuffled.

O truque está na semente do embaralhamento: cookie anônimo do visitante + dia. O mesmo visitante vê a mesma ordem o dia todo, então a paginação nunca repete nem pula um item; no dia seguinte a ordem muda e todos têm sua vez de aparecer. Robôs recebem uma ordem padrão do dia.The trick is the shuffle seed: the visitor’s anonymous cookie + the day. The same visitor sees the same order all day, so pagination never repeats or skips an item; the next day the order changes and everyone gets a turn. Bots get a default order for the day.

Eficiência: uma consulta SQL por recorte carrega todos os cards com plano, capa e contagens (por subconsultas), e as URLs assinadas das capas são geradas somente para os cards da página pedida.Efficiency: one SQL query per slice loads every card with plan, cover and counts (through subqueries), and the signed cover URLs are generated only for the cards of the requested page.

As regras de quem pode aparecer (e-mail confirmado, conta ativa, não moderado, bio, cidade, fotos mínimas, atividade recente) vivem na API, e um endpoint devolve a lista do que falta para o dono ver o checklist. O front só desenha.The rules for who may appear (confirmed email, active account, not moderated, bio, city, minimum photos, recent activity) live in the API, and an endpoint returns what is missing so the owner sees the checklist. The front end only draws it.

Segurança em profundidadeDefense in depth

Sessão e autenticaçãoSession and authentication

  • Dois tokens com papéis opostos. Token de acesso (JWT, 15 minutos) só na memória da aba, nunca em localStorage. Token de renovação opaco, em cookie HttpOnly + SameSite=Strict, que o JavaScript não enxerga.Two tokens with opposite roles. An access token (JWT, 15 minutes) only in tab memory, never in localStorage. An opaque refresh token in an HttpOnly + SameSite=Strict cookie that JavaScript cannot see.
  • Rotação e detecção de roubo. Cada renovação gera um token novo. Apresentar um token já usado revoga a família inteira de sessões. O servidor guarda só o hash SHA-256 do token.Rotation and theft detection. Each refresh issues a new token. Presenting an already-used token revokes the whole family of sessions. The server stores only the token’s SHA-256 hash.
  • Senhas com Argon2id. Resistente a hardware de força bruta.Passwords with Argon2id. Resistant to brute-force hardware.
  • BFF no Next. O servidor do front guarda o cookie de sessão no próprio domínio e repassa as chamadas de autenticação, com checagem de Origin contra CSRF e repasse do IP real do cliente.BFF in Next. The front-end server keeps the session cookie on its own domain and forwards authentication calls, with an Origin check against CSRF and forwarding of the real client IP.
  • Renovação sem corrida no cliente. Várias chamadas simultâneas que precisam de um token novo compartilham uma única requisição de renovação, e uma chamada que recebe 401 tenta de novo uma vez.No refresh races on the client. Several simultaneous calls needing a new token share one single refresh request, and a call that gets a 401 retries once.
NavegadorBrowserNext (BFF)API1. login: usuário e senha1. login: user and password2. repassa à API2. forwards to the API3. token de acesso + refresh opaco3. access token + opaque refresh4. token em memória + cookie HttpOnly4. token in memory + HttpOnly cookie5. chamadas com Authorization: Bearer5. calls with Authorization: Bearer
O refresh token nunca é visível ao JavaScript; o token de acesso vive só na memória da aba e dura 15 minutos.The refresh token is never visible to JavaScript; the access token lives only in tab memory and lasts 15 minutes.

AutorizaçãoAuthorization

  • Admin é uma linha no banco, não um papel no token, e é checado a cada requisição: revogar vale imediatamente.Admin is a database row, not a role in the token, and it is checked on every request: revoking takes effect at once.
  • Posse em um lugar só: toda escrita nos dados de um perfil passa por ProviderOwnership (404 se não existe, 403 se é de outra conta).Ownership in a single place: every write to a profile’s data goes through ProviderOwnership (404 if it does not exist, 403 if it belongs to another account).

Proteção na porta de entradaProtection at the front door

Um filtro, registrado antes do Spring Security, protege toda /api/**. Rejeitar cedo não custa checar token nem tocar no banco. Ordem: cabeçalhos de segurança → tamanho do corpo (64 KB) → limite geral por segundo e por minuto (por conta quando logado, senão por IP) → limite da rota sensível (login, cadastro, refresh…).A filter, registered before Spring Security, protects all of /api/**. Rejecting early costs no token check and no database work. Order: security headers → body size (64 KB) → general limit per second and per minute (per account when logged in, otherwise per IP) → the sensitive route’s limit (login, signup, refresh…).

  • Contar por conta evita punir quem divide o mesmo IP (rede móvel, escritório).Counting per account avoids punishing people who share an IP (mobile network, office).
  • O IP real só é lido de um cabeçalho se a conexão vem de um proxy confiável (lista de CIDRs); senão, não dá para forjar.The real IP is read from a header only if the connection comes from a trusted proxy (CIDR list); otherwise it can’t be forged.
  • Bloqueio por identificador: 10 senhas erradas para um mesmo login, vindas de qualquer IP, bloqueiam novas tentativas por 15 minutos, inclusive para logins que não existem (a resposta não revela quais existem).Lockout per identifier: 10 wrong passwords for one login, from any IP, block further attempts for 15 minutes, including for logins that do not exist (the answer never reveals which exist).
  • Os limiters são mapas Caffeine com tamanho máximo: uma enxurrada de IPs novos expulsa os mais antigos em vez de esgotar a memória.The limiters are Caffeine maps with a maximum size: a flood of new IPs evicts the oldest instead of exhausting memory.
  • Cabeçalhos: CSP restritiva na API, nosniff, frame proibido; no front, CSP com origens exatas (API, CDN, storage), Permissions-Policy; a API soma HSTS em HTTPS.Headers: a restrictive CSP on the API, nosniff, framing denied; on the front end, a CSP with exact origins (API, CDN, storage), Permissions-Policy; the API adds HSTS over HTTPS.
Os próprios documentos admitem os limites, o que é um sinal de maturidade: contadores em memória valem por servidor (com N servidores, o limite efetivo é até N vezes maior), janelas fixas permitem até o dobro na virada, e o bloqueio por login pode ser usado para travar uma conta alheia. Cada ponto vem com o plano de evolução (Redis, captcha).The documents themselves admit the limits, which is a sign of maturity: in-memory counters are per server (with N servers the effective limit is up to N times higher), fixed windows allow up to double at the boundary, and the per-login lockout can be used to lock someone else’s account. Each point comes with an evolution plan (Redis, captcha).

Arquivos fora do caminho da APIFiles off the API’s path

Fotos e vídeos nunca passam pelo servidor da API. O fluxo é de três passos: pedir uma URL pré-assinada (de 1 minuto), enviar o arquivo com PUT direto ao storage e confirmar à API, que checa se o arquivo realmente existe e se a chave está na pasta daquele perfil.Photos and videos never go through the API server. The flow has three steps: ask for a presigned URL (1 minute), send the file with PUT straight to storage and confirm to the API, which checks that the file really exists and that the key is in that profile’s folder.

NavegadorBrowserAPIStorage1. pede uma URL de upload1. asks for an upload URLURL assinada (1 min) + chavesigned URL (1 min) + key2. PUT do arquivo direto no storage2. PUT the file straight to storage3. confirma a chave enviada3. confirms the uploaded keya API confere que o arquivo existethe API checks the file exists
A API só assina e confere; o banco guarda a chave do objeto, nunca a URL.The API only signs and checks; the database stores the object key, never the URL.
  • Dois buckets. O público (atrás da CDN) guarda fotos e vídeos; o privado guarda documentos e vídeo de verificação e nunca é público. Tipos privados são filtrados de toda leitura pública.Two buckets. The public one (behind the CDN) holds photos and videos; the private one holds documents and the verification video and is never public. Private types are filtered out of every public read.
  • Um prefixo por perfil (providers/{id}/…) nos dois buckets: listar ou apagar tudo de uma pessoa é uma operação por prefixo, o que torna o direito de exclusão simples de cumprir.One prefix per profile (providers/{id}/…) in both buckets: listing or deleting everything about a person is one prefix operation, which makes honoring a deletion request simple.
  • Nomes aleatórios (UUID) e tipos permitidos fixos; o Content-Type assinado precisa bater com o enviado.Random names (UUID) and fixed allowed types; the signed Content-Type must match the one sent.
  • Cotas pelo plano. Se a pessoa reduz o plano, o que passa da cota fica oculto, não apagado.Quotas by plan. If someone downgrades, what exceeds the quota is hidden, not deleted.

Analytics sem sobrecarregar o bancoAnalytics without overloading the database

Contar visualizações e cliques poderia virar um INSERT por evento. O projeto faz diferente, nos dois lados:Counting views and clicks could become one INSERT per event. The project does it differently, on both sides:

  • No navegador: um card só conta se ficou visível pelo menos 50% por 1 segundo (IntersectionObserver). Os eventos são agrupados (até 50 por requisição), enviados a cada 5 s e ao sair da página (keepalive). Falhas são silenciosas: analytics jamais atrapalha a página.In the browser: a card counts only if it was at least 50% visible for 1 second (IntersectionObserver). Events are batched (up to 50 per request), sent every 5 s and when leaving the page (keepalive). Failures are silent: analytics never disturbs the page.
  • Na API: cada evento só incrementa um contador num buffer em memória (LongAdder, sem travas). Um job periódico esvazia o buffer e grava tudo num UPSERT em lote (INSERT … ON CONFLICT DO UPDATE), numa transação. Se falhar, as contagens voltam ao buffer; no desligamento, há um último flush.On the API: each event only increments a counter in an in-memory buffer (LongAdder, lock-free). A periodic job drains the buffer and writes everything in a batch UPSERT (INSERT … ON CONFLICT DO UPDATE) in one transaction. On failure the counts return to the buffer; on shutdown there is one last flush.
  • Deduplicação: o mesmo visitante conta uma vez por perfil, métrica e mídia a cada 30 minutos; robôs não contam. Linhas de perfis já apagados são ignoradas por um EXISTS, para que uma linha órfã não derrube o lote.De-duplication: the same visitor counts once per profile, metric and media every 30 minutes; bots don’t count. Rows of already-deleted profiles are skipped by an EXISTS, so one orphan row can’t sink the batch.
Visitas e cliquesViews and clickslotes de até 50batches of up to 50Buffer em memóriaIn-memory buffersó soma contadoresonly adds countersJob periódicoPeriodic jobesvazia o bufferdrains the bufferUPSERT em loteBatch UPSERTse falhar, as contagens voltam ao bufferif it fails, counts go back to the buffer
Milhares de eventos viram poucas escritas: o banco recebe um lote numa transação, não um INSERT por clique.Thousands of events become a few writes: the database gets one batch in one transaction, not an INSERT per click.

Privacidade por projetoPrivacy by design

Vários dados simplesmente não são guardados, o que é a melhor proteção:Several data points are simply not stored, which is the best protection:

  • O CPF tem os dígitos verificadores validados e não é armazenado. A data de nascimento fica no perfil, mas nunca é devolvida por endpoints públicos (só a idade, se a pessoa permitir).The national ID number has its check digits validated and is not stored. The birth date stays on the profile but is never returned by public endpoints (only the age, if the person allows it).
  • O identificador anônimo do visitante (guest_id) é um UUID aleatório, vive só no navegador, é HttpOnly, e nunca vai ao banco nem aos logs. O IP só é contado em memória; o log diz qual limite foi atingido, nunca o IP.The visitor’s anonymous identifier (guest_id) is a random UUID, lives only in the browser, is HttpOnly, and never reaches the database or the logs. The IP is counted only in memory; the log says which limit was hit, never the IP.
  • A exclusão de conta é uma remoção definitiva: apaga perfil, mídia, arquivos nos dois buckets, verificação, sessões e analytics. O registro de auditoria das decisões tomadas por uma conta admin é mantido.Account deletion is a permanent purge: it removes profile, media, files in both buckets, verification, sessions and analytics. The audit log of decisions made by an admin account is kept.
  • Mensagens genéricas e URLs assinadas de curta duração (5 minutos para leitura).Generic messages and short-lived signed URLs (5 minutes for reading).

Qualidade e entregaQuality and delivery

  • Testes contra o banco de verdade. Os testes de integração usam Testcontainers com PostgreSQL real, não um banco falso. São dezenas de arquivos de teste cobrindo contas, mídia, proteção, assinaturas, admin e mais.Tests against a real database. Integration tests use Testcontainers with real PostgreSQL, not a fake database. There are dozens of test files covering accounts, media, protection, subscriptions, admin and more.
  • Guarda contra N+1. Um teste mede, pelas estatísticas do Hibernate, quantos comandos SQL cada endpoint de lista executa com poucos e com muitos itens; o número precisa ser o mesmo. Se crescer com os itens, o teste falha.N+1 guard. A test measures, through Hibernate statistics, how many SQL commands each list endpoint runs with few and with many items; the number must be the same. If it grows with the items, the test fails.
  • Front com rede de segurança. TypeScript estrito sem any, ESLint com zero avisos e regras de acessibilidade, Vitest para a camada de API, validadores e stores, Playwright com axe nas páginas-chave.A safety net on the front end. Strict TypeScript with no any, ESLint with zero warnings and accessibility rules, Vitest for the API layer, validators and stores, Playwright with axe on the key pages.
  • Orçamentos de desempenho. O Lighthouse CI falha o build se a acessibilidade cair da nota máxima ou se o deslocamento de layout (CLS) passar do limite; desempenho e boas práticas geram avisos.Performance budgets. Lighthouse CI fails the build if accessibility drops from the top score or layout shift (CLS) exceeds the limit; performance and best practices raise warnings.
  • CI e contêineres. GitHub Actions roda os testes e constrói a imagem Docker com cache. Os Dockerfiles são multi-estágio, rodam como usuário sem privilégios e o front tem healthcheck.CI and containers. GitHub Actions runs the tests and builds the Docker image with cache. The Dockerfiles are multi-stage, run as an unprivileged user, and the front end has a healthcheck.
  • Configuração tipada. Valores ajustáveis (TTLs, limites, cotas) são records @ConfigurationProperties com padrões versionados; segredos vêm de variáveis de ambiente, e o perfil de produção valida o esquema, desliga o Swagger e não tem valor padrão para CORS.Typed configuration. Tunable values (TTLs, limits, quotas) are @ConfigurationProperties records with versioned defaults; secrets come from environment variables, and the production profile validates the schema, turns Swagger off and has no default CORS value.
  • Tempo bem tratado. Instantes em UTC, regras “por dia” no calendário do fuso do negócio, e o relógio é injetado, o que torna as regras de tempo testáveis.Time handled well. Instants in UTC, “per day” rules on the business time zone’s calendar, and the clock is injected, which makes time rules testable.

Há ainda uma característica de processo: o repositório traz um arquivo de regras para agentes de IA. Humanos decidem arquitetura, dados e produção; o agente nunca lê arquivos de segredos, não mexe em ambientes compartilhados, escreve testes antes ou depois (e diz qual escolheu), e roda a suíte inteira antes de cada commit. É o mesmo princípio de responsabilidade humana da citação da IBM que este site usa.There is also a process trait: the repository carries a rules file for AI agents. Humans decide architecture, data and production; the agent never reads secret files, never touches shared environments, writes tests before or after (and says which it chose), and runs the whole suite before every commit. It is the same human-accountability principle as the IBM quote this site uses.

O que levar para outros projetosWhat to take to other projects

CaracterísticaTraitPor que importaWhy it matters
Contrato gerado da APIGenerated API contractO front quebra na compilação, não em produção.The front end breaks at compile time, not in production.
Endpoint por telaEndpoint per screenMenos viagens e menos lógica no cliente.Fewer round trips and less client logic.
Cache por camada, com dono e prazoCache per layer, with an owner and a lifetimeRápido e previsível, sem dados velhos escondidos.Fast and predictable, without hidden stale data.
Cache guarda o neutro, personaliza depoisCache holds the neutral, personalizes laterSem vazamento entre visitantes.No leaks between visitors.
Token de acesso em memória + refresh opaco rotativoAccess token in memory + rotating opaque refreshRoubo de sessão detectável e XSS menos útil.Detectable session theft and a less useful XSS.
Proteção antes da autenticaçãoProtection before authenticationRejeitar cedo é barato.Rejecting early is cheap.
Upload direto por URL assinadaDirect upload via signed URLA API não vira gargalo de arquivos.The API doesn’t become a file bottleneck.
Buffer + UPSERT em loteBuffer + batch UPSERTContar sem castigar o banco.Counting without punishing the database.
Teste de N+1 e banco realN+1 test and a real databaseA performance e a correção são verificadas, não supostas.Performance and correctness are verified, not assumed.
Limites admitidos por escritoLimits admitted in writingQuem herda o código sabe onde evoluir.Whoever inherits the code knows where to evolve.
Anotação pessoal: esta análise se baseia na leitura da documentação e do código dos dois repositórios (nenhum arquivo de ambiente foi lido). Números como 20 a 30 MB de memória são estimativas do próprio projeto, não medições.Personal note: this analysis is based on reading the documentation and code of both repositories (no environment file was read). Figures such as 20 to 30 MB of memory are the project’s own estimates, not measurements.