Segunda-feira, 8h40. No canal compartilhado com um dos clientes da plataforma, um varejista que vende no crediário1 e terceiriza a cobrança, a head de cobrança escreve três perguntas seguidas:
Quantos contratos viraram 31+2 no fim de semana? Quanto disso tem promessa de pagamento3 para esta semana? E por que o cliente do protocolo 88213 recebeu três SMS no domingo?
São perguntas absolutamente normais para quem opera uma carteira4. E o time descobre, ao vivo, que nenhuma delas tem resposta barata.
A primeira exige um Scan na tabela inteira com filtro por dias de atraso, e o campo daysPastDue foi atualizado pela última vez na madrugada de sexta, porque o job de recálculo falhou no sábado. A segunda depende de um array promises dentro do item do contrato, que ninguém consegue consultar sem ler todos os contratos. A terceira nem está no banco: tentativas de contato viraram linhas de log no CloudWatch, com retenção de trinta dias e sem nada que identifique o devedor.
Nenhuma das três falhas é bug de código. São decisões de modelagem que nunca foram tomadas. Alguém escolheu uma chave de partição num refinamento, alguém adicionou um array “só por enquanto”, alguém decidiu que contato “é log”. Cada escolha respondeu à pergunta de engenharia do momento, como eu gravo isso?, e ignorou a pergunta do negócio: quem vai perguntar o quê sobre esse dado, com que frequência, e o que acontece se a resposta vier errada?
Em dezembro do ano passado escrevi Um breve mergulho no DynamoDB, sobre a mecânica: partição e ordenação, particionamento vertical, a conta de RCU e WCU. Este texto sobe um andar. Ele não é sobre como a chave funciona; é sobre como um tech lead ou arquiteto decide qual chave existe, qual índice merece nascer, qual atributo deve ser gravado e qual deve ser calculado, e quanto cada uma dessas decisões custa em dólar, em latência e em risco jurídico.
O fio condutor é um motor de cobrança oferecido como BaaS5: uma plataforma que atende dezenas de instituições (SCDs6, varejistas com crediário próprio, fintechs de consignado7), cada uma com sua carteira, sua política de encargos e sua régua8. Vou chamá-la de Lastro. Ela não existe, mas cada problema descrito aqui existe.
Antes de começar
Assumo que você conhece PK, SK, GSI e o modelo de cobrança por capacidade; este texto parte de onde o de dezembro parou. Os preços citados são da lista pública da AWS em us-east-1 (setembro de 2026). A região de São Paulo é mais cara. Use os números como ordem de grandeza, não como orçamento. Os exemplos de código estão em TypeScript com o AWS SDK v3. Termos de negócio marcados com um número pequeno têm explicação, com analogia, nas notas ao final.
1. Comece pela reunião que ninguém quer marcar
O guia de design NoSQL da AWS é direto num ponto que engenheiros formados no relacional subestimam: você não deveria começar o schema no DynamoDB antes de saber quais perguntas ele precisa responder. No relacional, você normaliza e deixa as perguntas para depois; o otimizador se vira. No DynamoDB, as estruturas são desenhadas sob medida para os casos de uso do negócio, e consultar fora deles é caro e lento.
A consequência organizacional é mais importante que a técnica. Se o schema é derivado das perguntas, então a lista de perguntas é o artefato de arquitetura mais importante do projeto. E ela não pode ser escrita só por engenheiros.
Imagine assim
Um banco relacional é um supermercado: tudo está em algum corredor, e você encontra qualquer coisa, desde que aceite andar. O DynamoDB é a cozinha de um restaurante com a mise en place pronta: cada ingrediente já está cortado e posicionado para os pratos do cardápio, e o prato sai em segundos.
Mas, se o cliente pedir algo que não está no cardápio, o cozinheiro precisa ir ao estoque e revirar prateleira por prateleira. Isso é o Scan. A lista de padrões de acesso é o cardápio, e cardápio não se define só na cozinha: define-se com quem conhece os clientes.
Em cobrança, as perguntas vêm de cinco atores com necessidades radicalmente diferentes: a mesa de cobrança9 (o operador buscando um devedor, a supervisora olhando a carteira por faixa de atraso), a régua automática (o scheduler que acorda e precisa saber o que fazer hoje), o próprio devedor (no app ou no portal de autoatendimento, querendo ver o que deve e fechar um acordo), a conciliação (o webhook de liquidação10 de Pix e boleto que precisa achar a cobrança em milissegundos) e o compliance (auditoria, ouvidoria, relatório regulatório).
A documentação pede que você entenda três propriedades de cada padrão de acesso: o tamanho do dado lido e gravado, a forma como ele precisa chegar e a velocidade, especialmente nos picos. Eu acrescento duas colunas que mudam tudo em cobrança: quem pergunta e qual é o custo de negócio se a resposta atrasar ou vier errada. O catálogo da Lastro ficou assim:
ID QUESTION ASKED BY VELOCITY / SHAPE
AP01 Everything about one debtor (360 view) operator, app ~40 req/s, one debtor, < 50 ms
AP02 Open installments of a contract, oldest first app, agreements ~120 req/s, < 30 ms
AP03 Contracts with a dunning action due today dunning scheduler daily burst, ~2M items in < 20 min
AP04 Promises due today (and broken yesterday) dunning scheduler daily, ~80k items
AP05 Portfolio by delinquency bucket, largest first supervisor desk ~5 req/s, paginated, < 200 ms
AP06 Find the charge behind a Pix txid / boleto number settlement webhook peaks ~800 req/s on paydays
AP07 Was this settlement already applied? settlement webhook every webhook, MUST BE EXACT
AP08 How many contacts did this debtor get today? contact gateway every attempt, MUST BE EXACT
AP09 Full audit trail of one installment compliance, ombuds rare, < 2 s
AP10 Contracts eligible for credit bureau reporting dunning scheduler daily
AP11 Monthly aging and roll rate of the portfolio finance, BI monthly -> NOT IN DYNAMODB
AP12 Regulatory and accounting reports compliance monthly -> NOT IN DYNAMODBTrês leituras dessa lista valem mais que qualquer diagrama.
A primeira está nas duas últimas linhas. A pergunta mais valiosa do workshop é “qual destas perguntas não vamos responder aqui?”. Aging, roll rate11 e relatórios regulatórios são perguntas analíticas, agregações sobre a carteira inteira. DynamoDB responde perguntas com chave; agregação sobre tudo é Scan, e Scan é o jeito mais caro de descobrir que você escolheu o banco errado para aquela pergunta. Essas perguntas vão para o S3 via export e são respondidas com Athena ou no lakehouse. Declarar isso no workshop evita que, seis meses depois, alguém crie um GSI sobre
status(quatro valores possíveis, cardinalidade de catástrofe) para alimentar um dashboard.A segunda: velocidade não é média, é forma. AP03 não é “25 requisições por segundo”; é dois milhões de itens numa janela curta e quase nada no resto do dia. AP06 não é uniforme: ele explode no quinto dia útil, no dia do pagamento do INSS12, na véspera de feriado. São esses picos, não a média, que vão definir sharding e capacidade.
A terceira: as linhas marcadas como exatas. AP07 e AP08 não toleram leitura eventualmente consistente. Uma liquidação aplicada duas vezes é dinheiro errado no saldo do cliente; um contato a mais pode ser uma ligação que a lei proíbe. Isso já diz, antes de qualquer desenho, que essas perguntas não podem ser respondidas por GSI, que só oferece leitura eventualmente consistente. Voltaremos a isso na seção 4.
Ferramenta para o workshop
A AWS mantém o NoSQL Workbench, que permite desenhar o modelo com dados de exemplo e executar as queries antes de existir uma linha de código. Use na reunião, com a supervisora de cobrança presente: ver o próprio “devedor de teste” aparecer inteiro numa única query convence mais do que qualquer slide.
Pense comigo
Se a gerente de cobrança não estava na sala quando vocês listaram os padrões de acesso, quem representou o negócio?
Muito provavelmente, o ORM. E o ORM só conhece uma pergunta: “me dá a entidade por ID”.
Pontos altos
Em DynamoDB, a lista de perguntas vem antes da tabela. Ela é o cardápio que organiza a cozinha, e se escreve junto com o negócio.
Para cada pergunta, anote quatro coisas: quem pergunta, em que formato a resposta precisa chegar, qual é o pico de uso (não a média) e o que acontece se a resposta vier errada.
Decida em voz alta o que a tabela não vai responder. Relatórios que olham a carteira inteira vão para o S3, não para um Scan.
Perguntas que não podem errar nunca, como pagamento aplicado em dobro ou contato acima do limite legal, não podem depender de índice secundário.
2. Cada atributo é uma afirmação sobre o mundo
Um item no DynamoDB não tem schema imposto além da chave. Isso é liberdade para o código e perigo para o negócio: nada impede que o mesmo conceito exista com três nomes, em três formatos, em itens diferentes. A disciplina que o banco não impõe precisa vir do modelo.
A regra que uso: cada atributo deve ser uma afirmação verificável sobre a realidade do negócio, com significado único, unidade explícita e dono claro. Vamos aos dois itens centrais da Lastro, o cabeçalho do contrato e a parcela.
// CONTRACT header: one per contract, inside the debtor's item collection
{
"PK": "T#t042#D#9f2c1a7e4b0d3e61",
"SK": "CT#C-2026-004512",
"type": "CONTRACT",
"tenantId": "t042",
"contractId": "C-2026-004512",
"productCode": "CREDIARIO_12X",
"currency": "BRL",
"chargesPolicy": "CP#v3",
"status": "IN_COLLECTION",
"statusReason": "OLDEST_INSTALLMENT_OVERDUE",
"oldestOverdueDueDate": "2026-08-10",
"bucket": "D031_060",
"outstandingCents": 292740,
"nextActionDate": "2026-09-24",
"nextAction": "SMS_D45",
"shard": 7,
"version": 22,
"updatedAt": "2026-09-23T03:12:44Z"
}
// INSTALLMENT: one per installment, sorted under its contract
{
"PK": "T#t042#D#9f2c1a7e4b0d3e61",
"SK": "CT#C-2026-004512#IN#007",
"type": "INSTALLMENT",
"seq": 7,
"originalDueDate": "2026-08-10",
"dueDate": "2026-08-10",
"principalCents": 41230,
"interestCents": 7560,
"fineCents": 0,
"lateInterestCents": 0,
"paidCents": 0,
"status": "OVERDUE",
"version": 9
}A primeira linha dos dois itens, "PK": "T#t042#D#9f2c1a7e4b0d3e61", parece críptica, e é de propósito. Por ora, leia assim: “instituição t042, devedor cujo CPF, embaralhado, virou o código 9f2c1a7e4b0d3e61”. Na seção 3 desmonto essa chave peça por peça e mostro como se chega a ela.
Nada nesses dois itens está ali por acaso. Vamos por partes.
Atraso é do contrato; vencimento é da parcela
A primeira decisão é puramente de negócio: dias de atraso não pertencem à parcela. No mercado, o atraso de um contrato é medido pela parcela mais antiga em aberto, e é esse número que define faixa, régua, provisão13 e negativação14. Por isso oldestOverdueDueDate e bucket moram no cabeçalho do contrato, e a parcela guarda apenas o próprio vencimento e o próprio status.
Se você colocar faixa de atraso em cada parcela, o contrato do João aparece três vezes na carteira da supervisora (uma por parcela vencida) e, cedo ou tarde, alguém soma o saldo em triplo num relatório para a diretoria.
Grave o fato, derive o estado
Repare no que não está no item: daysPastDue. Dias de atraso mudam todo dia, para todo contrato inadimplente, sem que nada tenha acontecido no mundo. Gravar esse número exige uma escrita por contrato por dia. Chamo isso de escrita sem evento, e ela é o vazamento de custo e de risco mais comum que já vi em motores de cobrança.
O fato é oldestOverdueDueDate. Ele só muda quando algo acontece: um pagamento quita a parcela mais antiga, um acordo reestrutura o contrato. Dias de atraso são derivados na leitura, hoje menos essa data. No exemplo, em 24 de setembro o contrato está com 45 dias, exatamente o gatilho do SMS_D45. É um cálculo de uma linha que nunca fica desatualizado porque o job da madrugada falhou.
Já bucket é estado derivado gravado, e a exceção é deliberada. Ele precisa existir como atributo porque é chave de um índice (a carteira por faixa, que veremos adiante) e muda raramente: no máximo uma vez a cada trinta dias por contrato. A regra geral fica assim: estado derivado só merece virar atributo quando é necessário numa chave e muda muito menos do que é lido. Na seção 5 essa decisão vira dinheiro.
Imagine assim
O seu RG não traz a sua idade. Traz a sua data de nascimento. A idade muda todo ano sem que nada aconteça com você; a data de nascimento nunca muda. Se o documento trouxesse a idade, o país inteiro precisaria reemitir o RG a cada aniversário, e sempre haveria documentos errados circulando.
oldestOverdueDueDate é a data de nascimento do atraso; daysPastDue é a idade. E o bucket? É a série escolar. Dá para calcular pela idade, mas a escola grava a série porque é por ela que organiza as salas (o índice), e ela só muda uma vez por ano.

