Pular para conteúdo

Visão Geral do Sistema

Leia isso antes de qualquer outra coisa. Os outros documentos de arquitetura assumem que você entende o que está aqui.


O que é o Spryx Backend e qual problema ele resolve

O Spryx é uma plataforma SaaS multi-tenant para comunicação com clientes via IA. Empresas (tenants) configuram agentes de IA, conectam canais de mensagem (WhatsApp, email) e deixam os agentes atenderem seus clientes de forma autônoma.

O backend sustenta tudo isso em uma única aplicação: autenticação, billing, configuração de agentes, roteamento de mensagens, indexação de documentos, webhooks externos. Não é um conjunto de serviços — é um sistema coeso com fronteiras internas bem definidas.


Stack tecnológica

Camada Tecnologia Por que esta escolha
API HTTP FastAPI + Python 3.12 Assíncrono nativo, schema OpenAPI automático, tipagem estrita
Banco de dados PostgreSQL via Supabase Relacional com suporte a JSONB; multi-tenancy por tenant_id, não por schemas separados
ORM / queries SQLAlchemy Core (async) + asyncpg SQL explícito nos repositories; sem ORM mágico nos query handlers
Fila de tarefas Celery + Redis Processamento assíncrono pesado fora do ciclo HTTP (RAG, refresh de tokens, sync)
Cache / broker Redis Broker Celery nas filas default e indexing; cache de curta duração
IA OpenAI Embeddings (RAG) e execução de agentes; acessado pelo Worker, nunca por HTTP diretamente
Storage S3-compatível Assets e documentos com URLs assinadas; acesso cross-módulo via StoragePort
Billing Stripe Checkout, assinaturas, webhooks — sem abstração de "PaymentProvider", Stripe é o billing
Mensageria WAHA (WhatsApp HTTP API) Gateway HTTP sobre WhatsApp Business API
Segredos Doppler Um projeto Doppler por entrypoint (spryx-app-api, spryx-backoffice-api, spryx-worker)
Pacotes uv Substitui pip/poetry; lockfile determinístico, instalação rápida

Princípios arquiteturais

Estes princípios guiam todas as decisões de design. Violá-los geralmente cria bugs silenciosos ou acoplamento que custa caro para desfazer.

1. Monólito Modular — módulos nunca importam uns dos outros

O sistema tem 9 módulos. Cada um é um bounded context com seus próprios modelos de domínio, use cases, repositories e exceções. Nenhum módulo importa diretamente de outro.

Toda dependência cross-módulo passa por uma interface (Port) em src/modules/shared/ports/, implementada por um Adapter e conectada via DI:

# ❌ Errado — acoplamento direto entre módulos
from src.modules.asset.infrastructure.asset_repository import AssetRepository

# ✅ Correto — dependência via Port
from src.modules.shared.ports.storage import StoragePort  # interface
# implementação concreta resolvida em runtime pelo container

2. Use Cases são os únicos donos do ciclo de vida do UoW

A transação abre e fecha dentro do Use Case — nunca no controller, nunca no repository, nunca no service. Repositories e services recebem tx: Transaction e apenas o utilizam.

# ✅ Correto — UC abre a transação
class CreateAgentUC:
    async def execute(self, command: CreateAgentCommand) -> CreateAgentResult:
        async with self.uow.begin() as tx:          # UoW abre aqui
            agent = Agent(...)
            await self.repo.save(tx, agent)          # repo só usa o tx
            await self.audit.record(tx, ...)
        # UoW commita — side effects acontecem DEPOIS
        await self.notifications.send(...)

3. Modelo das três zonas de transação

Cada operação de escrita divide-se em três zonas com regras estritas:

ANTES (fora da transação)
  └─ IO externo lento: vault, APIs de terceiros, parsing de arquivo
  └─ Nunca segure um lock de banco enquanto faz chamada HTTP

DENTRO (transação aberta)
  └─ Todos os writes SQL: repo.save, audit.record
  └─ Leituras necessárias ao fluxo de negócio

DEPOIS (após commit)
  └─ Side effects: emails, webhooks, notificações push
  └─ Se falhar aqui, o dado já foi persistido — projete para idempotência

Por que isso importa: disparar um webhook ou enviar um email antes do commit garante que você vai notificar sobre uma operação que ainda pode falhar. IO externo dentro da transação segura locks desnecessariamente.

4. Injeção de dependência explícita — dataclass containers, não FastAPI Depends

Não há Depends() chains do FastAPI nem container mágico com auto-wire. Cada surface (entrypoint) tem seu próprio container com @cached_property:

# src/entrypoints/http/app/compose/container.py
class AppContainer:
    @cached_property
    def agent_repo(self) -> AgentRepository:
        return AgentRepository()

    @cached_property
    def create_agent_uc(self) -> CreateAgentUC:
        return CreateAgentUC(
            uow=self.infra.uow,
            repo=self.agent_repo,
            audit=self.audit_service,
        )

O router recebe o container via um único Depends(get_container) e acessa container.create_agent_uc diretamente. A árvore de dependências é código Python legível — nenhum framework resolve nada em background.

5. Surface containers por ponto de entrada

Cada entrypoint tem seu próprio container isolado:

Entrypoint Container Arquivo
App API AppContainer src/entrypoints/http/app/compose/container.py
Backoffice API BackofficeContainer src/entrypoints/http/backoffice/compose/container.py
Worker WorkerContainer src/entrypoints/worker/compose/container.py

Isso significa que cada entrypoint carrega apenas o que precisa. O Worker não instancia nada relacionado a HTTP. A App API não instancia nada de backoffice. Configurações por entrypoint vivem em src/settings/.

6. Estado derivado, não mutação direta

Entidades de domínio são modelos Pydantic imutáveis. Toda "atualização" produz uma nova instância:

# ❌ Errado — mutação direta
agent.status = AgentStatus.ACTIVE
agent.name = "Novo Nome"

# ✅ Correto — nova instância via model_copy
updated = agent.model_copy(update={
    "status": AgentStatus.ACTIVE,
    "name": "Novo Nome",
})
await self.repo.save(tx, updated)

Isso elimina bugs de estado parcial (objeto parcialmente modificado antes de uma exceção) e torna rastreável qual operação produziu qual estado.


Como tudo se conecta num request típico

HTTP POST /v1/tenant/agents
  → AppContainer.create_agent_uc injetado no router

  Controller
    → valida schema Pydantic
    → monta CreateAgentCommand(frozen=True)
    → chama uc.execute(command)

  Use Case — ANTES da transação
    → chama vault para credenciais se necessário

  Use Case — DENTRO da transação (UoW.begin())
    → repo.get_by_id(tx, tenant_id=...) — verifica duplicata
    → cria Agent() — nova entidade imutável
    → repo.save(tx, agent) — INSERT com tenant_id
    → audit.record(tx, event) — log imutável

  Use Case — APÓS commit
    → notification.send(user_id, "Agente criado")

  Controller
    → serializa CreateAgentResult → HTTP 201

Onde ir a seguir

Quero entender... Leia
Os entrypoints e suas rotas Entrypoints
As camadas em detalhe Camadas
Cada módulo e suas responsabilidades Módulos
Decisões de design registradas ADRs