Notas de design dos bounded contexts do Backstream. Revisitar antes de criar novos módulos. Ancorado no escopo do MVP. Para a visão de fora (quem usa o sistema e com quais sistemas externos ele troca dado), ver C4 Context.
Monolito modular com módulos isolados por bounded context. Cada módulo tem seu próprio domínio, application, infrastructure, e expõe um contrato público em public/ para outros módulos consumirem (commands, queries, events, types). Comunicação entre módulos via:
modules/*/public/events/Cada módulo expõe em public/:
createCredentials, createProfile, …)profileSummary, userSummary, …)AuthenticatedUser, AccessTokenVerifier, …)Regra dura: features e outros módulos só importam de modules/*/public/ — nunca de domain/, application/ ou infrastructure/ de outro módulo.
Identidade e credencial. Source of truth de email, senha, roles, revogação, tokens e OAuth.
User (email, roles, revokedAt)createCredentials, createCredentialsFromProvider, tryLoginViaProvider, issueTokensuserSummaryprofile. Também não cuida de slug, PIX, mídias, doações.CredentialsCreated, SocialUserRegisteredIdentidade comum de qualquer usuário, independente de role. Source of truth de name, phone e avatarUrl.
ProfileuserId (FK lógica pro auth), name, phone, avatarUrlprofileCompleted = phone preenchido — sinaliza onboarding incompleto pro frontcreateProfile (escrita cross-módulo; update fica no use case interno via rota própria)profileSummary (name, phone, avatarUrl, profileCompleted)ProfileCreated, ProfileUpdatedProfile ≠ módulo guarda-chuva. O profile guarda dados que valem pra todo usuário (admin, streamer, donor). Os módulos streamer e donor (futuros) guardam dados de role específico com invariantes próprias (slug único, username único). O "guarda-chuva" rejeitado seria um módulo único que misturasse identidade comum + perfil de streamer + perfil de doador — invariantes e lifecycles diferentes no mesmo aggregate. profile existe justamente pra evitar que auth cresça com name/phone.
Perfil público de quem cria conteúdo. Source of truth do slug e dos dados de payout.
StreameruserId (FK lógica pro auth), displayName, slug, pixKey, pagarmeAccountIdplataforma.com/seunome)RegistrationCompleted (ou evento futuro de onboarding de streamer) cria Streamer quando role é streamer, ou via use case explícito de "completar cadastro de streamer"StreamerOnboarded, StreamerSlugChangedGestão da carteira de mídias do streamer com metas de doação.
MediastreamerId (FK), name, category, targetAmount, currentAmount, description, statusawaiting_goal, goal_reached, reacted, cancelledgoal_reached; cancelamento exige justificativaMediaGoalReached (notifica streamer in-app), MediaCancelled (futuro: notifica doadores), MediaReactedFluxo de doação via PIX, integração Pagar.me, anti-impersonation.
DonationmediaId, amount, donorName, donorUserId (nullable — doação anônima), pagarmeChargeId, status (pending, paid, failed)donorUserId === null, donorName livredonorUserId !== null, donorName puxado do donor (ou autoriza usar pseudônimo)donorName (anônimo) coincide com username de algum Donor registrado, rejeita ou força sufixo. Implementação via query port pro módulo donorStreamer), webhook confirma pagamento e atualiza status + dispara eventoDonationConfirmed (consumido por media para atualizar progresso da meta)Perfil opcional do doador registrado. Permite histórico próprio e proteção contra impersonation.
DonoruserId (FK auth), usernameDonorRegisteredfeatures/Features são orquestrações de produto que coordenam dois ou mais módulos. Vivem em apps/api/src/features/ e têm a mesma estrutura interna (application, infrastructure), mas não são bounded contexts — não têm aggregate próprio nem source of truth de domínio.
Exemplo existente: features/registration — RegisterUserUseCase cria credenciais (auth) + profile na mesma transação e emite tokens.
| Pergunta | Resposta → onde fica |
|---|---|
| Quantos módulos a operação escreve? | Um → use case no módulo. Dois ou mais → feature. |
| Se eu removesse o módulo, o endpoint sobreviveria? | Sim → não pertence ao módulo. Não → pertence. |
| É jornada de produto ou operação técnica sobre uma entidade? | Jornada (registro, checkout, onboarding) → feature. CRUD/atualização de entidade → módulo. |
Escrita cruzada → feature. Leitura simples (agregar dados na resposta HTTP sem orquestração rica) → query port público injetado na rota do módulo dono. Ex.: login/me (auth) recebem ProfileSummaryQuery injetada e compõem via ProfileSummaryComposer — auth não importa profile internamente. Ver ADR 0002.
Regra dura: features só importam de modules/*/public/.
Duas interfaces, mesma implementação (DrizzleTransactionService em shared/transaction/):
TransactionRunner — recebido por use cases e features. Expõe run(callback) que abre transação e commita (ou rollback) ao final.DbContext — recebido por repositórios. Expõe current() que retorna o client Drizzle ativo (transação em andamento ou conexão default).Por trás: AsyncLocalStorage guarda a transação ativa durante o callback de run. Repositórios chamam dbContext.current() sem saber se estão dentro ou fora de uma transação.
Guard-rail de tipo: use case não recebe DbContext (não pode fazer SQL direto); repositório não recebe TransactionRunner (não abre transação). Quem orquestra a unidade de trabalho é sempre o use case/feature.
Durante uma transação, integration events são enfileirados — não publicados imediatamente. IntegrationBusAfterCommit acumula eventos com enqueue() e só chama flush() (publica no bus real) depois que txRunner.run(...) resolve com sucesso, ou seja, após o commit.
Isso evita dual-write: evento publicado para um registro que ainda pode dar rollback. Se o commit falhar, a fila nunca é flushed. Se um subscriber lançar após o flush, a request falha mas os dados já estão persistidos — trade-off documentado. Ver ADR 0001.
Pelo critério "menor coisa que prova a hipótese central":
(auth e profile já existem como fundação de identidade.)
Validação acontece no donation ao receber dados do doador anônimo:
Implementação simples no backend, sem UI sofisticada (alinhado com o escopo MVP).
profile atual é legítimo porque guarda só identidade comum; streamer e donor serão módulos separados.userId resolve. Quando precisar de dado fresco do user, query port no auth (ou profile, conforme o dado).Roles em auth/domain/role.ts: admin, streamer, donor. "Donor" foi escolhido em vez de "viewer" porque a plataforma não hospeda stream — o papel é doador, não espectador. Vocabulário alinhado com a ubiquitous language do negócio.
Quando essas features entrarem, decidir o módulo dono:
| Feature | Módulo provável |
|---|---|
| Login social (Twitch, Google, Discord) | auth (provedores OAuth, já tem código) |
| Customização visual (cor, banner, logo) | streamer |
| Catálogo TMDb/Jikan/IGDB | novo módulo catalog |
| Wishlist com upvote | novo módulo wishlist |
| Calendário/agenda | streamer |
| Notificações por email | novo módulo notification (escuta integration events) |
| Browser source para OBS | extensão do media ou novo streaming-overlay |
| Bot de chat | novo módulo chat-bot |
| Leaderboard, badges | novo módulo gamification |
| Cartão de crédito | extensão do donation |
| Carteira interna | novo módulo wallet |
donation.start({ donorName, ... })
↓
→ query port: donor.findByUsername(donorName)
→ se existe: rejeita ou força sufixo (ex: "joao#1234")
→ se não existe: aceita