O vencimento original é um fato; o vencimento vigente é uma decisão
originalDueDate nunca muda. dueDate muda quando há prorrogação15. Parece redundante até alguém do risco perguntar quanto da carteira de agosto foi prorrogada e quantas dessas prorrogações acabaram pagas. Se você sobrescreveu, perdeu a resposta. Pense numa passagem aérea remarcada: o bilhete precisa mostrar o voo original e o novo, senão ninguém sabe depois se o passageiro se atrasou ou se a companhia mudou o horário. É o mesmo princípio do artigo sobre modelagem de dados: você guardou o resultado e jogou fora a causa.
E vencimento é data civil, não instante: "2026-08-10", nunca "2026-08-10T00:00:00Z". Meia-noite UTC é 21h do dia anterior em Brasília; um vencimento gravado como timestamp vai, em algum relatório, aparecer um dia antes. Pior: a regra de mercado que permite pagar sem encargos no próximo dia útil um boleto que vence em feriado é uma regra de calendário, não de relógio. Modele o conceito que o negócio usa.
Dinheiro tem unidade, e encargo tem origem
Valores em centavos inteiros, com a moeda explícita no contrato. O tipo Number do DynamoDB é decimal de alta precisão, então o problema não está no banco; está no caminho: JSON parseado como double num cliente JavaScript, uma biblioteca que converte para float no meio de um mapper. Inteiro em centavos não tem ambiguidade em nenhuma camada.
A decomposição em principalCents, interestCents, fineCents e lateInterestCents não é preciosismo. Ela existe porque o negócio precisa provar duas coisas: a ordem de imputação do pagamento16 (o art. 354 do Código Civil manda imputar primeiro nos juros vencidos e depois no capital, salvo estipulação em contrário) e o respeito ao teto da multa moratória17 em relações de consumo, de 2% pelo art. 52, §1º, do CDC. Um único amountCents não prova nenhuma das duas.
Note que multa e juros de mora aparecem zerados na parcela vencida. Não é esquecimento. Encargo de atraso é calculado quando vira fato (na liquidação ou no fechamento de um acordo18) a partir da política vigente, referenciada no contrato por chargesPolicy: "CP#v3". A política é um item versionado na própria tabela. Quando o tenant mudar a regra em março, os contratos antigos continuam apontando para a versão sob a qual foram assinados. Recalcular encargo todo dia seria, de novo, escrita sem evento.
Regra de negócio é dado versionado, não um
ifque alguém alterou num PR de sexta-feira.

