Exemplo de Monolito Modular em .NET | Monolitos | Insights
- Exemplo de Monolito Modular em .NET | Monolitos | Insights

Abaixo está um exemplo enxuto, mas estruturalmente correto, de monólito modular em .NET.
Vou usar 3 contextos:
ClientesPedidosFinanceiro
E mostrar 2 formas corretas de conversa:
- síncrona por contrato público
- assíncrona interna por evento de aplicação/domínio
Estrutura sugerida
src/
ModularMonolith/
Shared/
Abstractions/
IIntegrationEvent.cs
IEventDispatcher.cs
Events/
IntegrationEventHandler.cs
Modules/
Clientes/
Application/
Contracts/
IClienteConsultaService.cs
Dtos/
ClienteResumoDto.cs
Services/
ClienteConsultaService.cs
Domain/
Cliente.cs
Infrastructure/
ClienteRepository.cs
Pedidos/
Application/
Commands/
CriarPedidoCommand.cs
CriarPedidoCommandHandler.cs
Events/
PedidoCriadoEvent.cs
Ports/
IPedidoRepository.cs
Domain/
Pedido.cs
Infrastructure/
PedidoRepository.cs
Financeiro/
Application/
EventHandlers/
PedidoCriadoEventHandler.cs
Ports/
ICobrancaRepository.cs
Domain/
Cobranca.cs
Infrastructure/
CobrancaRepository.cs
Program.cs
A ideia central é:
Pedidosnão conhece entidade nem repositório interno deClientesPedidosdepende apenas de um contrato público- após salvar o pedido,
Pedidospublica um evento interno Financeiroreage ao evento e cria a cobrança
1. Contratos compartilhados mínimos
Evento interno
namespace ModularMonolith.Shared.Abstractions;
public interface IIntegrationEvent
{
DateTime OccurredOnUtc { get; }
}
Dispatcher de eventos
namespace ModularMonolith.Shared.Abstractions;
public interface IEventDispatcher
{
Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : class, IIntegrationEvent;
}
2. Módulo Clientes
DTO público
namespace ModularMonolith.Modules.Clientes.Application.Dtos;
public sealed record ClienteResumoDto(
Guid Id,
string Nome,
bool Ativo,
decimal LimiteCreditoDisponivel
);
Contrato público do módulo Clientes
Esse é o ponto importante: outro módulo fala com Clientes por isso aqui.
using ModularMonolith.Modules.Clientes.Application.Dtos;
namespace ModularMonolith.Modules.Clientes.Application.Contracts;
public interface IClienteConsultaService
{
Task<ClienteResumoDto?> ObterPorIdAsync(Guid clienteId, CancellationToken ct = default);
}
Entidade interna do módulo Clientes
namespace ModularMonolith.Modules.Clientes.Domain;
public sealed class Cliente
{
public Guid Id { get; private set; }
public string Nome { get; private set; } = string.Empty;
public bool Ativo { get; private set; }
public decimal LimiteCreditoDisponivel { get; private set; }
public Cliente(Guid id, string nome, bool ativo, decimal limiteCreditoDisponivel)
{
Id = id;
Nome = nome;
Ativo = ativo;
LimiteCreditoDisponivel = limiteCreditoDisponivel;
}
}
Repositório interno
using ModularMonolith.Modules.Clientes.Domain;
namespace ModularMonolith.Modules.Clientes.Infrastructure;
public sealed class ClienteRepository
{
private readonly List<Cliente> _clientes =
[
new Cliente(Guid.Parse("11111111-1111-1111-1111-111111111111"), "João", true, 1000),
new Cliente(Guid.Parse("22222222-2222-2222-2222-222222222222"), "Maria", false, 500)
];
public Task<Cliente?> ObterPorIdAsync(Guid id, CancellationToken ct = default)
{
Cliente? cliente = _clientes.FirstOrDefault(c => c.Id == id);
return Task.FromResult(cliente);
}
}
Implementação do contrato público
using ModularMonolith.Modules.Clientes.Application.Contracts;
using ModularMonolith.Modules.Clientes.Application.Dtos;
using ModularMonolith.Modules.Clientes.Infrastructure;
namespace ModularMonolith.Modules.Clientes.Application.Services;
public sealed class ClienteConsultaService(ClienteRepository repository) : IClienteConsultaService
{
public async Task<ClienteResumoDto?> ObterPorIdAsync(Guid clienteId, CancellationToken ct = default)
{
var cliente = await repository.ObterPorIdAsync(clienteId, ct);
if (cliente is null)
return null;
return new ClienteResumoDto(
cliente.Id,
cliente.Nome,
cliente.Ativo,
cliente.LimiteCreditoDisponivel
);
}
}
3. Módulo Pedidos
Comando
namespace ModularMonolith.Modules.Pedidos.Application.Commands;
public sealed record CriarPedidoCommand(
Guid ClienteId,
decimal ValorTotal
);
Evento publicado por Pedidos
using ModularMonolith.Shared.Abstractions;
namespace ModularMonolith.Modules.Pedidos.Application.Events;
public sealed record PedidoCriadoEvent(
Guid PedidoId,
Guid ClienteId,
decimal ValorTotal,
DateTime OccurredOnUtc
) : IIntegrationEvent;
Entidade Pedido
namespace ModularMonolith.Modules.Pedidos.Domain;
public sealed class Pedido
{
public Guid Id { get; private set; }
public Guid ClienteId { get; private set; }
public decimal ValorTotal { get; private set; }
public DateTime CriadoEmUtc { get; private set; }
public Pedido(Guid id, Guid clienteId, decimal valorTotal)
{
if (valorTotal <= 0)
throw new ArgumentException("Valor do pedido deve ser maior que zero.");
Id = id;
ClienteId = clienteId;
ValorTotal = valorTotal;
CriadoEmUtc = DateTime.UtcNow;
}
}
Porta interna do módulo Pedidos
using ModularMonolith.Modules.Pedidos.Domain;
namespace ModularMonolith.Modules.Pedidos.Application.Ports;
public interface IPedidoRepository
{
Task AddAsync(Pedido pedido, CancellationToken ct = default);
}
Repositório interno
using ModularMonolith.Modules.Pedidos.Application.Ports;
using ModularMonolith.Modules.Pedidos.Domain;
namespace ModularMonolith.Modules.Pedidos.Infrastructure;
public sealed class PedidoRepository : IPedidoRepository
{
private readonly List<Pedido> _pedidos = [];
public Task AddAsync(Pedido pedido, CancellationToken ct = default)
{
_pedidos.Add(pedido);
return Task.CompletedTask;
}
}
Handler do comando
Aqui está a parte mais importante.
Repare que Pedidos conversa com Clientes via IClienteConsultaService, e não via entidade/repositório do módulo Clientes.
using ModularMonolith.Modules.Clientes.Application.Contracts;
using ModularMonolith.Modules.Pedidos.Application.Events;
using ModularMonolith.Modules.Pedidos.Application.Ports;
using ModularMonolith.Modules.Pedidos.Domain;
using ModularMonolith.Shared.Abstractions;
namespace ModularMonolith.Modules.Pedidos.Application.Commands;
public sealed class CriarPedidoCommandHandler(
IClienteConsultaService clienteConsultaService,
IPedidoRepository pedidoRepository,
IEventDispatcher eventDispatcher)
{
public async Task<Guid> HandleAsync(CriarPedidoCommand command, CancellationToken ct = default)
{
var cliente = await clienteConsultaService.ObterPorIdAsync(command.ClienteId, ct);
if (cliente is null)
throw new InvalidOperationException("Cliente não encontrado.");
if (!cliente.Ativo)
throw new InvalidOperationException("Cliente inativo.");
if (cliente.LimiteCreditoDisponivel < command.ValorTotal)
throw new InvalidOperationException("Cliente sem limite de crédito.");
var pedido = new Pedido(
Guid.NewGuid(),
command.ClienteId,
command.ValorTotal);
await pedidoRepository.AddAsync(pedido, ct);
await eventDispatcher.PublishAsync(
new PedidoCriadoEvent(
pedido.Id,
pedido.ClienteId,
pedido.ValorTotal,
DateTime.UtcNow),
ct);
return pedido.Id;
}
}
4. Módulo Financeiro
Entidade Cobranca
namespace ModularMonolith.Modules.Financeiro.Domain;
public sealed class Cobranca
{
public Guid Id { get; private set; }
public Guid PedidoId { get; private set; }
public Guid ClienteId { get; private set; }
public decimal Valor { get; private set; }
public DateTime CriadaEmUtc { get; private set; }
public Cobranca(Guid id, Guid pedidoId, Guid clienteId, decimal valor)
{
Id = id;
PedidoId = pedidoId;
ClienteId = clienteId;
Valor = valor;
CriadaEmUtc = DateTime.UtcNow;
}
}
Porta interna
using ModularMonolith.Modules.Financeiro.Domain;
namespace ModularMonolith.Modules.Financeiro.Application.Ports;
public interface ICobrancaRepository
{
Task AddAsync(Cobranca cobranca, CancellationToken ct = default);
}
Repositório interno
using ModularMonolith.Modules.Financeiro.Application.Ports;
using ModularMonolith.Modules.Financeiro.Domain;
namespace ModularMonolith.Modules.Financeiro.Infrastructure;
public sealed class CobrancaRepository : ICobrancaRepository
{
private readonly List<Cobranca> _cobrancas = [];
public Task AddAsync(Cobranca cobranca, CancellationToken ct = default)
{
_cobrancas.Add(cobranca);
return Task.CompletedTask;
}
}
Contrato genérico de handler de evento
using ModularMonolith.Shared.Abstractions;
namespace ModularMonolith.Shared.Events;
public interface IIntegrationEventHandler<in TEvent>
where TEvent : class, IIntegrationEvent
{
Task HandleAsync(TEvent @event, CancellationToken ct = default);
}
Handler do evento
Financeiro não é chamado diretamente por Pedidos. Ele apenas reage ao evento.
using ModularMonolith.Modules.Financeiro.Application.Ports;
using ModularMonolith.Modules.Financeiro.Domain;
using ModularMonolith.Modules.Pedidos.Application.Events;
using ModularMonolith.Shared.Events;
namespace ModularMonolith.Modules.Financeiro.Application.EventHandlers;
public sealed class PedidoCriadoEventHandler(ICobrancaRepository cobrancaRepository)
: IIntegrationEventHandler<PedidoCriadoEvent>
{
public async Task HandleAsync(PedidoCriadoEvent @event, CancellationToken ct = default)
{
var cobranca = new Cobranca(
Guid.NewGuid(),
@event.PedidoId,
@event.ClienteId,
@event.ValorTotal);
await cobrancaRepository.AddAsync(cobranca, ct);
}
}
5. Dispatcher simples em memória
Para um monólito modular, isso já resolve muito bem.
using ModularMonolith.Shared.Abstractions;
using ModularMonolith.Shared.Events;
namespace ModularMonolith.Shared.Events;
public sealed class InMemoryEventDispatcher(IServiceProvider serviceProvider) : IEventDispatcher
{
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : class, IIntegrationEvent
{
var handlers = serviceProvider.GetServices<IIntegrationEventHandler<TEvent>>();
foreach (var handler in handlers)
await handler.HandleAsync(@event, ct);
}
}
6. Registro no DI
Program.cs
using ModularMonolith.Modules.Clientes.Application.Contracts;
using ModularMonolith.Modules.Clientes.Application.Services;
using ModularMonolith.Modules.Clientes.Infrastructure;
using ModularMonolith.Modules.Financeiro.Application.EventHandlers;
using ModularMonolith.Modules.Financeiro.Application.Ports;
using ModularMonolith.Modules.Financeiro.Infrastructure;
using ModularMonolith.Modules.Pedidos.Application.Commands;
using ModularMonolith.Modules.Pedidos.Application.Events;
using ModularMonolith.Modules.Pedidos.Application.Ports;
using ModularMonolith.Modules.Pedidos.Infrastructure;
using ModularMonolith.Shared.Abstractions;
using ModularMonolith.Shared.Events;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ClienteRepository>();
builder.Services.AddSingleton<IClienteConsultaService, ClienteConsultaService>();
builder.Services.AddSingleton<IPedidoRepository, PedidoRepository>();
builder.Services.AddSingleton<CriarPedidoCommandHandler>();
builder.Services.AddSingleton<ICobrancaRepository, CobrancaRepository>();
builder.Services.AddSingleton<IIntegrationEventHandler<PedidoCriadoEvent>, PedidoCriadoEventHandler>();
builder.Services.AddSingleton<IEventDispatcher, InMemoryEventDispatcher>();
var app = builder.Build();
app.MapPost("/pedidos", async (
CriarPedidoCommand command,
CriarPedidoCommandHandler handler,
CancellationToken ct) =>
{
var pedidoId = await handler.HandleAsync(command, ct);
return Results.Created($"/pedidos/{pedidoId}", new { PedidoId = pedidoId });
});
app.Run();
7. Fluxo completo
Quando você chama:
POST /pedidos
Com body:
{
"clienteId": "11111111-1111-1111-1111-111111111111",
"valorTotal": 200
}
Acontece isso:
PedidosrecebeCriarPedidoCommandPedidoschamaIClienteConsultaServiceClientesresponde com umClienteResumoDtoPedidosvalida e salva o pedidoPedidospublicaPedidoCriadoEventFinanceiroconsome o eventoFinanceirocria a cobrança
8. Por que isso está correto
Correto porque:
Pedidosnão acessaClienteRepositoryPedidosnão usaClientediretamentePedidosdepende de um contrato públicoFinanceironão é acoplado ao fluxo principal- cada módulo mantém seus detalhes internos encapsulados
9. O que seria o jeito errado
Errado
public sealed class CriarPedidoCommandHandler(
ClienteRepository clienteRepository)
{
public async Task HandleAsync(...)
{
var cliente = await clienteRepository.ObterPorIdAsync(...);
}
}
Isso está errado porque Pedidos passou a conhecer infraestrutura interna de Clientes.
Pior ainda
public sealed class CriarPedidoCommandHandler(
ClienteDbContext dbContext)
{
public async Task HandleAsync(...)
{
var cliente = await dbContext.Clientes.FirstOrDefaultAsync(...);
}
}
Aqui você destruiu completamente a fronteira do módulo.
10. Regra prática para monólito modular em .NET
Use esta regra:
Entre contextos
Application -> Application- via
interface,DTO,command,query,event
Dentro do contexto
Application -> DomainInfrastructureimplementa portas/interfaces
Nunca entre contextos
Infrastructure -> InfrastructureDbContext do outro móduloRepository do outro móduloEntity do outro módulo como contrato
11. Versão mental simplificada
Pensa assim:
- consulta/decisão imediata: interface pública
- reação a algo que aconteceu: evento interno
Esse é o equilíbrio mais comum e mais saudável.
12. O que você pode evoluir depois
Depois disso, você pode sofisticar com:
- MediatR para commands/events
- Outbox para eventos internos confiáveis
- módulos em assemblies separados
- testes de contrato entre módulos
- políticas para impedir referência indevida entre projetos
13. Resumo objetivo
No .NET, a forma correta em monólito modular é:
- expor contratos públicos por módulo
- usar DTOs entre fronteiras
- publicar eventos internos para reações desacopladas
- manter entidades, repositórios e DbContext privados ao módulo dono