Skip to main content

Exemplo de Monolito Modular em .NET | Monolitos | Insights

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

alt text

Abaixo está um exemplo enxuto, mas estruturalmente correto, de monólito modular em .NET.

Vou usar 3 contextos:

  • Clientes
  • Pedidos
  • Financeiro

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 é:

  • Pedidos não conhece entidade nem repositório interno de Clientes
  • Pedidos depende apenas de um contrato público
  • após salvar o pedido, Pedidos publica um evento interno
  • Financeiro reage 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:

  1. Pedidos recebe CriarPedidoCommand
  2. Pedidos chama IClienteConsultaService
  3. Clientes responde com um ClienteResumoDto
  4. Pedidos valida e salva o pedido
  5. Pedidos publica PedidoCriadoEvent
  6. Financeiro consome o evento
  7. Financeiro cria a cobrança

8. Por que isso está correto

Correto porque:

  • Pedidos não acessa ClienteRepository
  • Pedidos não usa Cliente diretamente
  • Pedidos depende de um contrato público
  • Financeiro nã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 -> Domain
  • Infrastructure implementa portas/interfaces

Nunca entre contextos

  • Infrastructure -> Infrastructure
  • DbContext do outro módulo
  • Repository do outro módulo
  • Entity 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