O estado não carrega contexto
status e statusReason são atributos separados, pelo mesmo princípio que defendi no artigo sobre máquinas de estado em crédito: IN_COLLECTION é um estado; o motivo é atributo. Criar IN_COLLECTION_BY_OVERDUE e IN_COLLECTION_BY_BROKEN_AGREEMENT gera explosão combinatória na máquina, nos índices e nos relatórios.
A régua e o pagamento vão correr um contra o outro
Às 00h07 o scheduler está reagendando a próxima ação do contrato. Às 00h07 o webhook de Pix está aplicando um pagamento no mesmo contrato. Sem controle de concorrência, o último a escrever vence, e o contrato quitado ganha um nextAction de SMS. O atributo version, com toda escrita condicionada a version = :expected, transforma essa corrida num erro explícito e retentável em vez de um dado silenciosamente errado. Imagine dois atendentes editando a mesma ficha em papel: quem entrega por último apaga, sem saber, o que o outro escreveu. O version é o número da página no rodapé da ficha: se ele mudou desde que você a pegou, devolva e pegue a versão nova.
Nome de atributo custa bytes, mas legibilidade custa mais
O nome do atributo entra no tamanho do item; a AWS documenta isso nas restrições do serviço. Esse fato levou muita gente a nomes como a1, dd, pc. Não faça isso. O item precisa ser legível por quem vai investigar um incidente às 3h da manhã direto no console. O equilíbrio é nome curto e semântico: dueDate, e não installmentCurrentDueDateLocal nem dd. Na seção 5 fica claro que a economia de bytes que importa vem de outro lugar.
Chaves sintéticas estão saindo dos índices
Até novembro de 2025, um GSI tinha exatamente um atributo de partição e um de ordenação. Isso obrigava a concatenar valores em strings como GSI1PK = "T#t042#ACT#2026-09-24#07" e, pior, a fazer backfill da tabela inteira toda vez que um índice novo exigia uma chave nova. Desde então, GSIs aceitam até quatro atributos na partição e quatro na ordenação, usando os atributos naturais do item, sem custo adicional.
Isso é mudança de modelagem, não só de sintaxe: índices novos podem nascer a partir de atributos que já existem, e o item deixa de carregar strings que só servem ao banco. É por isso que tenantId aparece duas vezes no contrato, dentro da PK e como atributo próprio: é o preço, barato, de índices com chaves naturais. A chave primária da tabela base continua com um atributo de cada, então PK e SK sintéticos continuam existindo ali. Antes de adotar, confira se o seu SDK e o seu mapper já suportam o recurso.
Pense comigo
Em 2029, uma ação judicial pergunta por que a parcela 7 do contrato
C-2026-004512foi liquidada com R$ 9,75 de multa. Qual atributo responde?Se a resposta for “a gente recalcula com a regra atual”, você não tem uma resposta. Tem uma estimativa, e estimativa não se defende.
Pontos altos
O atraso pertence ao contrato e é medido pela parcela mais antiga em aberto. O vencimento pertence a cada parcela.
Grave a data de nascimento, não a idade: guarde o que muda quando algo acontece e calcule o que muda só porque o dia virou.
Um valor calculado só vira campo gravado quando organiza um índice e muda raramente, como a série escolar.
Vencimento é data do calendário, não hora do relógio. Dinheiro é número inteiro em centavos, separado pela origem: principal, juros, multa.
Regras de encargo são versionadas, e cada contrato aponta para a versão sob a qual foi assinado.
Toda escrita que pode concorrer com outra confere o número da versão. Nomes de campo curtos, mas que qualquer pessoa entenda no console.
3. Num BaaS, a chave de partição é uma decisão de isolamento
A documentação da AWS descreve um bloco de multi-tenancy em que todos os dados de um tenant ficam na mesma coleção de itens, com o tenant como chave de partição. Funciona muito bem para muitos tenants pequenos e parecidos, e oferece de brinde uma exclusão barata quando o tenant vai embora.
Num BaaS de cobrança, é uma armadilha. O maior tenant da Lastro tem 60% dos contratos. Toda a escrita da régua dele cairia numa única chave, sujeita ao limite físico de throughput por partição (mil unidades de escrita por segundo). As alternativas óbvias também falham. CPF puro como PK mistura tenants: a mesma pessoa pode dever para o varejista e para a SCD, e cada instituição é controladora19 do próprio dado perante a LGPD, então a coleção do devedor não pode ser compartilhada. Contrato como PK fragmenta o devedor: a visão 360 vira N consultas.
A escolha: PK = "T#" + tenantId + "#D#" + debtorKey. O tenant garante isolamento; o devedor garante cardinalidade.
Imagine assim
Pense na plataforma como um prédio comercial. Usar o tenant como chave é mandar todos os funcionários da maior empresa do prédio para um único elevador: no horário de pico, ninguém sobe. Usar só o CPF é guardar na mesma gaveta os papéis que duas empresas diferentes têm sobre a mesma pessoa, quando cada uma só pode ver os seus.
Tenant mais devedor é cada empresa no seu andar, com um armário por cliente. Ninguém disputa elevador, e nenhum papel vai parar no andar errado.
Não coloque CPF na chave
Minha proposta de dezembro usava o CPF na chave. Hoje eu não faria isso, e a razão é operacional, não teórica: chaves vazam. Elas aparecem nas mensagens de erro que vão para o log, nos registros do stream que alimentam outros sistemas, nos payloads da DLQ e no CloudWatch Contributor Insights, cuja função é justamente listar as chaves mais acessadas. CPF em chave é CPF em todo lugar.
debtorKey é um HMAC-SHA256 do documento, com um segredo por tenant, truncado:
import { createHmac } from "node:crypto";
// Same CPF, different tenants -> different keys. No index needed to find a debtor.
export function debtorKey(tenantSecret: Buffer, document: string): string {
const normalized = document.replace(/\D/g, "");
return createHmac("sha256", tenantSecret)
.update(normalized)
.digest("hex")
.slice(0, 16);
}Três propriedades importam. É determinístico: o operador digita o CPF, o serviço calcula a chave e faz uma Query direta, sem índice por CPF. Não é reversível sem o segredo. E o mesmo CPF gera chaves diferentes em tenants diferentes, o que impede correlacionar carteiras. Funciona como a ficha da chapelaria de um restaurante: o número encontra o seu casaco na hora, mas não revela o seu nome a quem olha a ficha, e outro restaurante daria outro número para a mesma pessoa. O documento em si fica como atributo, criptografado no cliente; o AWS Database Encryption SDK faz criptografia por atributo exatamente para esse cenário.
O segredo é parte da chave
Escolha e guarde esse segredo com o mesmo cuidado com que se escolhe uma chave de partição. Rotacioná-lo significa recalcular a PK de todos os devedores do tenant, ou seja, migrar a tabela. Planeje versionamento do segredo desde o primeiro dia, ou aceite conscientemente que ele não roda.
Anatomia da chave, peça por peça
Com as duas decisões tomadas (tenant mais devedor, e CPF nunca em claro), a chave se monta em quatro passos. Vamos seguir um CPF fictício, 123.456.789-09, de um devedor do tenant t042:
step 1 normalize the document "123.456.789-09" -> "12345678909"
step 2 HMAC-SHA256 with t042's secret -> "9f2c1a7e4b0d3e61a84c…e07b" (64 hex chars)
step 3 keep the first 16 characters -> "9f2c1a7e4b0d3e61" (debtorKey)
step 4 add tenant and entity prefixes -> "T#" + "t042" + "#D#" + "9f2c1a7e4b0d3e61"
PK = "T#t042#D#9f2c1a7e4b0d3e61"Cada pedaço da chave responde a uma pergunta: de qual instituição, de que tipo de coisa, de quem.
Do geral para o específico, como um endereço. Você escreve um endereço começando pelo estado, depois a cidade, a rua e o número, e o carteiro vai estreitando a busca a cada pedaço. A chave faz o mesmo: primeiro a instituição, depois o tipo de coisa, depois quem. Essa ordem não é estética. É ela que permite dizer ao IAM “este papel só pode tocar chaves que começam com T#t042#“, porque prefixo é a única coisa que uma regra de acesso consegue comparar.
Prefixos dizem o que vem depois. A mesma tabela guarda famílias de chave diferentes, e o prefixo é o que impede que elas se confundam:
T#t042#D#9f2c1a7e4b0d3e61 debtor item collection (profile, contracts, installments...)
T#t042#CHG#<txid> charge lookup (section 4)
T#t042#STL#<settlementId> settlement idempotency (section 4)Sem o D#, CHG# e STL#, nada impediria que o identificador de uma liquidação coincidisse, por acaso, com o código de um devedor. Com o prefixo, a colisão entre famílias é impossível por construção, e quem abre o console às 3h da manhã sabe o que está olhando sem ler o código.
Por que # como separador? Porque esse caractere nunca aparece nos identificadores do negócio, e porque é a convenção que a própria documentação da AWS usa nas chaves compostas. Qualquer caractere que não apareça nos valores serviria; o importante é escolher um e nunca mais mudar.
Por que letras tão curtas? A chave se repete em todos os itens da coleção e em todos os índices que a projetam, e o tamanho dela entra na conta de cada item. T e D bastam, desde que o time tenha o dicionário por escrito, no README ou no ADR:
T tenant CT contract
D debtor IN installment
CHG charge (Pix / boleto) AG agreement
STL settlement EV event (audit trail)
PROFILE debtor profile LIMIT daily contact counter
CONTACT contact attempt PROMISE payment promiseUma chave curta só é legível se existir o dicionário. Sem ele, T#t042#D# vira hieróglifo no primeiro rodízio do time.
Por que o tenant aparece em claro e o devedor não? O identificador da instituição não é dado pessoal e precisa estar legível para a regra de acesso e para a operação. Quando o Contributor Insights mostrar uma chave quente, T#t042 diz na hora qual cliente está gerando a carga, enquanto o código do devedor esconde quem é a pessoa. Você sabe qual cliente está pegando fogo sem saber o nome de ninguém.
Por que só 16 caracteres do hash? Dezesseis caracteres hexadecimais são 64 bits, um espaço de 18 quintilhões de valores. Para um tenant com 10 milhões de devedores, a chance de dois CPFs diferentes gerarem o mesmo código fica na ordem de uma em algumas centenas de milhares: baixa, mas não zero. Por isso o cadastro do devedor grava o item PROFILE com o documento criptografado e, ao criar um devedor novo, confere se o documento bate com o que já está lá. Uma colisão improvável vira um erro detectável, e nunca dois devedores misturados na mesma pasta. Se você preferir não conviver com esse cenário, use 20 caracteres (80 bits): a chance cai para algo desprezível, ao custo de quatro bytes a mais por item.
Isolamento no IAM, não só no código
Com o prefixo do tenant na chave, o isolamento pode ser imposto pela política de acesso. A condição dynamodb:LeadingKeys restringe quais valores de partição um papel pode tocar. A camada de API assume um papel com o tenant como tag de sessão, e a política só aceita chaves que comecem com o prefixo daquele tenant:
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:PutItem", "dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:*:*:table/collections",
"Condition": {
"ForAllValues:StringLike": {
"dynamodb:LeadingKeys": ["T#${aws:PrincipalTag/tenantId}#*"]
}
}
}Um bug que monte a chave do tenant errado vira AccessDeniedException, não vazamento de dados entre instituições. Num BaaS regulado, essa diferença cabe inteira num parecer de segurança.
O preço dessa escolha: offboarding
Nenhuma chave é grátis. Com o tenant como partição, apagar um tenant seria uma Query seguida de deletes. Com o devedor como partição, os dados de um tenant estão espalhados por milhões de coleções. Quando um tenant encerra o contrato e pede exclusão, você precisa enumerá-los: um export filtrado, ou um GSI esparso com projeção KEYS_ONLY sobre os itens PROFILE, particionado por tenantId. Registre esse custo no ADR. Trade-off não documentado é dívida com data de vencimento desconhecida.
A coleção do devedor
Tudo o que a plataforma sabe sobre um devedor, num tenant, mora na mesma coleção de itens, ordenado pela sort key. Pense numa pasta suspensa de arquivo com abas separadoras em ordem alfabética: puxar a pasta inteira é a visão 360; abrir direto na aba das parcelas é um begins_with.
Os prefixos da sort key não são estética: são o plano de consultas. Cada faixa contígua é uma pergunta de negócio respondida com uma única Query.
A visão 360 (AP01) é uma Query na partição, sem filtro. As parcelas do contrato (AP02) são uma Query com begins_with no prefixo #IN#. A trilha de auditoria da parcela 7 (AP09) é outra, no prefixo #EV#007#. Os prefixos foram escolhidos para que a ordem alfabética coincida com a necessidade de leitura: eventos ficam antes das parcelas porque E vem antes de I, e por isso o prefixo das parcelas nunca devolve eventos por acidente.
Um detalhe que já gerou chamado em produção: a sort key é string, então a sequência precisa de zeros à esquerda. Sem eles, IN#10 vem antes de IN#2, e o app mostra a parcela dez antes da segunda. Ordem lexicográfica é regra de negócio disfarçada de detalhe técnico.
Histórico não mora dentro do item
A tentação é óbvia: um array statusHistory dentro da parcela. O problema técnico é que, numa atualização, o DynamoDB cobra a escrita pelo maior tamanho entre o item antes e depois da mudança. Cada update na parcela passa a pagar pelo histórico inteiro. É como levar o álbum de família completo ao cartório toda vez que você só precisa trocar a foto do documento. Uma parcela com dezoito meses de eventos passa de 1 KB e dobra o custo de toda escrita futura, inclusive a do pagamento. O problema de negócio é que o array não é consultável: a auditoria vira “baixar tudo e procurar”.
A saída é o particionamento vertical que a própria AWS recomenda: eventos como itens irmãos (#EV#), append-only. Cada evento custa uma escrita pequena, uma única vez, e só é lido quando alguém pergunta.
Cuidado com LSI
Um índice secundário local só pode ser criado junto com a tabela e, quando existe, impõe um limite de 10 GB a cada coleção de itens. A coleção de um devedor antigo, com anos de contatos e eventos, é exatamente o tipo de coisa que cresce sem ninguém olhar. Prefira GSI, que pode nascer depois e não impõe esse teto.
Pense comigo
Se o seu maior tenant dobrar de tamanho amanhã, qual chave esquenta primeiro? Se a resposta for “a do tenant”, o seu modelo tem um teto de crescimento que ninguém escreveu em lugar nenhum, e ele vai ser descoberto no pior dia possível.
Pontos altos
Num BaaS, a chave junta instituição e devedor: cada inquilino no seu andar, cada devedor no seu armário.
Documento pessoal nunca vai na chave. Use um código derivado (HMAC), como a ficha da chapelaria: encontra o casaco sem expor o nome.
O isolamento entre instituições é garantido pela política de acesso da AWS, e não só pelo cuidado do código.
Toda chave tem um preço. O desta é o trabalho de remover um tenant inteiro; escreva isso no ADR.
A ordem das abas da pasta é planejada: prefixos pensados para as perguntas e números com zeros à esquerda.
Histórico vira folhas novas na pasta, nunca um anexo que cresce dentro do mesmo item.
4. Índices secundários são respostas, não atalhos
Cada GSI é uma cópia parcial da tabela, com capacidade, custo de escrita e custo de armazenamento próprios. Por isso cada índice deve nascer com o nome da pergunta que responde. Na Lastro, são dois índices de negócio e dois tipos de item de lookup, e a escolha entre um e outro diz muito sobre maturidade de modelagem.
GSI1: a régua do dia é um índice esparso
GSI1 dunning-agenda sparse: only items that have nextActionDate
partition key tenantId (S) + nextActionDate (S) + shard (N)
sort key nextAction (S) + contractId (S)
projection INCLUDE debtorKey, bucket, outstandingCents, channelPrefsA AWS descreve o índice esparso como um bloco de construção: só entram no GSI os itens que possuem os atributos de chave. Com chaves de múltiplos atributos vale a mesma regra; se faltar qualquer componente, o item fica de fora. Na Lastro, apenas contratos em cobrança ativa e promessas pendentes têm nextActionDate.
O efeito é elegante. Quando um pagamento cura o contrato, a mesma transação que baixa a parcela remove nextActionDate e nextAction, e o contrato sai da régua. Não existe “job para limpar a régua”. Sair da régua é remover um atributo. A modelagem está dizendo, em estrutura de dados, uma regra de negócio: régua só existe para quem deve.
Imagine assim
A régua é o quadro de post-its na porta da geladeira. Só vai para o quadro o que precisa ser feito, cada post-it com a sua data. Quando a tarefa acaba, você arranca o post-it; ninguém precisa “fazer faxina no quadro” no fim do mês.
A tabela é a casa inteira. O índice esparso é só a porta da geladeira, e é por isso que ele é pequeno, barato e rápido de ler.
Promessas de pagamento usam o mesmo índice, com nextAction = PROMISE_CHECK na data prometida. A elegibilidade para negativação (AP10) também: BUREAU_REPORT é só mais uma ação agendada, na data em que o contrato passa a ser elegível pela política do tenant. Um índice, três perguntas, porque as três têm a mesma forma: o que precisa acontecer para este tenant nesta data. E, como o sort key começa por nextAction, o worker que só envia SMS consulta só os SMS.
Há ainda um benefício de custo que a documentação destaca: um índice esparso só cobra escrita e armazenamento dos itens que entram nele. A régua indexa os 25% da carteira em cobrança, não os 100%.

O shard é a unidade de paralelismo do seu batch
Primeiro, a palavra. Shard, em inglês, é caco, fragmento, o pedaço de algo que se partiu. Em banco de dados, fazer sharding é dividir de propósito um volume grande em pedaços menores, guardados em lugares diferentes, para que nenhum pedaço receba trabalho demais sozinho. Na Lastro, o shard é apenas um número de 0 a 15 gravado no contrato, que diz em qual dos dezesseis pedaços do índice aquele contrato mora.
Sem o atributo shard, a partição do índice seria apenas tenant e data. Toda madrugada, depois de executar as ações do dia, o scheduler reagenda a próxima ação de cada contrato. Mudar nextActionDate é mudar a chave do índice, e para o GSI isso significa remover o item de uma posição e inseri-lo em outra. No maior tenant, são cerca de 1,2 milhão de contratos convergindo na mesma chave de “amanhã”.
E aqui mora o detalhe que transforma um problema de performance num incidente de negócio. A AWS avisa que throttling de escrita num GSI devolve back-pressure para a tabela base: quando o índice não aguenta, as escritas na tabela falham. Quem mais escreve na tabela base às 00h07? O webhook de liquidação.
O cliente que pagou o Pix à meia-noite para “limpar o nome” gera um webhook que toma throttling, os retries se esgotam, a mensagem cai na DLQ e a baixa só entra às 9h30, no reprocessamento manual. Às 8h, a régua mandou SMS de cobrança para quem já tinha pago. Reclamação no Procon, post no Reclame Aqui, risco de dano moral. Tudo porque um índice de agenda estava quente.
Imagine assim
É o engarrafamento na saída do estacionamento de um shopping que tranca a rua por onde passa a ambulância. O estacionamento (a régua, que pode esperar) não tem nada a ver com o hospital (o pagamento, que não pode esperar), mas os dois usam a mesma rua: a tabela base.
A saída é o write sharding com sufixo calculado: shard = hash(contractId) mod N. O N sai de uma conta simples, o pico de escritas por segundo esperado no índice dividido por mil, com folga para crescimento. O sufixo é calculado, não aleatório, para que qualquer contrato saiba sempre em que shard está.

O insight que costuma passar despercebido: o shard não é gambiarra de banco. É exatamente a unidade de paralelismo que o seu batch já queria. Às 8h, dezesseis workers fazem, cada um, uma Query paginada no próprio shard. Sem coordenação, sem locks, sem a eterna pergunta “quem pegou este contrato?”. O desenho que protege a partição é o mesmo que distribui o trabalho. Pense nos caixas de um supermercado: com uma fila única, tudo trava; com dezesseis caixas e uma regra fixa para escolher o caixa (o final do número do contrato, digamos), a fila anda, e cada caixa já tem o seu operador.
Sobre a projeção: INCLUDE apenas com o que o worker precisa para decidir e agir. Cada atributo projetado é armazenamento replicado e custo extra em toda escrita que o altere. Projetar ALL “por garantia” duplica a tabela.
GSI2: a carteira da supervisora
GSI2 portfolio-by-bucket sparse: only CONTRACT items that have bucket
partition key tenantId (S) + bucket (S)
sort key outstandingCents (N) + contractId (S)
projection INCLUDE debtorKey, oldestOverdueDueDate, productCode, lastContactAtUma Query com ScanIndexForward = false devolve os maiores saldos primeiro; os dias de atraso são calculados na tela a partir de oldestOverdueDueDate. É aqui que a decisão da seção 2 aparece com clareza. Se daysPastDue fosse chave de ordenação, cada contrato inadimplente mudaria de posição no índice todos os dias. Com a faixa na partição, o contrato se move no máximo uma vez por mês. Nunca coloque numa chave de índice um atributo que muda com o calendário.
outstandingCents na ordenação muda com pagamento parcial, o que é raro e até desejável: o contrato desce na lista. A partição tem baixa cardinalidade (poucas faixas por tenant), e a documentação recomenda considerar sharding também em GSIs assim. Aqui o volume de escrita é baixo, então a decisão foi começar sem shard e alarmar as métricas de throttling do índice. Nem todo índice precisa de shard; todo índice precisa de alarme.
Quando a mesa quer filtrar por tudo
Se a supervisora pedir filtros livres (produto, cidade, faixa, canal, último contato, score), a resposta não é GSI3, GSI4 e GSI5. É um motor de busca, como o OpenSearch, alimentado pelo stream da tabela. DynamoDB é excelente em perguntas conhecidas e ruim em perguntas abertas. O erro de arquitetura é tentar transformá-lo, índice a índice, no que ele não é.
Onde GSI não serve: exatidão
GSI só oferece leitura eventualmente consistente. Na maior parte do tempo a propagação é rápida, mas “na maior parte do tempo” não é contrato. O GSI é o quadro de avisos que uma secretária atualiza copiando do livro oficial: quase sempre em dia, mas, quando o assunto é dinheiro, você consulta o livro. No Pix, o QR Code é gerado e pago em segundos. Se o webhook procurar a cobrança pelo txid19num GSI, existe uma janela em que a cobrança recém-criada ainda não chegou ao índice. “Cobrança não encontrada” num webhook de liquidação é dinheiro sem dono.

Por isso AP06 e AP07 são respondidos por itens de lookup na tabela base, lidos com consistência forte:
// Written in the same transaction that creates the charge
PK = "T#t042#CHG#<txid or boleto number>" SK = "CHG"
-> debtorKey, contractId, seq, amountCents, expiresAt
// Written in the same transaction that applies the payment
PK = "T#t042#STL#<settlementId>" SK = "STL"
-> amountCents, receivedAt, appliedToO item de liquidação é a idempotência. Pense no carimbo de PAGO num boleto de papel: se a mesma via voltar ao caixa, o carimbo já está lá, e ninguém recebe duas vezes. O webhook vai chegar duas vezes: o provedor reenviou, o consumer reprocessou, alguém rodou o replay da DLQ. A transação que aplica o pagamento inclui um Put condicionado à inexistência desse item. Na segunda tentativa, a transação inteira é cancelada e o saldo não é baixado duas vezes.
import { TransactWriteCommand } from "@aws-sdk/lib-dynamodb";
// Settlement webhook: apply a payment exactly once
await doc.send(new TransactWriteCommand({
TransactItems: [
{
// 1. Idempotency: the settlement id IS the key
Put: {
TableName: TABLE,
Item: {
PK: `T#${tenantId}#STL#${settlementId}`,
SK: "STL",
type: "SETTLEMENT",
amountCents,
receivedAt,
appliedTo: `${contractId}#${pad3(seq)}`,
},
ConditionExpression: "attribute_not_exists(PK)",
},
},
{
// 2. Installment: optimistic lock on version
Update: {
TableName: TABLE,
Key: { PK: debtorPk, SK: `CT#${contractId}#IN#${pad3(seq)}` },
UpdateExpression:
"SET paidCents = paidCents + :amt, #status = :newStatus, version = version + :one",
ConditionExpression: "version = :expected",
ExpressionAttributeNames: { "#status": "status" },
ExpressionAttributeValues: {
":amt": amountCents,
":newStatus": installmentTransition.to, // decided by the state machine, not here
":one": 1,
":expected": installmentVersion,
},
},
},
// 3. Contract header: new balance and delinquency fact.
// When cured: REMOVE oldestOverdueDueDate, #bucket, nextActionDate, nextAction
// -> the contract leaves the dunning agenda and the bucket portfolio in the same commit.
{ Update: contractUpdate(contract, installmentTransition, expectedContractVersion) },
],
}));Por que não usar o ClientRequestToken?
TransactWriteItems aceita um token de idempotência, mas ele vale por uma janela de dez minutos. Provedores de pagamento reenviam webhooks por horas; replays de DLQ acontecem dias depois. O token protege o retry do SDK. A idempotência de negócio precisa morar na chave, com a vida útil do próprio fato.
Limite de contato: a data na chave garante a regra; o TTL só faz a limpeza
No artigo sobre resiliência em sistemas de cobrança, falei do “guarda de trânsito” da régua: um limite diário de contatos por devedor, independentemente do canal. Na Lastro, ele é um item na coleção do devedor, incrementado de forma atômica e condicional:
import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
// Contact gateway: atomically claim one of today's allowed contacts
await doc.send(new UpdateCommand({
TableName: TABLE,
Key: { PK: debtorPk, SK: `LIMIT#${localDate}` }, // civil date in America/Sao_Paulo
UpdateExpression: "SET expiresAt = if_not_exists(expiresAt, :ttl) ADD attempts :one",
ConditionExpression: "attribute_not_exists(attempts) OR attempts < :max",
ExpressionAttributeValues: {
":one": 1,
":max": policy.maxDailyContacts,
":ttl": toEpochSeconds(addDays(localDate, 8)), // housekeeping only
},
}));
// ConditionalCheckFailedException => do NOT contact. It is not "retry later".Dois detalhes carregam o negócio. Primeiro, a data na chave é local, no fuso de São Paulo: o limite legal é por dia civil do devedor, não por dia UTC. Segundo, o TTL. Deletes por TTL são gratuitos, mas assíncronos, e a documentação avisa que podem levar mais de 48 horas. Se a correção dependesse do TTL (um único item LIMIT que “expira” à meia-noite), o devedor poderia ficar bloqueado por dois dias, ou o contador poderia zerar tarde demais. Com a data na chave, o contador de amanhã é outro item, e o TTL só varre o lixo. TTL é ferramenta de custo, nunca de correção. O TTL é o caminhão de lixo, que passa sem horário fixo. Ninguém espera o caminhão para saber se o leite venceu: a data está impressa na embalagem. Aqui, a data está na chave.
Cada tentativa também grava um item CONTACT#{timestamp} com canal, template e resultado. É prova, não log. A pergunta “por que ele recebeu três SMS no domingo?” deixa de ser uma investigação e vira uma Query.
A imagem acima é o mapa que deveria estar anexado a todo ADR de modelagem em DynamoDB: pergunta de um lado, estrutura do outro, e nenhuma linha solta.
Pense comigo
Qual dos seus índices, se ficasse lento amanhã às 00h07, impediria um cliente de pagar?
Se você não sabe responder, abra agora o painel de métricas dos seus GSIs. Não depois do incidente.
Pontos altos
Cada índice existe para responder uma pergunta com nome e dono.
Índice esparso é o post-it na geladeira: sair da régua é arrancar o post-it, não rodar um job de limpeza.
Um índice sobrecarregado trava as escritas da tabela principal. A régua, que pode esperar, pode impedir o registro de um pagamento, que não pode.
O shard espalha a carga como os caixas de um supermercado e, de brinde, define quantos workers o batch usa.
Nunca use como chave de índice um valor que muda só porque o calendário andou.
O que precisa ser exato (pagamento aplicado uma única vez, limite legal de contatos, cobrança recém-criada) fica na tabela principal, lido com consistência forte.
O TTL é o caminhão de lixo: limpa, mas não decide o que venceu. A regra mora na data da chave.
Quando a operação quer filtrar por qualquer coisa, a resposta é um motor de busca, não mais índices.
5. Custo: onde o dinheiro realmente vaza
Vamos dimensionar a Lastro com números redondos e premissas explícitas: 8 milhões de contratos ativos, em média 10 parcelas por contrato, 25% da carteira em cobrança (2 milhões de contratos), quarenta tenants, o maior deles com 60% do volume.
Primeiro, a verdade que desmonta metade das discussões de revisão: armazenamento quase nunca é a conta. Contratos, parcelas, eventos, contatos e itens de lookup somam algo em torno de 95 GB. A US$ 0,25 por GB-mês na classe Standard, são cerca de US$ 24 por mês. Discutir abreviação de nome de atributo por causa de armazenamento é otimizar o centavo errado.
O dinheiro, e principalmente o risco, vaza em três lugares.
Vazamento 1: escrita sem evento
Compare dois modelos para o mesmo requisito, “saber o atraso de cada contrato”, a US$ 0,625 por milhão de unidades de escrita (on-demand):
MODEL A: store daysPastDue, recompute nightly, daysPastDue in GSI2 sort key
contract item at 1.1 KB (embedded promises and history)
2M delinquent contracts x 30 nights = 60M updates / month
base table: 1.1 KB rounds to 2 KB -> 2 WRU = 120M WRU
GSI2 key change: delete + insert (~0.4 KB) = 120M WRU
total = 240M WRU ~ US$ 150 / month
MODEL B: store oldestOverdueDueDate, derive days past due at read time
contract item under 1 KB, bucket changes at most once a month
~2M bucket moves / month x (1 base + 2 index) = 6M WRU ~ US$ 4 / monthImagine assim
O modelo A reemite, toda madrugada, o RG de dois milhões de pessoas só para atualizar a idade impressa. O modelo B imprime a data de nascimento uma única vez e deixa cada um fazer a conta quando precisar.
E repare no detalhe cruel do modelo A: quanto mais gente entra na inadimplência, maior fica a fila da gráfica.
Quarenta vezes mais caro. E, sendo honesto, ninguém é demitido por US$ 150 por mês. O custo real do modelo A não está na fatura; está no formato da carga. Ele cria, por design, um pico diário de escrita proporcional à inadimplência, concentrado em poucas chaves de baixa cardinalidade, na mesma janela em que a régua reagenda as ações. No modo on-demand, a tabela acomoda instantaneamente até o dobro do pico anterior; um salto além disso pode gerar throttling enquanto a capacidade se ajusta.
Agora pense no que acontece numa crise de crédito. A inadimplência sobe, o pico noturno do modelo A cresce junto, e o risco de throttling aumenta exatamente quando a cobrança mais importa. Um bom modelo não aumenta a própria carga com notícia ruim. No modelo B, a carga cresce com eventos (pagamentos, contatos, acordos), não com o calendário.
Vazamento 2: ler o que você não precisa
A primeira versão da tela de carteira quase sempre é um Scan com FilterExpression. O filtro dá a sensação de que você só paga pelo que volta. Não é assim: a capacidade consumida é calculada sobre os itens lidos, não sobre os devolvidos. É uma biblioteca que cobra por cada livro que o bibliotecário tirou da estante procurando o seu, e não pelo único livro que você levou para casa.
Eventually consistent Scan over 95 GB
95 GB / 4 KB x 0.5 ~ 12.5M RRU ~ US$ 1.56 per scan
Supervisor screen opened hourly by 40 tenants
40 x 24 h x 30 days = 28,800 scans / month ~ US$ 45,000 / month
Same screen on GSI2, 100 contracts per page (~0.3 KB each)
30 KB / 4 KB x 0.5 ~ 4 RRU per page -> cents per monthEsse é o único número do artigo que realmente assusta, e ele não vem de um recurso caro do DynamoDB. Vem de uma pergunta sem estrutura para respondê-la.

Vazamento 3: fronteiras de tamanho e transações por reflexo
Escritas são cobradas em blocos de 1 KB; leituras, em blocos de 4 KB. Um cabeçalho de contrato com 1,02 KB paga duas unidades em toda atualização, para sempre, como na tabela dos Correios, em que um pacote de 1,02 kg paga a tarifa de 2 kg. Por isso o histórico sai do item (seção 3), e por isso a projeção dos índices é enxuta.
Transações custam o dobro: cada item é preparado e confirmado. É o reconhecimento de firma em cartório: vale quando o documento exige, e é desperdício quando uma assinatura simples resolve. Use-as apenas quando a invariante atravessa itens, como na liquidação (item de idempotência, parcela e contrato precisam mudar juntos, ou nenhum muda). O contador de contatos não precisa de transação; um ADD condicional num único item já é atômico. E lembre que escrita condicional que falha também consome capacidade: cada webhook duplicado custa uma escrita. É um seguro barato, mas entra na conta.
Custo como teste automatizado
Use ReturnConsumedCapacity: "INDEXES" nos testes de integração e declare um orçamento por operação de negócio: “aplicar pagamento consome no máximo 8 WRU”, “abrir a visão 360 consome no máximo 4 RRU”. Quando alguém embutir um array no item ou projetar ALL num índice, o CI quebra antes da fatura.
Modo de capacidade e classe de tabela
O perfil da Lastro (picos em dia de pagamento, lote noturno, vales no resto do dia) favorece on-demand, cujo preço caiu pela metade em novembro de 2024. Provisionado com auto scaling fica em torno de três vezes mais barato por requisição quando a utilização é alta e estável, então revisite a decisão quando o tráfego amadurecer. Antes de picos previsíveis, como o dia do INSS, a AWS permite pré-aquecer a capacidade de tabelas e índices (warm throughput).
Para o histórico, a documentação de fundamentos é clara: uma tabela que mistura dado operacional e histórico não aproveita bem a classe Standard-Infrequent Access. Contratos encerrados, eventos antigos e evidências de contato com mais de alguns meses vão para uma tabela separada em Standard-IA, com armazenamento a US$ 0,10 por GB-mês contra US$ 0,25, e requisições cerca de 25% mais caras. O caminho natural é TTL na tabela quente, stream configurado com imagem antiga, e uma Lambda que reconhece o delete feito pelo próprio serviço e arquiva no destino frio. Tabelas separadas também permitem políticas de backup e de recuperação diferentes para dados de criticidade diferente.
A pergunta de unit economics
Unit economics. Quanto custa, e quanto rende, uma unidade do negócio; aqui, um contrato ativo por mês. É o que diz se o preço cobrado de cada tenant cobre o custo de atendê-lo, como saber o custo de cada prato antes de fechar o preço do cardápio.
Num BaaS, o número que importa não é a fatura do DynamoDB; é o custo por contrato ativo por mês, porque é ele que entra na precificação para o tenant. Somando escrita (régua, contatos, liquidações, eventos), leitura, armazenamento, PITR e o export mensal, a Lastro fica na casa de US$ 200 por mês em us-east-1. Algo como dois centavos de dólar por mil contratos.
Agora compare com o custo operacional da própria cobrança: 2 milhões de contratos em cobrança, dez tentativas de contato por mês, metade por SMS a R$ 0,05, são R$ 500 mil por mês só em SMS. O banco custa menos do que algumas horas de disparo.
A conclusão não é “DynamoDB é barato, relaxa”. É que o valor do modelo está nas decisões de negócio que ele torna corretas: o contador que impede o quarto contato economiza mais, em SMS e em processo judicial, do que qualquer otimização de byte.
Pense comigo
Você sabe quanto custa, em unidades de escrita, aplicar um pagamento na sua plataforma?
Se não sabe, também não sabe quanto vai custar o próximo quinto dia útil, nem se ele vai caber na capacidade que você tem.
Pontos altos
Guardar dado custa pouco. O que custa é escrever sem motivo e ler o que não precisava.
A carga do banco deve crescer com o que acontece (pagamentos, contatos, acordos), não com a passagem dos dias, nem com o aumento da inadimplência.
Scan com filtro cobra cada livro tirado da estante, não os que você levou.
Transação custa o dobro, como reconhecer firma: use só quando vários itens precisam mudar juntos ou nenhum muda.
As faixas de 1 KB e 4 KB funcionam como a tabela de peso dos Correios. Ponha no CI um teste que mede o consumo de cada operação de negócio.
O histórico vai para uma tabela própria, na classe mais barata para dado pouco acessado.
Meça o custo por contrato ativo e compare com o custo de uma decisão de cobrança errada.
6. A tabela é o estado; o stream é a história
A tabela responde “como está agora”. O resto da plataforma quer saber “o que aconteceu”: negativação, contabilidade, notificações, o motor de busca da mesa, o lakehouse. O DynamoDB Streams, consumido por Lambda com filtros de evento, publica as transições como eventos de domínio (InstallmentOverdue, PromiseBroken, AgreementSigned, SettlementApplied) em EventBridge ou Kafka.
No single-table design, toda mudança de todo tipo de item passa pelo mesmo stream. A documentação lembra que, com os filtros de evento da Lambda, isso não pesa na fatura; com outros consumidores, pesa. Filtre por type na borda.
O cuidado de modelagem aqui é sutil: o stream mostra o antes e o depois, mas não o porquê. Ele é a câmera de segurança da portaria: grava que a porta abriu às 14h02, mas não se foi o morador, o entregador ou o síndico. O motivo precisa estar no livro de registro. Se o contrato mudou de IN_COLLECTION para ACTIVE, foi pagamento, acordo ou estorno? O consumer não tem como saber, a menos que o item carregue essa informação. É mais um motivo para statusReason existir: ele não serve só para a tela, serve para que o evento derivado da mudança tenha significado.
E as perguntas analíticas da seção 1 nunca tocam a tabela. O export incremental para o S3 é cobrado por GB de dado da tabela e não consome capacidade de leitura; aging, roll rate e relatórios regulatórios rodam em Athena ou no lakehouse, sobre dados que o time de dados pode consultar livremente sem acordar ninguém de plantão.
Pontos altos
A tabela guarda o agora, o stream conta o que aconteceu, e o S3 atende as análises.
O stream é a câmera da portaria: mostra que a porta abriu, não por quê. O motivo precisa estar gravado no item.
BI nunca faz Scan na tabela que atende a operação; usa o export.
7. A revisão de design que eu faria no seu PR
Se você é tech lead ou arquiteto e vai revisar um modelo em DynamoDB, estas são as perguntas que eu levaria para a sala. Nenhuma delas é sobre sintaxe.
Onde está a lista de padrões de acesso, e quem do negócio a validou?
Qual pergunta vocês decidiram não responder na tabela, e para onde ela vai?
Qual atributo muda sem que nada aconteça no negócio? Por que ele está gravado em vez de derivado?
Alguma chave, da tabela ou de índice, contém dado pessoal?
Qual será o maior tenant daqui a dois anos, e qual chave ele esquenta primeiro?
Qual GSI, se sofrer throttling, bloqueia uma escrita crítica na tabela base?
Quais perguntas exigem leitura forte, e por que nenhuma delas depende de GSI?
Onde mora a idempotência de cada comando externo, e qual é a janela real de retry do provedor?
Quanto custa, em unidades de capacidade, cada operação de negócio, e existe teste para isso?
Qual é o plano para histórico, retenção, rotação do segredo de chave e offboarding de tenant?
Voltando à segunda-feira, 8h40
Com o modelo da Lastro, as três perguntas da head de cobrança têm dono.
“Quantos contratos viraram 31+ no fim de semana?” Operacionalmente, uma Query na faixa
D031_060do tenant no GSI2, restrita poroldestOverdueDueDate, que lê a faixa e não a tabela. Analiticamente, a contagem dos eventos de mudança de faixa que o stream já publicou.“Quanto disso tem promessa para esta semana?” Consultas no GSI1 com
nextAction = PROMISE_CHECK, uma por dia da semana, em cada shard, pelos mesmos workers que já leem a régua.“Por que ele recebeu três SMS no domingo?” Uma Query na coleção do devedor com o prefixo
CONTACT#2026-09-20, ao lado do contadorLIMIT#2026-09-20. Se foram três, a prova está ali, e o limite respeitado (ou violado) também.
No relacional, o schema descreve o dado, e as perguntas se resolvem depois. No DynamoDB, o schema descreve as perguntas. Em cobrança, as perguntas são o negócio: quem deve, desde quando, quanto, o que foi prometido, o que foi feito e o que a lei permite fazer a seguir. Um modelo que responde a isso com uma Query por pergunta não é só rápido e barato. É auditável, explicável e defensável.
Toda chave é uma pergunta. Garanta que alguém do negócio a tenha feito.
Se você opera cobrança, crédito ou pagamentos em DynamoDB, me conta nos comentários: qual foi a pergunta que a sua tabela não soube responder?
Crediário. Venda parcelada em que a própria loja, ou a financeira ligada a ela, empresta o dinheiro ao cliente. É o velho caderninho do fiado da mercearia, só que com contrato, juros, CPF e, claro, uma operação de cobrança por trás.
31+ e faixas de atraso. Jargão para "contratos com 31 dias ou mais de atraso". O mercado agrupa a inadimplência em faixas (1 a 30, 31 a 60, 61 a 90 e acima de 90 dias), e cada faixa pede um tratamento diferente. Pense num termômetro: 37,5 °C pede atenção, 39 °C pede médico. Com cinco dias de atraso, um lembrete gentil resolve; com 45, a conversa já é sobre acordo; depois de 90, o contrato costuma seguir para negativação e para perda. "Virar 31+" é atravessar a linha da primeira para a segunda faixa: o sinal de que o atraso deixou de ser esquecimento.
Promessa de pagamento. Quando o devedor diz "pago no dia 26", a cobrança registra a data, pausa as ações até lá e confere, no dia, se o dinheiro entrou. Se não entrou, é uma promessa quebrada, um dos sinais de risco mais fortes que existem. É o fiado com data marcada.
Carteira. O conjunto de todos os contratos de crédito que uma instituição tem a receber. Imagine um pomar: cada árvore é um contrato, e a gestora olha a saúde do pomar inteiro (quantas árvores estão doentes, em que estágio, onde a praga avança), não só a de uma fruta.
BaaS e tenant. Banking as a Service: uma empresa que oferece a infraestrutura financeira para que outras marcas operem crédito, conta ou cobrança sem construir tudo do zero. Pense numa cozinha industrial compartilhada, as chamadas dark kitchens: várias marcas de restaurante usam a mesma cozinha, cada uma com cardápio, clientes e receitas próprios, e o pedido de uma nunca pode sair com a comida da outra. Cada marca cliente da plataforma é um tenant, "inquilino" em inglês.
SCD. Sociedade de Crédito Direto: um tipo de fintech autorizada pelo Banco Central a emprestar dinheiro usando capital próprio.
Consignado. Empréstimo cuja parcela é descontada direto do salário ou do benefício do INSS, antes de o dinheiro chegar à conta. Como o pagamento é quase automático, o risco é menor, e os juros também.
Régua de cobrança. A sequência programada de ações conforme os dias de atraso: lembrete no primeiro dia, SMS no quinto, ligação no décimo quinto, proposta de acordo no quadragésimo quinto, e assim por diante. Funciona como a caderneta de vacinação: cada idade tem a sua vacina, cada dia de atraso tem a sua ação. O SMS_D45 do exemplo é "o SMS do 45º dia de atraso".
Mesa de cobrança. A equipe humana da cobrança: operadores que ligam, negociam e registram acordos, e supervisores que acompanham a carteira e as metas. É o call center da cobrança, com roteiros e indicadores próprios.
Liquidação, baixa e conciliação. Liquidação é o dinheiro efetivamente entrando. O webhook de liquidação é o aviso automático que o banco ou o provedor de pagamento envia quando isso acontece: o "plim" do caixa. Dar baixa é marcar a parcela como paga. Conciliar é casar cada valor recebido com a cobrança que o originou, como conferir o extrato do cartão com as notas fiscais do mês: nenhum real pode ficar sem dono.
Aging e roll rate. Aging é a foto da carteira distribuída pelas faixas de atraso num dia específico. Roll rate mede quanto de uma faixa "escorrega" para a seguinte de um mês para o outro, por exemplo, quantos contratos que estavam com 1 a 30 dias chegaram a 31 a 60. Se o aging é a foto de uma escada com pessoas em cada degrau, o roll rate mostra quantas desceram um degrau desde a última foto.
Quinto dia útil e pagamento do INSS. Datas em que salários (pagos até o quinto dia útil do mês, pela CLT) e benefícios do INSS caem na conta. Com dinheiro na mão, muita gente paga as contas no mesmo dia, e o volume de pagamentos dispara. Para a cobrança, são os dias de Black Friday.
Provisão. Uma reserva contábil que a instituição separa pensando nas perdas que espera ter; quanto maior o atraso, maior a reserva. É o envelope de "imprevistos" do orçamento doméstico, só que calculado com regras do Banco Central.
Negativação. A inclusão do CPF do devedor nos birôs de crédito (Serasa, SPC, Boa Vista), que outras empresas consultam antes de conceder crédito. O CDC exige comunicação prévia por escrito ao consumidor, e um erro aqui costuma virar processo. É o "nome no mural" que todo o comércio consulta, e por isso não admite engano: negativar quem já pagou é dano quase certo.
Prorrogação. Mudar a data de vencimento de uma parcela, por pedido do cliente, por política comercial ou dentro de uma renegociação. Pense numa consulta médica remarcada: o prontuário precisa registrar a data original e a nova, senão ninguém sabe depois se o paciente faltou ou se a clínica mudou a agenda. Quando alguém pergunta "quanto da carteira foi prorrogada?", quer medir quanta dívida foi empurrada para frente, porque prorrogação em excesso pode esconder inadimplência real: o contrato parece em dia só porque o vencimento mudou de lugar.
Imputação do pagamento e principal. Principal é o valor emprestado; juros são o preço desse empréstimo. Imputação é a regra que decide qual parte da dívida o dinheiro quita primeiro. Imagine baldes enchidos em sequência: o pagamento enche primeiro o balde dos juros vencidos, e só o que sobra cai no balde do principal. Sem os valores separados no dado, não há como provar em qual balde cada real caiu.
Multa moratória e juros de mora. Dois encargos diferentes pelo mesmo atraso. A multa é cobrada uma única vez, como uma taxa de religação; em relações de consumo, não pode passar de 2% do valor da prestação. Os juros de mora correm enquanto o atraso durar, como um taxímetro ligado. Guardar os dois separados é o que permite provar que o teto foi respeitado.
Acordo. Uma renegociação: as parcelas em atraso são substituídas por uma nova condição, geralmente com desconto de encargos, uma entrada e um novo parcelamento. Se o cliente deixa de pagar o acordo, ele "quebra", e o contrato volta para a régua.
txid, nosso número e E2E. Os códigos de rastreio do dinheiro. O txid identifica uma cobrança Pix, o QR Code que você escaneia. O nosso número identifica um boleto dentro do banco que o emitiu. O E2E (end-to-end ID) identifica uma transação Pix específica no momento em que ela é liquidada. É como o número de rastreio de uma encomenda: é por ele que se descobre de onde o pacote veio e para quem ele vai.
Fontes e leituras
AWS, Data modeling foundations: single table and multiple table design
AWS, Multi-attribute composite keys in global secondary indexes (nov/2025)
Rafael Miguel, Um breve mergulho no DynamoDB
Rafael Miguel, Quando Orientação a Objetos Estraga Seu Modelo de Dados
Rafael Miguel, Engenharia de Crédito: Implementando Máquinas de Estado
Rafael Miguel, Desafios de Arquitetura e Resiliência em Sistemas de Cobrança




