Da Experiência do Produto às Operações do Produto: Construindo a Arquitetura Administrativa do PromoBet
Como o PromoBet evoluiu de uma aplicação focada na experiência do produto para um sistema com uma camada administrativa dedicada, ingestão de catálogo global, validação e infraestrutura operacional.
Existe um tipo particular de complexidade que aparece à medida que um projeto cresce.
Ela não é necessariamente causada por uma única funcionalidade difícil.
Às vezes, ela surge no momento em que um sistema originalmente projetado para fazer algo também precisa começar a gerenciar as coisas que tornam esse algo possível.
O PromoBet chegou a esse ponto recentemente.
Durante muito tempo, grande parte da arquitetura girava em torno da própria experiência do produto: tenants, usuários, produtos, spins, recompensas, engajamento, analytics, pipelines de ingestão, validação e todas as peças necessárias para fazer essas funcionalidades trabalharem em conjunto.
Mas, em determinado momento, precisávamos responder a uma pergunta diferente:
Como realmente operamos o sistema?
Como introduzimos produtos no catálogo global?
Como validamos esses produtos antes que façam parte da experiência?
Como diferenciamos operações de nível sistêmico de operações específicas de um tenant?
Onde deveriam ficar as funcionalidades administrativas?
E, talvez mais importante:
Como introduzir tudo isso sem transformar a aplicação existente em uma coleção cada vez mais complicada de responsabilidades não relacionadas?
A solução foi a introdução de uma aplicação administrativa separada, promobet-admin, apoiada por uma camada dedicada de administração de sistema na API.
O que inicialmente parece apenas “adicionar um admin” acabou sendo uma mudança muito mais arquitetural.
Ela nos obrigou a esclarecer limites que anteriormente existiam, em grande parte, de maneira implícita.
E, ao fazer isso, finalmente deu à arquitetura de ingestão que vínhamos construindo nos últimos meses um ponto de entrada operacional real.
A arquitetura antes da camada administrativa
O PromoBet já vinha evoluindo em direção a uma separação de responsabilidades bastante deliberada.
O sistema de produtos havia evoluído para algo muito além de simplesmente armazenar uma lista de produtos e exibi-los.
Nós introduzimos um pipeline de ingestão capaz de receber diferentes representações externas e convertê-las em um modelo interno de produto canônico.
Conceitualmente, o fluxo era:
Dados Externos de Produto
↓
Adapter
↓
Normalização
↓
Enriquecimento
↓
Validação
↓
Produto Canônico
↓
PersistênciaEssa distinção se tornou cada vez mais importante à medida que começamos a trabalhar com diferentes fontes de produtos.
Um arquivo CSV não necessariamente representa um produto da mesma maneira que um payload JSON.
Um provedor pode chamar um campo de product_name.
Outro pode chamá-lo de title.
Um preço pode ser representado como um número:
{
"price": 100
}ou como um objeto aninhado:
{
"price": {
"value": 100
}
}Informações de afiliados podem ter diferentes representações.
URLs podem aparecer em campos diferentes.
Dados de comissão podem chegar em formatos diferentes.
Em vez de permitir que essas diferenças se espalhassem pela aplicação, a arquitetura de ingestão passou a ser responsável por traduzi-las para uma única representação canônica.
Essa foi uma decisão importante.
O restante da aplicação não deveria precisar saber de onde um produto veio.
Ela deveria simplesmente receber um produto válido para o PromoBet.
O pipeline de ingestão se tornou mais do que um parser
Uma das lições desse trabalho foi perceber que ingestão não é realmente sobre parsing.
Parsing é apenas o começo.
A abstração mais útil é:
dados externos → dados confiáveis da aplicação
Isso exige várias etapas.
Adapters
Os adapters entendem as representações externas.
Eles respondem a perguntas como:
Onde está o nome do produto nesse formato específico?
Qual campo contém a URL de afiliado?
O preço é um valor primitivo ou está aninhado?
Normalização
A normalização remove diferenças de representação.
O objetivo é impedir que convenções de nomenclatura específicas de cada provedor avancem para as camadas internas da aplicação.
Enriquecimento
Algumas informações podem ser derivadas em vez de serem fornecidas diretamente.
Por exemplo, uma categoria ou um provedor de afiliados pode, em alguns casos, ser inferido a partir dos dados existentes do produto.
Validação
A validação determina se o objeto resultante é realmente aceitável pelo PromoBet.
Isso inclui coisas como:
- campos obrigatórios
- URLs válidas
- estrutura válida do produto
- tiers suportados
- preços normalizados
- informações de comissão
- outras regras de domínio
Transformação
Por fim, um produto validado pode ser transformado para a representação apropriada ao seu destino.
Essa distinção se tornou especialmente importante quando introduzimos o catálogo global.
O catálogo global era uma responsabilidade diferente
O PromoBet já possuía catálogos de produtos específicos por tenant.
Esses produtos ficavam dentro do escopo do tenant:
tenants/{tenantId}/products/{productId}Esse modelo faz sentido quando o produto pertence a um tenant específico.
Mas precisávamos de outro conceito.
Um catálogo de produtos que pudesse ser gerenciado no nível do sistema e utilizado pela experiência global/default.
Isso significou introduzir:
globalCatalog/{productId}À primeira vista, isso pode parecer apenas mais uma coleção do Firestore.
Arquiteturalmente, é algo mais significativo.
Passamos a ter dois escopos diferentes de propriedade de produtos:
Produto do Tenant
↓
Escopo do Tenant
↓
tenants/{tenantId}/productse:
Produto Global
↓
Escopo do Sistema
↓
globalCatalogOs produtos em si ainda poderiam compartilhar o mesmo modelo canônico.
O contexto de propriedade e operação era diferente.
Essa distinção importava.
Por que não criamos um segundo pipeline de produtos
Uma abordagem tentadora seria criar algo como:
Importação CSV do Tenant
Importação JSON do Tenant
Importação CSV Global
Importação JSON Globalcom cada implementação desenvolvendo gradualmente suas próprias regras.
Isso funcionaria inicialmente.
Também seria exatamente o tipo de duplicação que se torna cara posteriormente.
Em vez disso, a decisão foi manter a ingestão independente do escopo de persistência.
O pipeline continua responsável por transformar dados externos de produtos em produtos PromoBet validados.
O destino decide o que acontece depois.
Conceitualmente:
┌── Catálogo do Tenant
│
Produto Validado ──┤
│
└── Catálogo GlobalEssa é uma das decisões arquiteturais mais importantes desta iteração.
Ingestão de produto e propriedade de produto são conceitos relacionados, mas não são a mesma responsabilidade.
Introduzindo o promobet-admin
Quando o catálogo global passou a ser um dado operacional, outra pergunta apareceu imediatamente:
Quem gerencia isso?
Poderíamos ter adicionado os controles administrativos diretamente à aplicação principal do PromoBet.
Mas isso criaria outro limite pouco claro.
A aplicação utilizada por clientes e tenants é fundamentalmente diferente de uma aplicação utilizada para operar o sistema.
A primeira está preocupada com a experiência do produto.
A segunda está preocupada com o estado da própria plataforma.
Então introduzimos uma aplicação separada:
promobet-adminIsso cria uma relação muito mais clara:
PromoBet
↓
Experiência do produto
promobet-admin
↓
Operações do sistemaA distinção não é apenas visual.
Ela também existe nos níveis de API e autorização.
Autenticação não é autorização
Uma das primeiras coisas que se tornou importante foi separar autenticação de autorização.
O PromoBet já possuía autenticação com Firebase.
Isso responde:
Quem é esse usuário?
Mas a API administrativa precisa responder a uma pergunta diferente:
Esse usuário autenticado tem permissão para executar essa operação de nível sistêmico?
Essas responsabilidades passaram a existir em middlewares distintos.
verifyFirebaseToken
↓
Quem é você?
requireSystemAdmin
↓
Você pode executar esta operação?A verificação de system admin utiliza o registro do usuário no Firestore e exige:
mode === "system_admin"Isso é importante porque a existência do promobet-admin nunca deve ser considerada o limite de segurança.
O frontend pode fornecer uma interface administrativa.
Ele não pode conceder privilégios administrativos.
A API continua sendo a autoridade.
/admin/me se tornou o primeiro limite administrativo
O primeiro endpoint criado para esse propósito foi intencionalmente simples:
GET /api/admin/meSua função não é executar uma operação administrativa.
É estabelecer a identidade administrativa.
O fluxo passa a ser:
Login no Firebase
↓
Firebase ID Token
↓
GET /admin/me
↓
verifyFirebaseToken
↓
requireSystemAdmin
↓
Administrador autorizadoIsso deu ao promobet-admin uma maneira autoritativa, baseada no backend, de determinar se o usuário autenticado realmente pertence à camada de administração do sistema.
Pode parecer um endpoint pequeno.
Arquiteturalmente, ele é o início da superfície da API administrativa.
CSV foi o primeiro fluxo operacional real
O próximo passo foi fazer a arquitetura realmente realizar algo útil.
Precisávamos pegar produtos reais e colocá-los no catálogo global.
A primeira implementação foi a ingestão de CSV:
POST /api/admin/globalCatalog/import/csvO endpoint combina várias peças que anteriormente existiam de maneira relativamente independente.
Autenticação administrativa
↓
Autorização de sistema
↓
Upload multipart
↓
CSV adapter
↓
Normalização
↓
Enriquecimento
↓
Validação
↓
Transformação global
↓
FirestoreEsse foi o momento em que a arquitetura de ingestão deixou de ser principalmente infraestrutura e passou a fazer parte da operação real do produto.
Dry runs se tornaram especialmente valiosos
Importar dados externos diretamente para um catálogo de produção é inerentemente arriscado.
Mesmo quando existe validação, há uma diferença significativa entre:
“O sistema diz que esses produtos são válidos.”
e:
“Quero ver exatamente o que o sistema vai importar antes de confirmar.”
Por isso, a API de importação administrativa suporta um modo de dry run:
?dryRun=trueA API processa o arquivo normalmente, mas interrompe o fluxo antes da persistência.
O resultado contém informações como:
total
validCount
errorCount
warningsCount
products
items
errorsIsso fornece ao promobet-admin informação suficiente para construir uma prévia.
O fluxo passa a ser:
Upload
↓
Parse
↓
Validação
↓
Preview
↓
Confirmação humana
↓
ImportaçãoEsse é um modelo operacional muito mais seguro do que tratar um upload como uma mutação imediata do banco de dados.
JSON revelou outra propriedade importante da arquitetura
Depois que o CSV estava funcionando, JSON era o próximo passo natural.
Mas não havia motivo para construir outra interface ou outro fluxo conceitual.
A diferença existe apenas na fronteira de entrada.
CSV chega como arquivo.
JSON chega como dados estruturados.
Depois disso, ambos devem convergir.
┌── CSV
│
Dados Externos ┤
│
└── JSON
↓
Adapter
↓
Normalização
↓
Enriquecimento
↓
Validação
↓
Catálogo GlobalA implementação JSON, portanto, segue o mesmo contrato de verificação utilizado pelo CSV.
Isso também foi um teste útil para saber se a abstração estava realmente funcionando.
Se adicionar um novo formato de entrada exige reconstruir todas as regras de negócio, provavelmente a abstração está no lugar errado.
Neste caso, o novo caminho JSON pôde reutilizar a mesma arquitetura de processamento de produtos.
Um pequeno problema revelou uma distinção importante
Durante a ingestão JSON, encontramos um problema bastante realista relacionado a dados externos.
Um preço poderia chegar como:
{
"price": {
"value": 100
}
}em vez de:
{
"price": 100
}A primeira implementação assumia demais sobre o formato da entrada.
Isso resultava em falhas de validação antes que o produto pudesse chegar ao catálogo.
A solução não foi fazer a camada de validação entender todas as possíveis representações externas.
Em vez disso, o adapter JSON passou a ser responsável por achatar e normalizar esses valores antes que o produto canônico entrasse no pipeline comum.
Isso reforça uma regra importante:
as peculiaridades específicas do provedor pertencem à fronteira.
As camadas mais profundas devem receber dados previsíveis.
Transformação e persistência também foram separadas
Outra mudança mais sutil foi separar:
toGlobalCatalogProduct()de:
importGlobalProducts()O primeiro transforma um produto canônico na representação necessária para o catálogo global.
O segundo persiste essa representação.
Isso significa que podemos raciocinar sobre eles de forma independente:
Produto Canônico
↓
Transformação
↓
Produto do Catálogo Global
↓
PersistênciaIsso impede que o helper de banco de dados se transforme em uma segunda camada de lógica de negócio.
Também deixa espaço para futuros destinos sem obrigar as funções de persistência a entender como os produtos devem ser construídos.
O catalog resolver foi a peça final
Colocar os produtos no Firestore é apenas metade do problema.
A aplicação também precisa consumi-los.
O catalog resolver existente já fornecia uma abstração útil:
getCatalogForTenant()Essa abstração permitiu introduzir o catálogo global sem obrigar o restante do sistema de spins a conhecer a estrutura das coleções do Firestore.
O resolver agora diferencia:
tenant_pool
global_pool
legacy_fallbackA lógica é aproximadamente:
Existe um tenant?
│
├── Sim
│ ↓
│ Produtos do tenant
│
└── Não
↓
Produtos globais
│
├── Disponíveis
│ ↓
│ globalCatalog
│
└── Vazio
↓
Dados legadosEssa foi uma decisão arquitetural importante porque a experiência de spin não precisa saber de onde os produtos vieram.
Ela pede um catálogo.
O resolver decide o que esse catálogo significa no contexto atual.
Manter o fallback legado foi intencional
Também não havia motivo para remover imediatamente o catálogo estático existente.
Os dados anteriores de lomadeeProducts continuam disponíveis como fallback legado.
Isso nos dá uma transição gradual:
Catálogo estático
↓
Fallback legado
Firestore globalCatalog
↓
Fonte global principalIsso é útil porque migrações não precisam necessariamente acontecer em uma única troca radical.
O novo caminho pode ser validado enquanto o caminho antigo continua disponível como uma rede de segurança.
Quando o catálogo baseado em Firestore estiver suficientemente maduro, o fallback legado poderá eventualmente ser removido.
O momento em que a arquitetura se tornou real
A parte mais satisfatória dessa implementação não foi, na verdade, a interface administrativa.
Foi o primeiro fluxo completo funcionando de ponta a ponta.
Fizemos upload de dados reais de produtos.
A API processou esses dados.
O pipeline de ingestão os normalizou.
A camada de verificação os aceitou.
A interface administrativa mostrou o resultado.
Os produtos foram persistidos no Firestore.
E então a experiência existente de spin global consumiu esses produtos.
O primeiro dry run de CSV retornou:
20 total
20 válidos
0 erros
0 avisosEsses produtos foram então persistidos em globalCatalog.
O spin global posteriormente consumiu os registros importados.
Isso completou o ciclo:
Dados Reais de Produto
↓
promobet-admin
↓
PromoBet API
↓
Autenticação
↓
Autorização de Sistema
↓
Ingestão
↓
Validação
↓
Firestore
↓
Catalog Resolver
↓
Experiência Existente do ProdutoNesse ponto, a arquitetura deixou de ser teórica.
O sistema que vínhamos construindo finalmente conseguia ingerir produtos reais e colocá-los diretamente dentro da experiência.
A complexidade aumentou — mas a clareza também
Existe uma contradição interessante nessa mudança.
O sistema é objetivamente mais complexo agora.
Temos:
- outra aplicação
- outra superfície de API
- autorização de system admin
- outra coleção no Firestore
- fluxos administrativos
No papel, existem mais peças.
Mas a arquitetura também está mais clara.
Antes, as operações de nível sistêmico não tinham um lugar óbvio.
Agora têm.
A ingestão de produtos precisava coexistir com o comportamento da aplicação.
Agora o pipeline de ingestão possui uma fronteira definida.
Produtos globais poderiam facilmente acabar misturados aos conceitos de tenant.
Agora seus escopos são explícitos.
Autenticação e autorização poderiam ser confundidas.
Agora são responsabilidades separadas.
Comportamentos administrativos poderiam acabar vazando para a aplicação voltada ao usuário.
Agora existe um ponto de entrada dedicado.
Esse é um tipo importante de evolução arquitetural:
às vezes, adicionar estrutura reduz a complexidade conceitual mesmo enquanto aumenta a complexidade técnica.
promobet-admin é mais do que um importador de catálogo
O catálogo global é apenas a primeira capacidade.
A aplicação administrativa agora oferece um lugar natural para funcionalidades que não pertencem à experiência voltada ao usuário.
À medida que a plataforma evoluir, esse mesmo limite poderá suportar coisas como:
Analytics
Visões de nível sistêmico sobre:
- spins
- recompensas
- cliques
- engajamento
- atividade dos tenants
- performance dos produtos
Usuários
Visibilidade operacional sobre:
- usuários
- tenant users
- atividade
- estado das contas
- problemas operacionais
Assinaturas
Visões administrativas sobre:
- estado das assinaturas
- planos dos tenants
- utilização
- ciclo de vida
Suporte e interações
Eventualmente, administradores do sistema poderão precisar interagir com:
- reports de usuários
- solicitações de contato
- solicitações de suporte
- problemas operacionais
- outras interações geradas pelos usuários
Nenhuma dessas funcionalidades pertence naturalmente à experiência do produto.
Elas pertencem à camada operacional da plataforma.
E essa camada agora tem um lugar.
A arquitetura começa a refletir a organização do produto
Uma das coisas que considero mais interessantes nesta fase do PromoBet é que a arquitetura começa a refletir a própria estrutura conceitual do produto.
Existe uma experiência do produto.
Existem tenants.
Existem usuários.
Existem dados globais do sistema.
E agora existe uma camada de administração do sistema responsável por operar todos eles.
A arquitetura deixou de ser apenas uma coleção de decisões técnicas feitas para fazer funcionalidades individuais funcionarem.
Os limites começam a representar responsabilidades reais dentro da plataforma.
Isso torna o desenvolvimento futuro mais fácil de raciocinar.
Quando um novo requisito aparece, a primeira pergunta passa a ser menos:
Onde podemos colocar isso?
e mais:
Qual parte do sistema é realmente responsável por isso?
Essa é uma pergunta muito mais saudável.
O que deliberadamente ainda não construímos
Seria fácil continuar expandindo a camada administrativa assim que a fundação estivesse pronta.
Nós deliberadamente paramos.
Ainda não existe uma suíte completa de gerenciamento do catálogo.
Não existe histórico de importações.
Não existe versionamento de catálogo.
Não existe uma estratégia de cache para o catálogo global.
Não existe ainda um dashboard completo de analytics administrativos.
Não existe uma interface de gerenciamento de assinaturas.
Não existe um sistema de tickets de suporte.
Tudo isso pode ser útil.
Mas são problemas separados.
O objetivo desta iteração era mais específico:
criar o limite e provar o fluxo completo.
Agora que esse limite existe, essas capacidades podem ser adicionadas incrementalmente.
De infraestrutura para operação
Olhando para a evolução, o mais interessante é como várias peças que pareciam inicialmente separadas finalmente se conectaram.
A arquitetura de ingestão nos deu uma maneira de transformar dados externos de produtos em dados confiáveis da aplicação.
A camada de validação nos deu confiança nesses dados.
O catálogo global nos deu um destino persistente de nível sistêmico.
O catalog resolver deu à experiência existente uma maneira de consumir esses produtos.
A autenticação do Firebase nos deu identidade.
A autorização de system admin nos deu um limite de segurança.
E o promobet-admin deu a todas essas capacidades um ponto de entrada operacional voltado para o usuário administrativo.
Individualmente, nenhuma dessas peças é particularmente revolucionária.
Juntas, elas mudam o que o sistema é capaz de fazer.
Agora podemos pegar algo que existe fora do PromoBet — um catálogo real de produtos — e fazê-lo passar por um processo controlado:
Dados Externos
↓
Interpretar
↓
Normalizar
↓
Enriquecer
↓
Validar
↓
Revisar
↓
Persistir
↓
Resolver
↓
ExperiênciaEssa é uma transição significativa.
A próxima fase
Este provavelmente é o principal aprendizado desta iteração.
Nós não simplesmente adicionamos uma página administrativa.
Estabelecemos um novo eixo dentro do sistema.
A aplicação voltada ao usuário continua focada na experiência do produto.
A aplicação administrativa passa a ser a interface operacional da plataforma.
A API impõe o limite entre elas.
A arquitetura de ingestão fornece um caminho controlado para dados externos.
E o Firestore fornece o catálogo persistente de nível sistêmico que conecta a administração ao comportamento em runtime.
A partir daqui, novas capacidades administrativas não precisam ser inventadas do zero.
Elas podem crescer naturalmente sobre a mesma fundação.
E é justamente isso que torna a complexidade adicional válida.
Temos mais código do que tínhamos antes.
Mas também temos limites mais claros, um modelo operacional mais seguro, um ponto de entrada administrativo real
E isso parece um marco muito mais significativo do que simplesmente dizer:
“Adicionamos um painel administrativo.”