⚠️ Projeto didático. Este repositório é uma prova de conceito, criada para ensinar como estruturar um sistema de negócio com modelagem de domínio rica, contextos delimitados e comunicação entre módulos por eventos de domínio (Result pattern, Unit of Work, Domain Events, Transactional Outbox e entrega assíncrona at-least-once). Ele não é uma referência pronta para produção — várias simplificações foram feitas de propósito para manter o foco no aprendizado (ver Limitações).
Demonstra, ponta a ponta, como um ERP simples pode ser organizado em módulos que representam bounded contexts, mantendo um Core isolado de frameworks, e como um módulo reage ao que acontece em outro sem conhecê-lo — via eventos capturados na mesma transação do agregado e despachados depois, fora da requisição.
Cliente/Swagger → [API] → [Dispatcher] → [Use Case] → [Agregado emite evento]
│
(mesma transação, via interceptor do EF Core)
▼
[PostgreSQL: eventos.outbox]
│
[BackgroundService do Outbox] lê o lote pendente
│
1 escopo + 1 transação por evento → [Dispatcher de Eventos]
│
┌───────────────────────────────┼───────────────────────────┐
▼ ▼ ▼
[Estoque] [Financeiro] [Produção]
entrada/saída de saldo título a pagar/receber unicidade da receita
- A API recebe a requisição e o Dispatcher resolve o caso de uso correspondente (mediator próprio, sem MediatR).
- O caso de uso carrega o agregado, aplica a regra e o agregado emite eventos de domínio.
- Um interceptor de
SaveChangescaptura esses eventos e grava as linhas emeventos.outboxna mesma transação que persiste o agregado — atomicidade entre o dado e o evento. - Um BackgroundService lê a caixa de saída em lotes, reidrata cada evento e o entrega aos seus manipuladores, um escopo e uma transação por evento.
- Os handlers de outros módulos reagem: Estoque movimenta saldo, Financeiro emite título, Produção garante a unicidade da receita ativa.
Os documentos estratégicos que embasam o desenho — mapa de contextos e catálogo de eventos — estão em Documentação complementar.
| Padrão / mecanismo | Onde | O que resolve |
|---|---|---|
| Monólito modular por bounded context | simple-erp.Core/Modulos |
Cada módulo tem entidades, VOs, eventos, interfaces e casos de uso próprios; referências entre módulos só por Id, nunca por objeto de domínio. |
| Core isolado de frameworks | simple-erp.Core.csproj |
O .csproj do Core não tem nenhum PackageReference: nem EF, nem ASP.NET, nem MediatR. A regra de negócio não sabe onde é persistida nem como é exposta. |
| Domínio + Aplicação no mesmo núcleo | Core (Entidades + UseCases) |
Casos de uso conversam diretamente com o domínio, sem camada de tradução artificial. |
| Result pattern | Compartilhado/Base/Resultado.cs |
Falha de negócio é valor de retorno (Resultado<T>), não exceção — o fluxo esperado fica explícito na assinatura. |
| Objetos de Valor | */ObjetosDeValor |
Cpf, Cnpj, Email, Dinheiro, Quantidade, CodigoProduto… validam na construção: um objeto inválido não chega a existir. |
| Unit of Work + transação explícita | IUnitOfWork / UnitOfWork |
Uma transação por operação; os repositórios compartilham o mesmo DbContext. |
Mediator próprio (IDispatcher) |
Api/Mediador/Dispatcher.cs |
O controller depende só do IDispatcher; o use case é resolvido pelo par entrada/saída, sem biblioteca externa. |
| Domain Events | Compartilhado/Base/EventoDeDominio.cs |
O agregado registra o que aconteceu; quem reage é problema de outro módulo. |
| Transactional Outbox | Interceptadores/CapturaDeEventosParaOutboxInterceptor.cs + tabela eventos.outbox |
Elimina a janela "gravou o pedido mas perdeu o efeito colateral": agregado e evento são salvos juntos ou nenhum dos dois. |
| Consistência eventual entre contextos | ProcessadorDeEventosPendentes |
Respeita "uma transação = um agregado": o efeito em outro módulo vem depois, em transação separada. |
| 1 escopo + 1 transação por evento | ProcessadorDeEventosPendentes |
Falha em um evento não contamina o próximo do lote; o efeito do handler e a marcação de "processado" são confirmados juntos. |
| Teto de tentativas (poison message) | MaximoDeTentativas = 5 |
Um evento defeituoso para de ser retomado e deixa de travar a fila atrás de si — a linha fica pendente, com o último erro registrado, para análise. |
| Entrega at-least-once | Outbox | O evento pode ser reentregue; handlers precisam ser idempotentes. É o contrato honesto de sistemas distribuídos, não uma falha da implementação. |
| Registro histórico dos eventos | Tabela eventos.outbox |
O catálogo de eventos deixa de ser só documentação: fica auditável no banco. |
| Schema por módulo | Persistencia/Esquemas.cs |
parceiros, catalogo, suprimentos, estoque, producao, vendas, financeiro, eventos — a fronteira do contexto aparece também no banco. |
| EF Core Migrations aplicadas no start | Configuracao/MigracaoExtensions.cs |
O mesmo caminho de evolução do schema vale para dev, container e produção (sem EnsureCreated). |
| Carga inicial idempotente | Configuracao/Seed |
Base de demonstração só em Development, sem duplicar em reinícios. |
| # | Projeto | Tipo | Responsabilidade |
|---|---|---|---|
| 1 | simple-erp.Core |
Class library (.NET 10) | Domínio + Aplicação. Entidades, objetos de valor, eventos, handlers, interfaces de repositório e casos de uso, organizados por módulo. Zero dependências externas. |
| 2 | simple-erp.Infraestrutura |
Class library (.NET 10) | EF Core 10 + Npgsql: DbContext, configurações, conversores de VO, repositórios, Unit of Work, interceptor e processador do Outbox, migrations. |
| 3 | simple-erp.Api |
ASP.NET Core 10 (REST) | Controllers, Swagger, mediator, LogService, carga inicial e o BackgroundService que processa a caixa de saída. |
| 4 | simple-erp.Testes |
xUnit (.NET 10) | ~93 arquivos de teste: domínio, casos de uso e repositórios (estes com Postgres real via Testcontainers). |
| — | PostgreSQL 17 | Infra (container) | Banco simple_erp, com um schema por módulo. |
| — | pgAdmin 4 | Infra (container) | Inspeção do banco, já com a conexão registrada. |
São 8 bounded contexts, cada um com agregados, eventos e casos de uso próprios e um schema dedicado no banco; referências entre eles só por Id, nunca por objeto de domínio. Panorama:
| Contexto | Papel no mapa |
|---|---|
| ParceirosComerciais · CatalogoDeProdutos | Upstream de identidade |
| Suprimentos · Vendas · Producao (+ Composicao) | Transacionais (núcleo do fluxo) |
| Estoque | Hub de integração |
| Financeiro | Downstream |
O tratamento estratégico completo — tipo de subdomínio (core / suporte / genérico), agregado raiz, contagem de casos de uso e eventos, schema e os padrões de context mapping — está em anexos/mapa-contexto.md.
Toda reação entre módulos acontece por evento de domínio via Outbox, nunca por referência direta. Hoje 4 eventos cruzam fronteira (compra, venda, produção) mais 1 intra-contexto (unicidade da receita). O headline é o fan-out: PedidoDeCompraEfetivado dispara reações em dois módulos que Suprimentos não conhece — Estoque (entrada de saldo) e Financeiro (título a pagar). A ficha de cada evento — publicador, gatilho, payload e assinantes — está em anexos/mapa-eventos.md.
Especificação completa em
anexos/requisitos-funcionais.md. Resumo abaixo.
- RF01 — Parceiros comerciais. Cadastrar, editar, consultar (por id e paginado com filtros), inativar e reativar clientes e fornecedores, com CPF/CNPJ validado e único por tipo de parceiro.
- RF02 — Catálogo de produtos. Cadastrar, editar, listar, inativar/reativar produtos e classificá-los como Fabricado ou Matéria-Prima.
- RF03 — Compras. Criar pedido de compra para um fornecedor, adicionar/remover itens, aprovar, efetivar e cancelar, com cálculo de total e máquina de estados.
- RF04 — Estoque. Manter saldo por produto e registrar movimentações tipadas (entrada por compra, saída por venda, saída/entrada por produção, ajuste), consultar saldo e extrato paginado, impedindo saída sem saldo.
- RF05 — Composição de produto. Definir a receita de um produto fabricado com versionamento e histórico, ativar/inativar versões e garantir uma única receita ativa por produto.
- RF06 — Produção. Criar ordem de produção, calcular a necessidade de insumos a partir da composição ativa, confirmar, concluir e cancelar conforme o status.
- RF07 — Vendas. Criar pedido de venda para um cliente, adicionar/remover itens, aplicar desconto, aprovar, concluir e cancelar (com motivo).
- RF08 — Financeiro. Emitir títulos a pagar e a receber, registrar baixas (parciais/total), cancelar e consultar títulos.
- RF09 — Integração por eventos. Toda reação entre módulos acontece por evento de domínio persistido em Outbox e despachado fora da requisição, com retentativa e teto de tentativas.
ℹ️ Estado da API REST: todos os 8 contextos têm controller e estão expostos no Swagger — o fan-out entre módulos pode ser exercitado ponta a ponta pela API, não só pelos testes:
Contexto Rota base Parceiros Comerciais api/clientes,api/fornecedoresCatálogo de Produtos api/produtosSuprimentos (Compras) api/pedidos-de-compra(+/aprovar,/efetivar,/cancelar)Estoque api/estoqueComposição api/produtos/{id}/composicoes,api/composicoes/{id}(+/ativar,/inativar)Produção api/ordens-de-producao(+/confirmar,/concluir,/cancelar)Vendas api/pedidos-de-venda(+/aprovar,/concluir,/cancelar)Financeiro api/financeiro/titulos(+ baixas e cancelamento)
Na raiz do repositório:
docker compose up --buildIsso sobe: PostgreSQL, a API (que aplica as migrations e, em Development, a carga inicial) e o pgAdmin.
Endereços padrão:
| Serviço | URL / Porta | Credenciais |
|---|---|---|
| API (Swagger) | http://localhost:8080/swagger | — |
| pgAdmin | http://localhost:5050 | admin@simpleerp.com / admin |
| PostgreSQL | localhost:5432 |
simple_erp / simple_erp (db simple_erp) |
Para parar:
docker compose downOs volumes
simple-erp-pgdataesimple-erp-pgadminpersistem entre reinícios. Para começar do zero (banco vazio, migrations e seed reaplicados):docker compose down -v.
docker compose up -d postgres
dotnet run --project simple-erp.ApiA connection string padrão do appsettings.json já aponta para localhost:5432.
dotnet testOs testes de repositório sobem um PostgreSQL descartável via Testcontainers — precisam do Docker em execução.
O arquivo está em collection/simple-erp-fluxo-completo.insomnia.json — uma trilha guiada com ~97 requests em 10 pastas que percorre o sistema inteiro pela API REST, com requests de PROVA que confirmam cada efeito gerado por evento.
-
No Insomnia: Import → From File e selecione o arquivo.
-
Use o environment Local (docker compose) (já traz
base_urle os ids de apoio). -
Rode as pastas na ordem — cada uma demonstra uma etapa do fluxo orientado a eventos:
- 00 — Diagnóstico e estado inicial: API no ar, seed aplicado, e a prova de que o estoque começa zerado e sem títulos.
- 01 — Catálogo: listagens filtradas, edição, inativar/reativar, classificação Fabricado / Matéria-Prima.
- 02 — Composição (evento intra-contexto): definir receita v1 e v2, ativar, e provar que o handler desativou a versão anterior (unicidade da receita ativa).
- 03 — Compra → evento: aprovar e efetivar um pedido, e provar a entrada de estoque + título a pagar (o fan-out central).
- 04 — Produção → evento: concluir uma ordem e provar a saída dos insumos + entrada do acabado.
- 05 — Venda → evento: aprovar um pedido e provar a saída de estoque + título a receber.
- 06 — Financeiro: baixas parciais/total, baixa acima do saldo (400) e cancelamento.
- 07 — Estoque: extrato consolidado.
- 08 — Erros de domínio e de contrato: casos negativos (400/404).
- 09 — Rotas restantes: listagens e cancelamentos.
- Suba o ambiente e observe os logs da API:
Processamento da caixa de saída iniciado (lote de 20, intervalo de 5s). - Efetive um pedido de compra pela collection (pasta 03) ou pelo Swagger (
POST api/pedidos-de-compra/{id}/efetivar). - Consulte a tabela
eventos.outbox: a linha doPedidoDeCompraEfetivadoaparece comprocessado_em_utcnulo. - Aguarde até 5 segundos e consulte de novo:
processado_em_utcpreenchido, e o logEvento de domínio despachado a partir da caixa de saída. - Confirme o fan-out:
estoque.movimentacoesrecebeu a entrada por compra efinanceiro.titulosganhou um título a pagar — dois módulos que Suprimentos não conhece, reagindo ao mesmo evento.
-- Cadastros
SELECT * FROM parceiros.clientes;
SELECT * FROM parceiros.fornecedores;
SELECT * FROM catalogo.produtos;
-- Transacional
SELECT * FROM suprimentos.pedidos_de_compra;
SELECT * FROM vendas.pedidos_de_venda;
SELECT * FROM producao.ordens_de_producao;
SELECT * FROM producao.composicoes_de_produto;
-- Efeitos gerados por evento
SELECT * FROM estoque.saldos;
SELECT * FROM estoque.movimentacoes;
SELECT * FROM financeiro.titulos;
-- Caixa de saída: pendentes, processados e falhas
SELECT nome_do_evento, id_agregado_origem, criado_em_utc, processado_em_utc, tentativas, ultimo_erro
FROM eventos.outbox
ORDER BY id DESC;
-- Só o que ainda não foi despachado
SELECT * FROM eventos.outbox WHERE processado_em_utc IS NULL;
-- Poison messages: estouraram o teto de tentativas
SELECT * FROM eventos.outbox WHERE processado_em_utc IS NULL AND tentativas >= 5;Base: .NET 10 · C# · ASP.NET Core (controllers) · Swashbuckle/Swagger
Persistência: PostgreSQL 17 · EF Core 10 · Npgsql · EFCore.NamingConventions (snake_case) · EF Migrations
Testes: xUnit · FluentAssertions · NSubstitute · Testcontainers.PostgreSql
Infra local: Docker Compose · pgAdmin 4
Note o que não está aqui: nenhuma biblioteca de mediator, de mapeamento ou de validação. O mediator, o Result pattern e a validação nos objetos de valor são do próprio projeto — de propósito, para que o mecanismo fique visível em vez de escondido atrás de um pacote.
| Documento | Conteúdo |
|---|---|
anexos/requisitos-funcionais.md |
Especificação de origem do domínio (RF01–RF09). |
anexos/mapa-contexto.md |
Mapa de contextos: os 8 bounded contexts, o tipo de subdomínio (core / suporte / genérico), o papel de cada um (upstream / downstream / hub) e os padrões de integração. |
anexos/mapa-eventos.md |
Catálogo de eventos: para cada evento, quem publica, quando, o payload e quem reage — mais a matriz publicador → assinante e os fluxos de Event Storming. |
apresentação/Domain-Driven-Design.pdf |
Material de apresentação sobre DDD. |
O mapa de contexto e o catálogo de eventos são companheiros e se referenciam entre si: o primeiro é estratégico (contextos e relações), o segundo é tático (a ficha de cada evento). Ambos ficam versionados junto ao código, em
anexos/.
Estas simplificações são intencionais para focar no aprendizado; em produção você trataria cada uma:
- Segredos em texto claro no
docker-compose.ymle noappsettings.json(use secrets / variáveis de ambiente seguras). - API sem autenticação/autorização.
- Outbox com polling e instância única: o worker roda dentro do próprio processo da API, sem lock distribuído (
FOR UPDATE SKIP LOCKED). Com mais de uma réplica, o mesmo evento pode ser processado em paralelo. - Sem broker de mensageria: o despacho é in-process. O passo natural de evolução é publicar o conteúdo do Outbox em RabbitMQ/Kafka em vez de chamar os handlers diretamente.
- Handlers não são idempotentes por construção: a entrega é at-least-once, mas não há chave de idempotência no lado consumidor — uma reentrega pode duplicar um efeito.
- Sem retry com atraso (backoff): as 5 tentativas acontecem na cadência do polling, sem espaçamento crescente.
- Sem expurgo/retenção da Outbox: a tabela cresce indefinidamente; falta um job de limpeza das linhas já processadas.
- Sem reprocessador de poison messages: a linha que estoura as 5 tentativas fica parada no banco, sem DLQ nem ferramenta de reenvio.
- Sem métricas e sem tracing distribuído (o
ILogServicecobre só o logging estruturado). - Ids gerados pela aplicação, com valores fixos no seed — sem estratégia de geração distribuída.