CoreService
Transações, categorias, metas, parties, gamificação, carteira e catálogo de ativos. É um resource server OAuth2: a identidade é o sub do token, não o e-mail.
- STACK
- Spring Boot · Modulith
- DADOS
- Postgres · Flyway
[00] Showcase de decisões
Um app de finanças pessoais e em grupo com mecânicas de RPG, construído por uma pessoa sobre um servidor próprio. O código-fonte é privado; esta página documenta as decisões, o que cada uma rejeitou e o que cada uma custou.
No contexto de um app de finanças pessoais e em grupo com dois canais de registro (app Android e bot do Telegram) e carteira de ativos,
enfrentando a necessidade de atender uma meta de 10 mil usuários simultâneos a partir de um único nó auto-hospedado, com RAM contada e sem orçamento para escalar horizontalmente,
decidimos adotar um monólito modular com fronteiras verificadas em compilação, identidade delegada a um IdP próprio e coleta de cotações em workers isolados que se comunicam por eventos,
rejeitando microsserviços, autenticação escrita dentro do domínio e identidade como serviço pago por usuário ativo,
para alcançar caminho de escrita curto e sem salto de rede, revogação de sessão e 2FA sem código de segurança próprio, e falha de fonte externa que não derruba o produto,
aceitando o ônus de escala apenas vertical do núcleo, consistência eventual nas telas de preço e mais um componente com banco próprio para operar.
[00] Resumo
O Vezta permite registrar gastos pelo app ou pelo Telegram, criar metas de economia e de limite e dividir despesas em grupo (parties). O progresso financeiro vira XP, níveis e conquistas, e a carteira acompanha ações e criptoativos.
Os repositórios são privados. Esta página mostra decisões, contratos e fluxos; nada aqui expõe segredo ou configuração de produção. O que pode ser auditado, e como, está em [09] Entrega e DX.
[01] Problema e restrições
Nenhuma decisão desta página faz sentido sem as restrições abaixo. Elas vieram primeiro, e é delas que sai a justificativa de cada escolha — inclusive das que parecem conservadoras.
| Restrição | O que ela significa | O que ela forçou na arquitetura |
|---|---|---|
| Usuário final | Pessoa física no Brasil que desiste de planilha e do app do banco por fricção, e grupos — casa, viagem, casal — que dividem despesa sem ferramenta própria. | Registro em até três toques e um canal de conversa, não apenas telas. Party como entidade de primeira classe, não um relatório compartilhado. |
| Escala-alvo | 10 mil usuários simultâneos no lançamento, sem verba para crescer em largura. | Consertar a leitura antes de escalar: agregação em SQL, índices e read models vêm antes de qualquer réplica (ADR-005). |
| Infraestrutura | Um nó único auto-hospedado, em hardware próprio sob guarda física, com memória contada. Só a cópia de segurança do banco sai da máquina. | Keycloak descartado por consumo de RAM; monólito modular em vez de malha de serviços; sem orquestrador (ADR-001, ADR-002). |
| Conformidade | LGPD: base legal declarada por finalidade, exclusão de conta feita pelo próprio usuário, PII mínima e nenhum dado bancário. | Identidade fora do domínio, exclusão propagada por outbox e zero credencial de banco no sistema — o app não integra open finance de propósito. |
| Equipe | Um desenvolvedor, cinco repositórios, sem revisor par. | Fronteira de módulo verificada em compilação e contrato escrito antes do código, porque não existe code review para pegar o erro depois. |
| Fontes de dados | Cotações vêm de APIs públicas com limite de requisições, formato instável e nenhum SLA. | Coleta fora do domínio, em workers que podem falhar sozinhos, e preço exibido com a hora da última atualização (ADR-004). |
[02] Contexto do sistema
Nível 1 do modelo C4: os limites do sistema e o ecossistema externo, sem uma única menção a tecnologia interna. É a visão que importa antes de abrir qualquer caixa.
[03] Arquitetura
Nível 2 do C4. O domínio fica num monólito modular. O que tem ciclo de vida ou padrão de falha próprio — o bot e a coleta de cotações — roda isolado, e a comunicação assíncrona passa por eventos. Tudo abaixo vive no mesmo nó físico.
Transações, categorias, metas, parties, gamificação, carteira e catálogo de ativos. É um resource server OAuth2: a identidade é o sub do token, não o e-mail.
Cliente Android com HUD, ledger, cinto de categorias e temas. Gráficos de ativos desenhados em Skia.
Bot que interpreta mensagens e chama o Core em nome do usuário, com o token dele, via device flow. Máquina de estados de conversa e banco próprio.
Consulta cotações na BRAPI e publica asset.price.updated. Raspa nomes do catálogo com Jsoup.
CoinGecko como fonte primária e Binance como fallback por moeda. Publica crypto.price.updated.
[04] Stack
Toda escolha abaixo trocou algo por outra coisa. A coluna da direita é a parte que costuma ficar fora de README.
| Camada | Escolha | Por quê | Custo assumido |
|---|---|---|---|
| Identidade | Zitadel auto-hospedado | Refresh/revogação, reset, e-mail verificado, 2FA e login social sem escrever um serviço de auth. | Mais um componente (com Postgres próprio) para operar. Keycloak foi descartado pelo consumo de RAM. |
| Backend | Monólito modular (Spring Modulith) | Fronteiras verificadas em compilação e eventos pós-commit sem custo de rede. | Escala o Core inteiro, não módulos isolados. Aceito para o volume-alvo. |
| Mensageria | RabbitMQ + outbox | Entrega confiável entre Core, scrapers e bot, com filas isoladas por consumidor. | Consistência eventual: telas precisam tolerar atraso de segundos. |
| Dados | Postgres por serviço | Cada serviço migra o próprio esquema (Flyway) sem coordenar deploys. | Sem joins entre serviços. Dados compartilhados viajam como evento e viram read model. |
| Cliente | Expo / React Native | Um código, iteração rápida, OTA e ecossistema de gestos e animação. | Gráficos com Skia não rodam na web; o cliente web fica fora do escopo atual. |
| Borda | Nginx | Proxy reverso leve, conhecido e suficiente para balancear réplicas do Core. | Configuração manual de TLS e rate limit (Caddy automatizaria parte). |
| Carga | k6 | Metas verificáveis: p95 < 300 ms, erros < 0,5%, teste de queda de réplica. | Exige ambiente próximo de produção, provisionado só durante os testes. |
[05] Decisões técnicas
Formato MADR: contexto, decisão, todas as opções consideradas com o motivo do veredito, e consequências separadas entre ganho e passivo. A ADR-000 está aqui como Substituída, não apagada — a trilha de por que uma decisão caiu vale mais do que a aparência de nunca ter errado.
O Core precisava de login antes de qualquer outra coisa, e escrever a autenticação ali era o caminho mais curto para ter o primeiro endpoint protegido e o app rodando ponta a ponta.
Usuário e senha com hash no próprio Core, JWT assinado pelo serviço e o e-mail como chave de identidade do usuário.
Zero componente novo para operar e primeiro endpoint protegido no mesmo dia.
Rejeitada na época: parecia peso desnecessário antes de existir um único usuário real.
Rejeitada na época: custo recorrente antes de haver receita e antes de o produto existir.
A autenticação própria (ADR-000) cobria apenas o caminho felizmente simples. Faltavam refresh com revogação, reset de senha, e-mail verificado, 2FA e login social, e cada um deles viraria código de segurança de responsabilidade minha.
Zitadel auto-hospedado com Login UI v2. O Core passa a ser resource server OAuth2: a chave de identidade é o sub, o papel ADMIN vem das claims e o access token dura 10 minutos.
Cobre revogação, 2FA e social de fábrica, roda no nó existente e mantém os dados de identidade sob guarda própria.
Rejeitada pelo consumo de memória: num nó único com RAM contada, ele competiria com o Core e os bancos.
Rejeitada pelo custo por usuário ativo na meta de escala, e por colocar o dado de identidade fora da infraestrutura própria.
Rejeitada: era a ADR-000, e o passivo de segurança dela foi exatamente o motivo desta decisão.
Havia um ciclo de dependência entre party e transaction, e a gamificação acoplava o cálculo de XP ao caminho de escrita da transação. Ao mesmo tempo, a infraestrutura-alvo é um nó único e a equipe é de uma pessoa.
Spring Modulith: módulos com fronteira verificada em compilação, o ciclo quebrado por inversão e a gamificação disparada por evento depois do commit.
Fronteira explícita e testável sem pagar latência de rede, deploy múltiplo nem observabilidade distribuída.
Rejeitada: no nó único, cada serviço custa RAM e um salto de rede no caminho de escrita, e a operação distribuída não cabe em uma pessoa.
Rejeitada: o ciclo party ↔ transaction já existia, e sem verificação em compilação ele volta no próximo commit.
A primeira versão do bot usava códigos de vínculo, um segredo interno compartilhado com o Core e um JWT assinado pelo próprio bot. Isso fazia do bot uma autoridade de identidade — confiança demais em um componente que lê mensagens de terceiros.
O comando /vincular inicia o device flow no Zitadel com um cliente dedicado. O bot guarda o sub e o refresh token cifrado em AES-GCM, e chama o Core com o token do usuário. O Core restringe esse cliente às operações do bot.
O bot nunca vê credencial e nunca pode mais do que o usuário que o autorizou; a sessão se estabelece entre a pessoa e o IdP.
Rejeitada: o bot assinava identidade, e vazar o segredo valeria por qualquer usuário.
Rejeitada: dá ao bot um poder maior que o do usuário, e um comprometimento dele vira acesso total ao domínio.
As fontes públicas de preço mudam de formato sem aviso, têm limite de requisições por mês e ficam fora do ar. Se o Core consultasse direto, o limite da fonte viraria o limite do produto.
Workers separados coletam e publicam asset.price.updated e catalog.{stock,crypto}.updated. O Core mantém o catálogo e o preço como read model alimentado por esses eventos.
A falha de uma fonte fica contida no worker dela, e o Core nunca bloqueia esperando terceiro.
Rejeitada: custo mensal fixo antes de existir receita, para um produto em que o preço é informativo.
Rejeitada: o limite de requisições da fonte passaria a limitar o produto, e cada queda dela viraria erro na tela do usuário.
A meta é 10 mil usuários simultâneos num único nó auto-hospedado. A Home somava totais carregando entidades, com N+1 causado pelo open session in view.
Agregações em SQL, índice composto (party_id, created_at), OSIV desligado, relações LAZY, um endpoint de resumo para a Home e read models mensais. Réplica de leitura só entra se o k6 provar a necessidade.
O gargalo medido estava na forma de ler, não na topologia — o ganho é grande e a complexidade adicionada é pequena.
Rejeitada: dois modelos e um barramento de projeção para manter, num produto que ainda não tinha usuário.
Rejeitada: esconde a query ruim em vez de corrigi-la, e cria um problema de invalidação antes de existir um problema de carga.
Postergada, não rejeitada: entra se e quando o teste de carga mostrar que a leitura saturou o nó.
Uma compra de ativo pode falhar porque o ticker ainda não entrou no catálogo — uma condição do sistema, não um erro do usuário. Rejeitar nesse caso faz a pessoa perder uma intenção que ela já expressou.
Uma operação de compra que falha na validação vira um rascunho em asset_operation_drafts, com retentativa manual, retentativa automática a cada atualização de catálogo, cron diário de segurança, expiração em 30 dias e preservação do executedAt original.
Preserva a intenção e a data original da operação, e resolve sozinho quando o catálogo chega.
Rejeitada: transfere para o usuário um problema de catálogo que é do sistema.
Rejeitada: sem estado visível, o usuário não sabe se a compra existe, falhou ou está esperando.
Hoje os segredos vivem em arquivos .env no servidor, com permissão restrita. Funciona para um operador, mas não há rotação, histórico nem separação entre ambientes.
Injetar os segredos no momento do deploy via Infisical Cloud, com credencial distinta por banco e portas internas fechadas.
Proposta: rotação e histórico sem gastar memória no nó, que é o recurso escasso.
Rejeitada: mais um serviço com apetite de memória disputando o mesmo hardware.
É o estado atual, e o motivo desta proposta existir.
[06] Segurança e conformidade
Segurança não é um item de lista de recursos; é a primeira coisa que elimina um projeto. Abaixo, as perguntas auditáveis respondidas de forma direta — incluindo a pendência que ainda existe.
A base legal de cada finalidade, o prazo de retenção, a lista de operadores e o canal do encarregado estão na Política de Privacidade, escrita no mesmo nível de detalhe desta página.
[07] Desafios
Totais por período eram calculados carregando entidades, com N+1 por causa do OSIV.
Agregados em SQL, índice composto, OSIV desligado e um endpoint de resumo com read model mensal. O k6 define a meta: p95 < 300 ms com erros abaixo de 0,5%.
Validar o ticker de forma síncrona falhava para moedas novas.
Rascunhos com retentativa manual, automática e diária (cron de segurança). Ao resolver, o Core publica asset.draft.resolved pelo outbox, e o Telegram avisa o usuário.
O plano gratuito permite 15 mil requisições por mês, um ticker por chamada e 3 meses de candles diários.
Os candles se acumulam no Core, com orçamento diário de histórico configurável por ambiente. A busca é assíncrona por eventos, e a UI recebe atualizações via SSE por ticker.
Transações e parties paginam a partir de 1; metas, a partir de 0. O bot erraria a primeira página.
O cliente do Telegram encapsula a diferença por endpoint, documentada e coberta por teste, em vez de mudar o contrato do Core às pressas.
Gastos viram hábito só se o registro for quase sem fricção.
O cinto de categorias do app (slots de um toque) e o bot do Telegram com teclado de respostas e máquina de estados. Gráficos de ativos em Skia, com gestos e animação na thread de UI.
[08] Fluxo interativo
O fluxo de device authorization na prática — a implementação da ADR-003. Escolha um passo (ou use as setas do teclado) para ver quem faz o quê.
O usuário pede o vínculo no chat. O bot inicia o device flow no Zitadel com o cliente dedicado ao Telegram.
POST /oauth/v2/device_authorization client_id=vezta-telegram scope=openid offline_access
[09] Entrega e DX
Trabalhar sozinho em cinco repositórios remove o revisor par. O que substitui ele é processo: contrato antes do código, fronteira que quebra o build e esquema versionado.
README por repositório com os passos para subir o serviço e suas dependências, para que o projeto rode sem configuração adivinhada.
Em usoPipeline por repositório, executada a cada push: build, compilação verificada e testes antes do merge no branch principal.
Em usoUma issue-contrato por funcionalidade define endpoint, payload e evento antes da implementação — é o que substitui o code review quando se trabalha sozinho em cinco repositórios.
Em usoO Spring Modulith transforma dependência cíclica entre módulos em falha de build, não em descoberta tardia.
Em usoFlyway em cada serviço, com esquema evoluído por arquivo versionado e nenhuma alteração manual em produção.
Em usoEntrega faseada: domínio, depois identidade, depois o canal do Telegram, depois a coleta de cotações. Cada fase fechou antes de a seguinte começar.
Em usoScripts E2E contra a stack local completa cobrem onboarding de perfil, exclusão de conta e vínculo do Telegram.
Em usoCenários k6 com usuários de máquina do Zitadel: p95 < 300 ms, erros < 0,5%, queda de réplica e rampa de 0 a 10 mil. Escritos, aguardando ambiente.
Meta definidaOs cinco repositórios são privados porque o produto está em pré-lançamento e a configuração de infraestrutura vive perto do código. Isso cria um problema legítimo para quem avalia: nada aqui é verificável só lendo a página. Como resolvo isso:
Para solicitar: [email protected]
[10] Post-mortem
Nenhuma arquitetura sai certa. A parte útil de um projeto é a lista do que foi avaliado errado e da dívida que foi assumida sabendo o preço — com a condição que faz cada uma deixar de caber.
O app está disponível para Android. Qualquer decisão desta página pode ser questionada diretamente com quem a tomou — inclusive as que estão marcadas como erro.