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 |