---
title: "Database per service agora é de graça. Isso não é motivo para usar"
url: https://tiarebalbi.com/pt-br/blog/database-per-service-free-tier-data-ownership
markdown: https://tiarebalbi.com/pt-br/blog/database-per-service-free-tier-data-ownership.md
description: "Database per service parece de graça com 1 GB no Neon. Comparei com um banco só, com schema e role por dono, e mantive o ledger em uma transação."
author: "Tiarê Balbi Bonamini"
locale: pt-br
published: 2026-10-19
updated: 2026-10-19
category: "Engineering"
translation: https://tiarebalbi.com/en/blog/database-per-service-free-tier-data-ownership
---
# Database per service agora é de graça. Isso não é motivo para usar

Database per service é o conselho padrão para microsserviços, e no dia 2 de outubro ficou mais barato. O Neon [dobrou o armazenamento gratuito para 1 GB por projeto](https://neon.com/blog/neon-free-plan-1-gb-per-project), e o plano gratuito continua permitindo 100 projetos. Um banco por serviço agora multiplica minha cota gratuita em vez da minha fatura. Isso mudou uma entrada da decisão. Não tomou a decisão por mim.

Na minha primeira semana no Bullpen, previ que a Parte 3 dividiria preços de ações e histórico de mercado em dois serviços, cada um com seu banco. Essa previsão está congelada na tag [`predictions-week-1`](https://github.com/tiarebalbi/bullpen/tree/predictions-week-1). Aí a construção aconteceu, e quase nada dela sobreviveu. Hoje o Bullpen guarda todos os dados em um único banco no Neon, com um schema e uma role de banco por dono, e um único dono para todos os dados de mercado. A regra que saiu disso é sobre transações: o dado autoritativo fica onde a transação termina, não onde o free tier termina.

Esta parte está escrita de um jeito diferente das duas primeiras. É um histórico de decisões: cada decisão com as condições que enfrentei, as opções que pesei, o que escolhi, por quê e o que me faria voltar atrás. Estou rodando o Bullpen como um teste, construindo para descobrir o que há de novo em 2026, o que se sustenta e o que não se sustenta. Não existe arquitetura universalmente certa, mas existem arquiteturas erradas para um conjunto de condições, e as condições são o assunto deste post.

## Onde as Partes 1 e 2 deixaram a questão dos dados

A [Parte 1, sobre quando usar microsserviços em 2026,](https://tiarebalbi.com/pt-br/blog/when-to-use-microservices-2026) definiu a regra da série inteira. Todo split precisa citar um de cinco motivos: contenção de dados, isolamento de falhas, mudança independente, adequação de runtime ou um orçamento compartilhado. Cada motivo compra uma fronteira, e só uma fronteira que precisa de deploy próprio, dados próprios e domínio de falha próprio merece virar microsserviço. A Parte 1 também prometeu que a Parte 3 traria a evidência do primeiro motivo, contenção de dados, e alertou sobre a armadilha em que este post entra: um padrão barato não é um motivo.

A [Parte 2, sobre as três funções da arquitetura como código,](https://tiarebalbi.com/pt-br/blog/architecture-as-code-describe-govern-remember) criou dois registros. A arquitetura planejada em `planned/` é meu plano atual, e eu a altero quando mudo de ideia. A tag `predictions-week-1` guarda o que previ no primeiro dia, e a Parte 6 compara os dois. A Parte 3 é a primeira vez que essa disciplina encontra uma reversão de verdade.

A Parte 1 também precisou de uma correção. Ela dizia que o Neon dá 0,5 GB a cada projeto gratuito. Isso já estava desatualizado quando o post saiu, em 4 de outubro, dois dias depois do anúncio do Neon. O post agora diz 1 GB, com uma nota de correção datada abaixo do parágrafo em vez de uma edição silenciosa. A mesma regra vale para a arquitetura: quando os fatos mudam, o registro mostra quando.

## Desintegradores, integradores e o registro de decisão em cinco passos

Para as perguntas de dividir ou manter junto, uso o modelo de _Software Architecture: The Hard Parts_, de Ford, Richards, Sadalage e Dehghani. Um conjunto de forças separa um sistema, que eles chamam de desintegradores: uma taxa de mudança diferente, uma necessidade diferente de escala ou de tolerância a falhas, uma fronteira de segurança diferente. Outro conjunto o mantém junto, os integradores: uma transação de banco, um fluxo que exige coordenação próxima, código compartilhado, dados relacionados. Um split vale a pena quando os desintegradores pesam mais que os integradores para as condições do momento.

Cada decisão abaixo segue os mesmos cinco passos:

1. **Condições:** os limites e fatos que eu tinha no dia em que decidi.
2. **Opções:** as abordagens que levei a sério.
3. **Escolha:** o que escolhi.
4. **Por quê:** quais forças venceram.
5. **O que me faria voltar atrás:** a observação que me faria reverter.

As condições vêm principalmente de free tiers. Estas são as que moldaram a Parte 3, em 13 de outubro de 2026:

| Plataforma | Limite gratuito | Para onde empurra |
|---|---|---|
| Neon | 1 GB e 100 CU-horas por mês por projeto, 100 projetos | Muitos bancos pequenos parecem de graça |
| Neon | Compute escala a zero após 5 minutos ocioso | Cada banco acorda frio por conta própria |
| Vercel Hobby | 1 build simultâneo, 100 deploys por dia | Menos projetos, que fazem build juntos |
| Alpha Vantage | 25 requisições de ações por dia | Um único dono para todo o orçamento |

As fontes são a [página de planos do Neon](https://neon.com/docs/introduction/plans), os [limites da Vercel](https://vercel.com/docs/limits) e a [página de suporte da Alpha Vantage](https://www.alphavantage.co/support/). A tabela já mostra a tensão. Os limites do Neon recompensam dividir os dados. Os da Vercel e da Alpha Vantage recompensam manter as coisas juntas.

Falta um limite nessa tabela, e foi ele que parou a construção: quantos bytes um projeto gratuito do Neon pode enviar por mês. A decisão 5 é onde eu o encontrei.

A imagem abaixo compara as duas arquiteturas: a previsão da primeira semana à esquerda e o que a Parte 3 construiu à direita. Repare no número de bancos e na única linha que o Trading pode traçar até os dados de mercado.

![Previsão da primeira semana com dois serviços e dois bancos ao lado do desenho construído, com um banco e dois schemas](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1791897329067-oohjvs7lssj.jpeg)

## Database per service ou um dono por transação: três decisões de dados

### Decisão 1: um único dono para todos os dados de mercado

**Condições.** A primeira semana previu dois serviços: Prices, para a cotação mais recente, e Market History, para preços passados. Quando escolhi uma fonte de dados de ações, a opção gratuita era a Alpha Vantage, com 25 requisições por dia. Isso basta para preços de fechamento de uma lista curta de símbolos, buscados uma vez por dia, e nada além. Os preços de cripto vêm do CoinGecko, com outro orçamento. A Alpha Vantage também oferece requisições ilimitadas para projetos open source ou educacionais verificados, mas projetei para o limite padrão, porque essa é a condição hoje.

**Opções.**

- Manter a previsão: um serviço Prices e um serviço History, cada um com seu banco.
- Juntar os dois em um único dono de Market Data, dentro do app de trading.
- Juntar os dois em um único dono de Market Data, como fronteira própria.

**Escolha.** Um único dono de Market Data, como fronteira própria. Ele guarda os dois provedores e os dois orçamentos.

**Por quê.** Listei os desintegradores e não encontrei nenhum. Preços e histórico vêm do mesmo provedor, mudam no mesmo ritmo e falham do mesmo jeito. O integrador era forte: os dois gastariam as mesmas 25 requisições. Dois serviços dividindo um orçamento significam ou um protocolo de coordenação entre eles ou um orçamento que pode estourar. O quinto motivo da Parte 1, um orçamento compartilhado, diz que um único dono deve guardar um limite do qual todos consomem.

Na primeira semana, dividi por tipo de dado. A construção me mostrou que a restrição externa era o que precisava de uma autoridade única.

Um dono só não exige, por si, uma fronteira separada. Um módulo dentro do app de trading guardaria o orçamento tão bem quanto. Fiz do Market Data uma fronteira própria por dois motivos concretos e um honesto. Ele roda num ritmo diferente de todo o resto: uma ingestão por dia depois do fechamento, não por requisição. Ele mantém as duas chaves de provedor e os dois orçamentos atrás de uma única interface, então nenhum outro código consegue gastar uma chamada. E é o lugar mais barato do Bullpen para testar Vercel Services, assunto da decisão 4. Se esse experimento não mostrar benefício, o Market Data volta a ser um módulo e a regra de propriedade continua a mesma.

A lição é mais estreita que "nunca separe preços de histórico". Com uma fonte em streaming, um histórico intradiário grande e uma política de retenção diferente, o armazenamento de histórico poderia ter uma necessidade de escala que o preço mais recente não tem. O Bullpen não tem nada disso.

**O que me faria voltar atrás.** Uma carga em que a ingestão de histórico e a atualização do preço mais recente passem a ter necessidades bem diferentes de escala, retenção, runtime ou isolamento de falhas. Um segundo provedor sozinho não bastaria: um único dono de Market Data consegue coordenar dois provedores. A mudança teria de fazer a separação valer o custo.

### Decisão 2: onde os dados moram

**Condições.** O plano gratuito do Neon dá a cada projeto seu próprio armazenamento, suas CU-horas e seu compute. Cada compute escala a zero após 5 minutos ocioso, e o plano gratuito não me deixa desligar isso. No menor compute, 0,25 CU, a documentação do Neon lista 104 conexões Postgres, com 7 reservadas para o Neon, segundo a [documentação de connection pooling](https://neon.com/docs/connect/connection-pooling). O pooler do Neon mantém um pool por role e banco, e todo pool consome dessas mesmas conexões de backend. O leaderboard precisa combinar posições do Trading com preços do Market Data.

**Opções.** O [Microservices.io](https://microservices.io/patterns/data/database-per-service.html) lista três formas de manter os dados de um serviço privados: tabelas privadas por serviço, um schema por serviço ou um servidor de banco por serviço. No Neon, isso vira:

- **Um projeto por serviço.** Cada dono ganha seu 1 GB, suas 100 CU-horas por mês e seu pool de conexões. Também ganha seu próprio cold start, suas credenciais e suas migrations. Uma consulta que precisa de dados de dois donos vira duas chamadas de rede e um join no código da aplicação.
- **Vários bancos em um projeto.** Eles dividem o compute, então dividem o cold start e o limite de conexões. O Postgres não faz join entre bancos sem extensão, então o leaderboard continua fazendo join no código.
- **Um banco, um schema e uma role de banco por dono.** Um cold start, as conexões de um compute, e a propriedade garantida por grants em vez de distância.

**Escolha.** Um projeto Neon e um banco, com um schema e uma role por dono: `market_data`, do `market_data_owner`, e `trading`, do `trading_owner`. O Market Data publica uma view somente leitura, `market_data.prices_v1`, e o Trading pode ler essa view e nada mais no schema `market_data`.

**Por quê.** A opção de um projeto por serviço parecia melhor no papel: mais armazenamento, mais CU-horas, mais conexões, tudo de graça. Também significava dois bancos acordando frios em horários próprios, e uma consulta do leaderboard dividida por uma chamada de rede. Nenhum dos cinco motivos pedia isso. A cota gratuita pedia, e uma cota gratuita não é motivo. As conexões extras também não decidiram. Com duas roles, o Bullpen tem dois pools que dividem as conexões de backend de um compute, e nenhuma parte do Bullpen tem uma necessidade medida de mais do que um compute oferece. O teste de carga da abertura do mercado, na Parte 5, é onde isso vai ser medido.

O que bancos separados realmente evitam é a falha que Martin Fowler descreve como [integration database](https://martinfowler.com/bliki/IntegrationDatabase.html): duas ou mais aplicações lendo e escrevendo as mesmas tabelas até ninguém conseguir mudar uma coluna. Grants também protegem contra essa falha, e falham de forma ruidosa. Neste desenho, uma consulta do Trading em uma tabela do Market Data é recusada pelo Postgres, e um teste na CI existe para provar isso.

No branch de produção, conectado como `trading_owner`, um select na tabela de cotações volta como `42501 permission denied for table quotes`, e a mesma role lê `market_data.prices_v1` sem erro. O inverso também vale: o `market_data_owner` é recusado em toda leitura e escrita no schema `trading`. As mesmas recusas rodam como testes na CI contra um container Postgres.

Um número me surpreendeu, duas vezes. Eu esperava que `SHOW max_connections` retornasse 104. Retornou 901 em 13 de outubro, e 901 de novo na manhã em que este post saiu. O Neon define o limite pelo maior tamanho que o compute pode atingir, e 901 é o valor para 2 CU. Então o compute que eu achava fixo em 0,25 CU podia escalar até 2 CU, e podia durante todas as medições abaixo. Os números valem como foram medidos; a folga por trás deles era maior do que eu pensava. A lição é a mesma que esta parte inteira repete: leia a configuração no sistema, não nas minhas anotações.

Também mudei um detalhe durante o planejamento. Meu primeiro esboço tinha uma role por módulo de código. Um revisor apontou que roles devem seguir transações, não módulos. Orders e Ledger podem ser dois módulos no código e ainda ter um único dono, porque dividem uma transação. Essa é a decisão 3.

**O que me faria voltar atrás.** Uma necessidade real de isolamento que grants não conseguem dar: uma carga que prejudica as consultas do outro dono mesmo tocando tabelas diferentes, ou um dono que precise de domínio de falha próprio. O teste de contenção abaixo é como eu veria o primeiro caso.

### Decisão 3: Orders e Ledger ficam em uma transação

**Condições.** Meu esboço de setembro para a Parte 3 separava Orders de Ledger. Uma compra precisa reservar poder de compra, registrar a ordem, escrever os lançamentos no ledger e atualizar a posição. O poder de compra nunca pode ficar negativo, nem quando duas compras disputam.

**Opções.**

- Separar em dois serviços e coordenar com uma saga: cada passo faz commit sozinho, e uma falha dispara passos de compensação.
- Separar e coordenar com uma transação distribuída, como two-phase commit.
- Manter os dois com um único dono, em uma transação de banco.

**Escolha.** Um único dono, `trading`, e uma transação para a ordem e seus lançamentos no ledger.

**Por quê.** Este foi o integrador mais forte de toda a construção. _The Hard Parts_ trata uma transação de banco como motivo para manter serviços juntos. Separá-los significa trocar um commit atômico por algo mais fraco, e eu não tinha um motivo que justificasse o custo. Two-phase commit bloqueia os participantes quando o coordenador some, que é o que [minhas notas sobre two-phase commit na JVM](https://tiarebalbi.com/en/blog/two-phase-commit-blocking-jvm-kotlin) mostraram. Uma saga troca atomicidade por compensação.

As duas são ferramentas reais, e a Parte 4 é onde o Bullpen precisa de uma saga: o ciclo de vida da ordem, com execuções parciais e cancelamentos. Para a escrita que move dinheiro, a regra que escrevi no registro de decisão é curta. As escritas autoritativas de ordem e ledger ficam dentro de uma única fronteira transacional. Um split precisa antes de um substituto declarado para essa atomicidade.

Uma constraint `CHECK (buying_power >= 0)` não basta sozinha. Ela verifica a linha que é escrita, mas, se duas transações leem o saldo, calculam um novo no código da aplicação e gravam de volta, a segunda escrita pode sobrescrever a primeira e as duas compras passam. Por isso a reserva é um único update condicional, executado na mesma transação das escritas da ordem e do ledger:

```sql
UPDATE trading.accounts
SET buying_power = buying_power - $1
WHERE account_id = $2
  AND buying_power >= $1
RETURNING buying_power;
```

O Postgres pega um lock de linha para o update, então a segunda de duas compras concorrentes espera e depois reavalia a condição contra o saldo que a primeira deixou. Se nenhuma linha volta, a compra é recusada e a transação faz rollback.

Rodei essa disputa localmente no PostgreSQL 16.13 antes de escrever isto: duas compras de 600 contra um poder de compra de 1.000, cada uma segurando a transação aberta por um segundo. Uma fez commit e deixou 400; a outra não recebeu linha nenhuma e fez rollback.

A constraint `CHECK` continua como segunda proteção. O teste de integração precisa mostrar três coisas: duas compras que cabem sozinhas, mas juntas estouram, não podem ambas fazer commit; a compra perdedora não deixa ordem, lançamento ou reserva para trás; e uma falha injetada depois do insert da ordem desfaz todas as escritas.

O teste de integração faz as três. Duas compras de 6 unidades a 100 disputam um poder de compra de 1.000. Exatamente uma faz commit; a outra é recusada como `insufficient-buying-power`. Depois, só existem as escritas da vencedora: uma ordem, dois lançamentos no ledger, uma posição e 400 de poder de compra restante. Uma falha injetada depois do insert da ordem não deixa linha de ordem nem de ledger.

**O que me faria voltar atrás.** Um time que precise lançar o ledger sem o fluxo de ordens, ou um ledger cuja carga de escrita concorra com a entrada de ordens a ponto de dar para medir. Nenhum dos dois existe num projeto de uma pessoa, e a Parte 6 é onde esboço onde ficariam as fronteiras de times.

## Deploy e leitura: duas decisões sobre o que vai para produção e o que os leitores veem

### Decisão 4: a primeira extração vai como Vercel Service

**Condições.** O Market Data é a primeira parte do Bullpen com dados, orçamento e agenda próprios. Isso faz dele a primeira extração, e força a decisão de topologia de deploy que deixei em aberto como ADR-0004 na primeira semana. No Vercel Hobby, só um build roda por vez, e um projeto pode criar 100 deploys por dia. Desde junho de 2026, o [Vercel Services](https://vercel.com/docs/services) permite que um projeto tenha mais de um serviço com build separado, em beta em todos os planos.

**Opções.**

- Manter o Market Data como módulo dentro do app de trading.
- Colocá-lo em um Vercel Service próprio, dentro do mesmo projeto.
- Colocá-lo em um projeto Vercel próprio.

**Escolha.** Um Vercel Service no mesmo projeto.

**Por quê.** Projetos separados dariam ao Market Data um deploy independente, e o Bullpen não precisa disso: uma pessoa entrega tudo, e a Parte 1 já esperava que mudança independente fosse o mais fraco dos cinco motivos aqui. No Hobby, projetos separados também disputariam o único slot de build da conta sempre que um push disparasse builds em mais de um deles. Um Service faz build separado dentro de um deploy compartilhado, então a fronteira fica visível no build e no código sem um segundo projeto para manter em sincronia. O experimento abaixo mede se isso economiza alguma coisa neste repositório; se não economizar, eu conto também.

A documentação do Services é direta sobre o custo: "All services share the same deployment." Então o Market Data é uma fronteira com release compartilhado, não um microsserviço. Pela regra da Parte 1, esse é o nome honesto: ele tem dados e orçamento próprios, mas não deploy nem domínio de falha próprios. Registro o release compartilhado no ADR-0004 como um acoplamento deliberado, para que ninguém leia o diagrama depois e conclua que os serviços são entregues de forma independente.

Fiz três pushes em cada uma das duas primeiras opções e cronometrei cada deploy. Os números são medianas de três:

| Métrica | Módulo no app | Vercel Service | Projeto separado |
|---|---|---|---|
| Do push até o deploy web ficar pronto | 70,9 s | 42,7 s | não rodado |
| Na fila atrás de outro build | 8,7 s | 4,0 s | não rodado |
| Build do app web | 55,2 s | 31,6 s | não rodado |

O Service parece mais rápido, mas a comparação não é limpa. Em duas das três execuções com Service, só o Market Data mudou, então o build do app web veio do cache do Turborepo. Parte da diferença é que um serviço que não mudou não é rebuildado, e parte é sorte de cache; as execuções não separam as duas coisas. Confio mais em três achados que não dependem de tempo. A opção de módulo só passou nas minhas regras de arquitetura depois que afrouxei uma: dentro de um app, a fronteira entre Market Data e Trading é uma convenção, e nada impede um import entre eles. Um push que mudou só a landing page ainda disparou um deploy do app web, que depois foi cancelado. E um Vercel Service sem framework precisa de runtime e entry point explícitos; sem eles, a Vercel faz o build como site estático e o build falha procurando uma pasta de saída.

Não construí a opção de projeto separado. As duas primeiras responderam à pergunta que eu tinha, e um segundo projeto adicionaria um segundo conjunto de configurações para manter em sincronia sem ganho medido.

**O que me faria voltar atrás.** Uma necessidade de fazer rollback do Market Data sem fazer rollback do app de trading, ou números mostrando que a fila de build está atrasando todo push.

### Decisão 5: o leaderboard lê uma materialized view, não uma fila

**Condições.** O leaderboard avalia as posições de cada jogador a preços atuais e os ranqueia. Preços de ações mudam uma vez por dia, depois do fechamento. Preços de cripto atualizam a cada poucos minutos. A Parte 1 nomeou o risco: às 9h30, leituras do leaderboard disputam o mesmo banco com escritas de ordens. O Bullpen ainda não tem multidão na abertura, então essa carga é uma previsão, não uma medição.

**Opções.**

- **Calcular na leitura:** rodar a consulta de ranking a cada requisição.
- **Uma materialized view:** guardar o ranking no Postgres e atualizá-lo quando preços ou execuções mudam.
- **Um read model alimentado por fila:** publicar um evento a cada mudança de preço e execução, e atualizar um armazenamento separado do leaderboard a partir desses eventos.

**Escolha.** Uma materialized view, `trading.leaderboard_mv`, construída a partir das posições e da view `prices_v1`. Ela é atualizada depois da ingestão diária de preços e quando uma leitura a encontra com mais de 5 minutos. Nenhum leitor espera por um refresh. Uma leitura defasada devolve o ranking atual com seu horário e dispara um refresh depois da resposta, protegido por um advisory lock não bloqueante do Postgres: se outro refresh estiver com o lock, a requisição pula. Um índice único permite rodar o refresh com `CONCURRENTLY`, então os leitores continuam lendo o ranking anterior enquanto ele roda. Se um refresh falhar, os leitores mantêm o último ranking bom e a página mostra a idade dele. Isso deixa a página no máximo um refresh atrás, o que o leaderboard tolera.

**Por quê.** O leaderboard tolera minutos de defasagem, então eu não precisava atualizá-lo a cada escrita. Isso sozinho não descartou nada; tornou as opções mais simples suficientes. Entre as duas mais simples, a view tira a leitura cara das tabelas do ledger, que é a contenção que preocupava a Parte 1.

A fila foi a opção que um revisor sugeriu, e eu a considerei a sério. O [Vercel Queues](https://vercel.com/docs/queues/concepts) entrega pelo menos uma vez, não garante ordem e não tem dead-letter queue embutida. Um read model correto em cima disso precisa de IDs de evento estáveis, uma versão por conta para que um evento antigo não sobrescreva um mais novo, e uma regra para mensagens que continuam falhando. É um preço justo para uma carga real. Sem um segundo consumidor desses eventos e sem uma carga de leitura medida, seria uma demonstração de tecnologia.

Escrevi sobre o que a manutenção de views garante e o que não garante nas [minhas notas sobre incremental view maintenance](https://tiarebalbi.com/en/blog/incremental-view-maintenance-streaming-sql-guarantees); um refresh de view no Postgres é o primo bem mais simples.

Uma view pode se desviar da fonte, então ela ganha uma verificação de reconciliação, ideia que tirei da sessão de análise de trade-offs de Neal Ford em 1º de outubro: com o sistema em repouso, o dado derivado precisa ser igual ao dado recalculado a partir da fonte da verdade. Na CI, a verificação compara a view com o mesmo ranking calculado direto das tabelas. Ela existe para falhar, nomeando a conta, numa execução depois do último refresh e numa linha adulterada na view.

As duas falhas nomeiam a conta. O caso da execução é o mais interessante: a view e as tabelas concordam no patrimônio, 1.000, e ainda discordam sobre do que ele é feito. A view diz 1.000 em caixa e nada em posições; as tabelas dizem 600 em caixa e 400 em posições. Uma verificação só do ranking teria passado. Na linha adulterada, a view diz 9.999 contra 1.000 nas tabelas.

**A evidência de contenção de dados.** Esta é a medição que a Parte 1 prometeu. É um pico simulado, não uma abertura de mercado real: um lote fixo de compras roda enquanto 0, 10 e 50 leitores concorrentes acessam o leaderboard, primeiro pela consulta calculada na leitura e depois pela view com sua política real de refresh, incluindo a primeira leitura depois que a view fica defasada. Um refresh também é uma consulta pesada, então o teste registra quanto ele dura e o que faz com as escritas no ledger. Se a view acelerar as leituras sem melhorar as escritas, isso prova o valor de um read model, não a necessidade de um split.

A primeira execução não respondeu à pergunta. Ela parou o banco. Criei 2.000 contas, e cada leitor buscava o leaderboard inteiro: cerca de 360 KB por resposta. Uma célula com 50 leitores moveu cerca de 1,7 GB para fora do Neon. Em 16 de outubro, o projeto passou dos 5 GB de transferência de rede do plano gratuito no mês, e o Neon suspendeu o compute. Eu tinha contado armazenamento, CU-horas e conexões. Não tinha contado os bytes saindo do banco.

Essa execução ainda mostrou um formato. Com 50 leitores, as escritas no ledger levaram 1.275 ms no p95 calculando na leitura e 1.005 ms com a view. Mas, quando olhei para onde o tempo ia, não era CPU, locks nem o pool de conexões. Era o tamanho de cada resposta. O banco estava ocupado enviando, não calculando.

Então três coisas mudaram antes da segunda execução, e a comparação precisa ser lida com as três em mente. A API do leaderboard agora devolve os 50 primeiros jogadores mais a linha de quem pediu, cerca de 9 KB em vez de cerca de 360 KB quando medi os dois numa cópia local com 2.096 contas. O teste cria 200 contas e roda cada célula por 15 segundos, atrás de uma proteção que se recusa a começar quando a estimativa passa de 10% da cota de transferência restante. E o projeto foi para o plano pago Launch do Neon, com as mesmas configurações de compute.

A segunda execução: 8 escritores fazendo compras, num compute que podia escalar até 2 CU, 200 contas, 15 segundos por célula. O cliente era meu laptop pela internet pública, então todo número inclui uma ida e volta que uma função na mesma região não pagaria. Compare as linhas entre si, não com uma latência de produção.

| Leitores | Caminho de leitura | p95 de escrita no ledger | p95 de leitura |
|---|---|---|---|
| 0 | nenhum | 575 ms | n/a |
| 50 | calculado na leitura | 1.007 ms | 810 ms |
| 50 | view | 613 ms | 353 ms |

Com 10 leitores, os dois caminhos foram indistinguíveis nas escritas. Com 50, calcular o ranking a cada leitura acrescentou 75% às escritas no ledger; a view as manteve a 7% da linha de base ociosa. As leituras pela view ficaram abaixo da linha de 500 ms que defini para trocar por uma fila; as calculadas na hora, não. O refresh em si não teve custo que eu conseguisse medir: 36 refreshes seguidos levaram 424 ms no p50 e 455 ms no p95, e as escritas no ledger ficaram em 573 ms no p95 durante eles contra 618 ms sem eles.

Então a contenção que a Parte 1 previu é real, e um read model dentro do mesmo banco eliminou a contenção observada nesse teste. Contenção de dados ainda não justifica um split no Bullpen. O teste não diz nada sobre uma multidão dez vezes maior ou sobre outra mistura de leituras e escritas; os critérios de troca abaixo existem para isso. Para esta carga, um banco separado para o leaderboard teria enviado os mesmos bytes por mais um salto de rede, e acrescentado um segundo banco para acordar.

Mais um número vale guardar. Estimei que a segunda execução moveria 185,6 MB; ela moveu 56,2 MB. Os bytes por leitura bateram com a estimativa, entre 8,2 e 9,5 KB. A estimativa supunha que cada leitor pedia 10 vezes por segundo. Um leitor que espera a resposta antes de pedir de novo conseguiu cerca de 3. Um teste de carga assim mede o que o sistema deixa os leitores fazerem, não o que os leitores querem.

**O que me faria voltar atrás.** Escrevi os critérios de troca no registro de decisão antes de medir qualquer coisa. Passo para um read model alimentado por fila quando um segundo consumidor precisar de eventos de preço ou execução, ou quando as leituras do leaderboard passarem de 500 ms no p95 na abertura.

## Operação: as decisões menores

As decisões restantes importaram, mas nenhuma mudou o formato do sistema. Cada uma ainda tem uma condição, uma escolha e uma reversão.

| Decisão | Escolha | Evidência | O que me faria voltar atrás |
|---|---|---|---|
| Banco frio às 9h30 | Aceitar o despertar e medi-lo contra uma meta definida antes | p95 frio 2.048 ms, meta 3.000 ms | p95 frio acima da meta, ou uma operação que falhe |
| Agenda de preços de ações | Um cron diário, depois do fechamento | Uma ingestão em preview gravou AAPL e MSFT de 16 de outubro | Preços intradiários, ou um plano pago |
| Orçamento de dados de mercado | Recusar chamadas acima de 20 por dia (5 em previews) antes que saiam | 60 chamadores, limite 20: 20 aceitos, 40 recusados, 0 acima | Um limite maior do provedor |
| Decisões propostas | Só um ADR aceito autoriza um nó | Falha enquanto Proposed, passa quando Accepted | Nenhum; isto era um bug |

**Cold start.** A [documentação de scale-to-zero do Neon](https://neon.com/docs/introduction/scale-to-zero) diz que um compute suspenso reativa em algumas centenas de milissegundos, e que a configuração é fixa no plano gratuito. O cron do Hobby dispara em qualquer momento dentro da hora agendada, segundo a [página do plano Hobby da Vercel](https://vercel.com/docs/plans/hobby), então um cron não consegue aquecer o banco de forma confiável logo antes das 9h30. Um agendador externo com horário preciso conseguiria, mas é uma nova dependência para um problema que eu não tinha medido.

Então fiz commit das metas antes de medir qualquer coisa. Uma requisição é fria quando nada chamou o deploy por pelo menos 20 minutos, e ela é cronometrada do meu laptop, então as metas incluem a ida e volta pela internet: frio em até 2,0 segundos no p50 e 3,0 segundos no p95, quente em até 0,3 segundo no p50 e 0,8 segundo no p95 para o leaderboard.

Todas se mantiveram. O leaderboard, que lê o Neon, respondeu frio em 1.013 ms no p50 e 2.048 ms no p95, e quente em 141 ms e 270 ms. A rota de preço, que não toca o banco, foi mais lenta a frio, com 2.361 ms no p95, e ainda não sei por quê. A execução também ficou aquém do plano: 8 e 9 amostras frias em vez de 10, porque meu laptop derrubou conexões ao entrar em suspensão. Deixei as falhas fora dos percentis e as registrei à parte.

**A verificação de desvio ciente de status.** A Parte 2 introduziu uma verificação que falha quando o modelo de arquitetura cita um provedor que nenhum registro de decisão sustenta. Um leitor apontou uma brecha: a verificação aceitava a decisão de um ADR Proposed como se fosse final. Isso significava que eu podia renomear o nó do provedor de ações para Alpha Vantage enquanto a decisão ainda estava em revisão, e a CI passaria. A verificação agora trata uma decisão Proposed como indecisa. O teste roda nas duas direções. Com o ADR-0008 de volta a Proposed, a verificação falha três vezes: no momento da Parte 3 e nas Partes 4 e 5 planejadas, que carregam o mesmo nó. Cada falha diz que a Alpha Vantage ainda está indecisa e como corrigir. Com o ADR-0008 Accepted, ela passa.

## O que a construção mudou nos registros

Quatro registros de decisão saem desta parte: o ADR-0004, da topologia de deploy, o ADR-0012, da propriedade de dados, o ADR-0013, do único dono de Market Data, e o ADR-0014, da view do leaderboard. O registro de propriedade carrega a regra de Orders e Ledger. Eles ficaram Proposed durante a construção e são Accepted com este post. Outros dois tratam dos próprios registros: o ADR-0015 liga as regras de arquitetura ao último momento construído, e o ADR-0016 diz que só uma decisão Accepted autoriza o modelo.

O modelo de arquitetura mudou em dois lugares. O momento da Parte 3 descreve o que realmente roda: o serviço `market-data`, um banco com dois schemas e as relações de propriedade que dizem quem pode se conectar a quê. Os momentos planejados das Partes 4 a 6 não carregam mais o split entre Prices e History, porque `planned/` é meu plano atual. A tag `predictions-week-1` não se moveu. Na [página de arquitetura](https://bullpen.tiarebalbi.com/architecture), o painel "What changed" da Parte 3 mostra a previsão ao lado do que construí.

A página agora também mostra o tráfego. Cada conexão carrega uma taxa com um de três rótulos: medida, do teste de carga e não de usuários; configurada, um agendamento ou limite escrito no repositório; ou ilustrativa, ou seja, a conexão é usada, mas ninguém mediu quanto. O Bullpen ainda não tem usuários em produção, então a maioria das linhas é ilustrativa, e a página diz isso.

O app de trading agora segue o design, mas o ticket de ordem está desabilitado. As operações abrem com login na Parte 4.

## Quanto a Parte 3 custou

A Parte 3 deveria rodar em free tiers, e rodou até 16 de outubro, quando a primeira execução de contenção esgotou a cota de transferência do projeto no Neon. Passei o projeto para o plano Launch do Neon no mesmo dia. Ele não tem mínimo mensal; cobra US$ 0,106 por CU-hora e US$ 0,35 por GB-mês de armazenamento, e inclui 500 GB de transferência por mês, segundo a [página de preços do Neon](https://neon.com/pricing). A segunda execução de contenção moveu 56 MB disso. O Neon agora é a primeira linha no registro de custos do Bullpen que pode passar de zero.

O que consegui ler por conta própria, em 16 de outubro:

| Recurso | Usado | Cota |
|---|---|---|
| Armazenamento no Neon | 12 MB | 1 GB grátis; US$ 0,35 por GB-mês no Launch |
| Transferência de rede no Neon | 5 GB do plano grátis esgotados, depois 56 MB | 500 GB por mês no Launch |
| Chamadas à Alpha Vantage | 0 em produção, 4 em previews no pico | 20 por dia, 5 em previews |
| Chamadas ao CoinGecko | 14 em outubro, todas de previews | 10.000 por mês |

A página de cobrança do Neon ainda não mostrava cobranças. As CU-horas e os números da Vercel ficam em painéis que copio para o registro de custos do repositório depois que cada semana fecha, então não estão nesta tabela, e não os estimei. Vercel, CoinGecko e Alpha Vantage continuam em planos gratuitos.

## Invariantes de transação definem as fronteiras rígidas

Coloque o dado autoritativo onde a transação termina, não onde o free tier termina. Um orçamento compartilhado, uma necessidade de deploy, um domínio de falha ou uma carga medida precisa justificar todo outro split, e a carga é medida em bytes, não só em consultas. A Parte 4 testa isso contra o ciclo de vida da ordem, onde uma transação só já não basta.
