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.

PromoBetArquitetura de SoftwareDesign de SistemasIngestão de ProdutosValidação de DadosFirebaseFirestoreDesenvolvimento Full-Stack

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:

text
Dados Externos de Produto

Adapter

Normalização

Enriquecimento

Validação

Produto Canônico

Persistência

Essa 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:

json
{
  "price": 100
}

ou como um objeto aninhado:

json
{
  "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:

text
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:

text
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:

text
Produto do Tenant

Escopo do Tenant

tenants/{tenantId}/products

e:

text
Produto Global

Escopo do Sistema

globalCatalog

Os 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:

text
Importação CSV do Tenant
Importação JSON do Tenant

Importação CSV Global
Importação JSON Global

com 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:

text
                   ┌── Catálogo do Tenant

Produto Validado ──┤

                   └── Catálogo Global

Essa é 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:

text
promobet-admin

Isso cria uma relação muito mais clara:

text
PromoBet

Experiência do produto

promobet-admin

Operações do sistema

A 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.

text
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:

text
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:

text
GET /api/admin/me

Sua função não é executar uma operação administrativa.

É estabelecer a identidade administrativa.

O fluxo passa a ser:

text
Login no Firebase

Firebase ID Token

GET /admin/me

verifyFirebaseToken

requireSystemAdmin

Administrador autorizado

Isso 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:

text
POST /api/admin/globalCatalog/import/csv

O endpoint combina várias peças que anteriormente existiam de maneira relativamente independente.

text
Autenticação administrativa

Autorização de sistema

Upload multipart

CSV adapter

Normalização

Enriquecimento

Validação

Transformação global

Firestore

Esse 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:

text
?dryRun=true

A API processa o arquivo normalmente, mas interrompe o fluxo antes da persistência.

O resultado contém informações como:

text
total
validCount
errorCount
warningsCount
products
items
errors

Isso fornece ao promobet-admin informação suficiente para construir uma prévia.

O fluxo passa a ser:

text
Upload

Parse

Validação

Preview

Confirmação humana

Importação

Esse é 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.

text
              ┌── CSV

Dados Externos ┤

              └── JSON

                 Adapter

              Normalização

              Enriquecimento

                Validação

             Catálogo Global

A 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:

json
{
  "price": {
    "value": 100
  }
}

em vez de:

json
{
  "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:

text
toGlobalCatalogProduct()

de:

text
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:

text
Produto Canônico

Transformação

Produto do Catálogo Global

Persistência

Isso 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:

text
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:

text
tenant_pool
global_pool
legacy_fallback

A lógica é aproximadamente:

text
Existe um tenant?

      ├── Sim
      │    ↓
      │  Produtos do tenant

      └── Não

      Produtos globais

           ├── Disponíveis
           │     ↓
           │   globalCatalog

           └── Vazio

             Dados legados

Essa 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:

text
Catálogo estático

Fallback legado

Firestore globalCatalog

Fonte global principal

Isso é ú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:

text
20 total
20 válidos
0 erros
0 avisos

Esses produtos foram então persistidos em globalCatalog.

O spin global posteriormente consumiu os registros importados.

Isso completou o ciclo:

text
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 Produto

Nesse 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:

text
Dados Externos

Interpretar

Normalizar

Enriquecer

Validar

Revisar

Persistir

Resolver

Experiência

Essa é 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.”