14 KiB
01 — Arquitetura
Documento espinha. Todos os outros dependem deste. Se houver conflito entre documentos, este prevalece sobre decisões de stack e estrutura.
1. Visão geral
┌─────────────────── Docker Compose ───────────────────┐
QR / Wi-Fi │ │
na portaria │ traefik (TLS automático, roteamento) │
│ │ │ │
▼ │ ├── portaria-backend (Spring Boot, stateless) │
web/visitor ──────┼──────┤ ├── REST (OpenAPI) │
React PWA │ │ ├── WebSocket (sinalização de chamada) │
│ │ ├── Outbox publisher + ShedLock │
apps/ morador ────┼──────┤ └── Resilience4j → LiveKit / FCM / APNs │
operador │ │ │
Compose MP │ ├── postgres 17 + PostGIS │
│ │ └── flyway · outbox · fila · jobs │
web/admin ──────┼──────┤ │
React + TS │ ├── livekit-server (SFU) + coturn (TURN) │
│ ├── minio (fotos, recados, gravações) │
│ └── otel-collector → prometheus/grafana/loki │
└──────────────────────────────────────────────────────┘
│
FCM (Android) · APNs + PushKit/CallKit (iOS)
Princípio central: o backend é stateless. Todo estado vive no Postgres, no MinIO ou no LiveKit. Nenhuma informação de chamada em andamento, timer de escalonamento ou sessão fica em memória de processo. Isso é o que permite replicar o backend e sobreviver a restart sem deixar visitante preso na porta.
2. Stack por camada
| Camada | Tecnologia | Justificativa |
|---|---|---|
| Backend | Spring Boot 3 + Kotlin (JDK 21) | Transações declarativas, Actuator, ShedLock, Resilience4j e Spring Security de fábrica. A robustez aqui vem de infraestrutura pronta e testada, não de código nosso |
| Persistência | PostgreSQL 17 + PostGIS | Geofence do visitante (ST_DWithin), fila durável, outbox e dados de negócio num só lugar transacional |
| Migrations | Flyway | Versionamento linear e auditável do schema |
| Acesso a dados | jOOQ | SQL tipado. Preferido sobre JPA aqui porque as consultas de auditoria e relatório do admin são analíticas, e JPA atrapalha nesse perfil |
| Mídia | LiveKit (self-hosted) + coturn | SFU open-source, dockerizado, com SDKs oficiais Android, Swift e JS |
| Objetos | MinIO (API S3) | Fotos, recados em vídeo e gravações. Trocável por S3 sem mudar código |
| Apps móveis | Compose Multiplatform (Android + iOS) | Uma base de UI para morador e operador |
| Web visitante | Vite + React + TS (PWA) | TTI ~1s em 4G. O visitante não instala nada e não espera |
| Web admin | Vite + React + TS | Mesma toolchain; ecossistema maduro de tabela densa, mapa e player |
| Compartilhado | Kotlin Multiplatform (shared/) |
DTOs e máquina de estados, únicos entre backend e apps |
| Observabilidade | OpenTelemetry → Prometheus / Grafana / Loki | SLOs medidos, não presumidos |
3. Estrutura do monorepo
Projeto-Portaria/
├── settings.gradle.kts # inclui :backend e :shared
├── shared/ # Kotlin Multiplatform
│ └── src/commonMain/kotlin/br/com/portaria/shared/
│ ├── model/ # entidades de domínio
│ ├── dto/ # kotlinx.serialization — contrato de rede
│ ├── state/ # máquina de estados (VisitStateMachine)
│ └── validation/ # regras compartilhadas
├── backend/ # Spring Boot 3 + Kotlin
│ └── src/main/kotlin/br/com/portaria/
│ ├── domain/ # entidades, agregados, portas
│ ├── application/ # casos de uso (services)
│ ├── adapter/
│ │ ├── in/rest/ # controllers + OpenAPI
│ │ ├── in/ws/ # WebSocket de sinalização
│ │ └── out/ # jooq, livekit, push, storage, notification
│ ├── infra/ # outbox, scheduler, security, config
│ └── resources/db/migration/ # Flyway
├── apps/ # Compose Multiplatform
│ ├── composeApp/ # código comum (morador + operador)
│ ├── androidApp/
│ └── iosApp/
├── web/
│ ├── visitor/ # Vite + React + TS (PWA)
│ ├── admin/ # Vite + React + TS
│ └── shared-ui/ # design tokens + componentes comuns
├── infra/
│ ├── docker-compose.yml
│ ├── docker-compose.prod.yml
│ ├── livekit/ coturn/ traefik/ observability/
└── docs/ prompts/
Gradle governa shared/, backend/ e apps/. pnpm workspaces governa web/. São dois mundos de build que só se encontram no contrato OpenAPI.
Fluxo de contratos
shared/dto (Kotlin) ──────► backend usa direto
──────► apps Compose usam direto
│
└──► backend expõe /v3/api-docs (OpenAPI)
│
└──► openapi-typescript ──► web/*/src/api/types.ts
Os webs nunca escrevem tipos de API à mão. São gerados no build a partir do OpenAPI, o que faz uma mudança de contrato quebrar o build do front em vez de quebrar em produção.
4. O módulo shared/
Contém apenas o que precisa ser idêntico entre servidor e app. Não é uma biblioteca de utilidades.
// shared/src/commonMain/kotlin/br/com/portaria/shared/state/VisitState.kt
enum class VisitState {
PENDENTE, TOCANDO, ESCALONADA, FILA_OPERADOR,
EM_CHAMADA, AUTORIZADA, NEGADA, RECADO_EM_VIDEO, EXPIRADA, CANCELADA
}
object VisitStateMachine {
private val transicoes: Map<VisitState, Set<VisitState>> = mapOf(
PENDENTE to setOf(TOCANDO, CANCELADA),
TOCANDO to setOf(EM_CHAMADA, ESCALONADA, AUTORIZADA, NEGADA, CANCELADA),
ESCALONADA to setOf(EM_CHAMADA, FILA_OPERADOR, RECADO_EM_VIDEO, AUTORIZADA, NEGADA, EXPIRADA),
// ...
)
fun permite(de: VisitState, para: VisitState) = para in (transicoes[de] ?: emptySet())
fun ehFinal(estado: VisitState) = estado in setOf(AUTORIZADA, NEGADA, EXPIRADA, CANCELADA, RECADO_EM_VIDEO)
}
A mesma classe valida no app (para desabilitar botões) e no servidor (para rejeitar comandos inválidos). O servidor é a autoridade — o cliente só antecipa.
5. Os sete padrões de robustez
Não são recomendações. São o núcleo de confiabilidade do sistema, e cada um existe porque há um modo de falha concreto em que gente fica presa na porta.
5.1 Transactional outbox
Falha que previne: a visita é criada, o processo cai antes de publicar a notificação, e o morador nunca fica sabendo que há alguém na porta.
@Transactional
fun criarVisita(cmd: CriarVisitaCommand): Visit {
val visita = visitRepository.save(Visit.nova(cmd))
outbox.enfileirar(VisitaCriada(visita.id, visita.unitId)) // mesma transação
return visita
}
Um publisher separado lê a outbox, despacha e marca como publicado, com retry e backoff exponencial. Entrega ao menos uma vez — por isso os consumidores são idempotentes.
5.2 Timeouts como jobs persistidos
Falha que previne: o processo que segurava o timer de 20 segundos morre e o escalonamento nunca acontece.
Nunca delay(), Timer ou @Scheduled de instância única guardando estado. O timeout é uma linha na tabela scheduled_jobs com run_at. Um poller com ShedLock garante que apenas uma réplica execute cada job.
@Scheduled(fixedDelay = 1000)
@SchedulerLock(name = "visit-timeouts", lockAtMostFor = "30s")
fun processarTimeouts() { /* SELECT ... FOR UPDATE SKIP LOCKED */ }
5.3 Optimistic locking
Falha que previne: dois moradores da mesma unidade respondem juntos; um autoriza, o outro nega, e o último a gravar vence em silêncio.
Coluna version em visits. Conflito devolve 409 com o estado atual, e o app mostra "outro morador já respondeu".
5.4 Idempotência
Falha que previne: morador com 4G ruim toca "Autorizar" três vezes e gera três autorizações.
Toda mutação aceita header Idempotency-Key. A tabela idempotency_keys guarda a resposta por 24h; repetição devolve a resposta original sem reexecutar.
5.5 Degradação graciosa
Falha que previne: LiveKit fora do ar significa prédio trancado.
LiveKit indisponível (circuit breaker aberto)
└─► modo ÁUDIO (menos banda, mesma sala)
└─► modo FOTO+TEXTO (visitante manda foto, morador aprova sem chamada)
└─► FILA_OPERADOR (humano resolve por telefone)
O nível de degradação vigente é exposto em /actuator/health e visível no painel admin.
5.6 Fila durável em Postgres
SELECT ... FOR UPDATE SKIP LOCKED sobre a tabela job_queue. Redis existe apenas para o LiveKit (obrigatório em multi-instância) e para rate limiting — nunca para dados que não podem se perder. Menos peças móveis, e o enfileiramento participa da mesma transação dos dados.
5.7 SLO medido
| SLO | Alvo | Métrica |
|---|---|---|
| QR → celular do morador tocando | p95 < 5s | portaria.visit.ring_latency |
| Taxa de atendimento pelo morador | > 70% | portaria.visit.answered_ratio |
| QR → resposta em entrega | p95 < 10s | portaria.delivery.resolution_latency |
| Abandono do entregador | < 10% | portaria.delivery.abandoned_ratio |
| Disponibilidade do fluxo de entrada | 99,5% | uptime do caminho crítico |
Sem essas métricas não se sabe se o produto está funcionando — a falha aqui é silenciosa por natureza.
6. Segurança — visão arquitetural
Três identidades distintas, três mecanismos:
| Quem | Autenticação | Duração |
|---|---|---|
| Visitante | JWT efêmero emitido ao validar o QR + geofence. Sem cadastro | 15 min, escopo de uma visita |
| Morador / operador | OIDC (Spring Security) + refresh token no keystore do dispositivo | Access 15 min, refresh 30 dias |
| Admin | OIDC + MFA obrigatório | Sessão de 8h |
Busca cega de unidade: o endpoint de destino recebe bloco + unidade e nunca confirma se existe ou quem mora lá. Resposta idêntica para unidade válida e inválida — inclusive na linha do tempo de estados: a visita-sombra percorre os mesmos estados nos mesmos tempos sem notificar ninguém (03-FLUXOS-E-CONTRATOS.md §2). Isso impede que qualquer pessoa com o QR enumere quem mora onde — um problema de segurança física antes de ser de privacidade. Detalhes em 06-LGPD-E-SEGURANCA.md.
7. Multi-tenant preparado, não ativado
Toda tabela de negócio tem tenant_id NOT NULL. Todo repositório filtra por ele. Policies de Row Level Security são criadas nas migrations mas ficam DISABLEd na v1.
CREATE POLICY tenant_isolation ON visits
USING (tenant_id = current_setting('app.current_tenant')::uuid);
ALTER TABLE visits DISABLE ROW LEVEL SECURITY; -- v1
Ativar multi-tenancy vira ENABLE ROW LEVEL SECURITY + a UI de gestão. Sem isso, adicionar tenant_id depois seria reescrever todo o schema e toda query.
8. ADRs — decisões e o que se abriu mão
ADR-001 · Spring Boot em vez de Ktor. Ktor é mais leve e idiomático, mas minimalista: agendamento, retry, métricas, transações e circuit breaker seriam código nosso. Num sistema onde falha silenciosa significa gente presa na porta, preferimos infraestrutura madura à elegância. Custo: mais cerimônia, startup mais lento, imagem maior.
ADR-002 · React em vez de Compose/Wasm na web. O bundle Wasm (3–8MB) daria TTI de 3–6s em 4G na tela do visitante — abandono garantido na portaria. Custo: a UI web não é compartilhada com os apps; só os contratos são.
ADR-003 · LiveKit self-hosted em vez de gerenciado. Atende o requisito de infra dockerizada e evita custo por minuto no núcleo do produto. Custo: operar SFU, TURN e banda. Mitigado por VideoCallProvider, que permite trocar por gerenciado sem tocar nos apps.
ADR-004 · jOOQ em vez de JPA. As consultas mais complexas do sistema são analíticas (auditoria, relatórios, filtros do admin), onde JPA atrapalha. Custo: passo de geração de código no build.
ADR-005 · Fila em Postgres em vez de Redis/Kafka. Volume real é de ~0,3 req/s; Kafka seria desproporcional. Postgres dá durabilidade e transacionalidade com os dados. Custo: não escala para milhões de mensagens — irrelevante nesta ordem de grandeza.
ADR-006 · Legítimo interesse em vez de consentimento. Consentimento sob "aceite ou não entre" não é livre e não é base legal válida. Ver 06-LGPD-E-SEGURANCA.md.
ADR-007 · Sem reconhecimento facial na v1. Elevaria os dados a sensíveis (LGPD art. 11), com exigência jurídica muito maior. Decisão de produto com fundamento legal, não limitação técnica.
9. Referências
02-MODELO-DE-DADOS.md— schema, outbox, fila, versionamento03-FLUXOS-E-CONTRATOS.md— máquinas de estado e API05-INFRA-DOCKER.md— compose, portas, dimensionamento06-LGPD-E-SEGURANCA.md— bases legais e modelo de ameaças