Monólito Modular: Desenhando as Fronteiras dos Módulos
Objective
Um monólito modular é uma única aplicação .NET implantável, dividida
internamente em módulos, cada um dono dos próprios dados e expondo uma
superfície pública pequena para os outros. Ele mantém a simplicidade
operacional de um processo só (um deploy, um fluxo de logs, chamadas em
memória) e ganha boa parte do desacoplamento que normalmente leva as pessoas a
microsserviços. No .NET a fronteira é desenhada com ferramentas que o
compilador já garante: projetos separados (assemblies), o modificador de acesso
internal, um projeto fino de contratos por módulo e um método de registro que
pertence a cada módulo. O objetivo é que um módulo possa ser lido, testado e,
um dia, extraído sem desembaraçar o resto do código.
Use Cases
- Começar um produto cujo domínio ainda está sendo descoberto, em que dividir em serviços agora congelaria as fronteiras erradas em chamadas de rede.
- Desembaraçar uma aplicação ASP.NET Core em que qualquer controller alcança
qualquer
DbSet, de modo que uma mudança em cobrança quebra o catálogo sem ninguém perceber. - Deixar vários times trabalharem no mesmo repositório com o compilador (e não uma página de wiki) avisando quando alguém entra no módulo de outro time.
- Preparar um módulo para ser extraído como serviço no futuro, tornando as dependências dele explícitas antes que exista uma rede no meio.
Deep Dive
Layout da solução: um módulo é um pequeno grupo de projetos
Um layout comum tem um projeto host que só compõe os módulos, e dois projetos
por módulo: um projeto público de contratos e um projeto de implementação que
deixa quase tudo internal.
textsrc/ Host/ -> ponto de entrada ASP.NET Core, liga os módulos Modules/ Orders/ Orders.Contracts/ -> público: DTOs, eventos de integração, IOrdersModule Orders.Core/ -> domínio, casos de uso, OrdersDbContext (internal) Catalog/ Catalog.Contracts/ Catalog.Core/ Shared/ Shared.Kernel/ -> mínimo: tipo Result, abstração de relógio, tipo base de evento
As regras de dependência são o que tornam isso um monólito modular, e não só uma estrutura de pastas:
Hostreferencia todos os projetos*.Core, apenas para chamar o método de registro de cada um.- O
Corede um módulo pode referenciar osContractsde outros módulos, nunca oCorede outro módulo. - Projetos
Contractsnão referenciam nada além de, no máximo,Shared.Kernel.
Referências entre projetos têm direção, então o compilador garante a segunda
regra por você. Se Orders.Core.csproj não referencia Catalog.Core, nada em
Orders consegue citar um repositório ou uma entidade de Catalog:
plaintext<!-- Modules/Orders/Orders.Core/Orders.Core.csproj --> <ItemGroup> <ProjectReference Include="..\Orders.Contracts\Orders.Contracts.csproj" /> <ProjectReference Include="..\..\Catalog\Catalog.Contracts\Catalog.Contracts.csproj" /> </ItemGroup>
Dividir um módulo em Domain, Application e Infrastructure é outra
decisão. Camadas dentro de um módulo são opcionais; a fronteira entre
módulos é a que realmente se paga.
internal por padrão, public de propósito
Dentro de Orders.Core, tudo é internal exceto o punhado de tipos que o host
precisa chamar. Escrever public por hábito é o caminho de menor resistência,
então isso é uma disciplina a construir (e, mais tarde, a testar):
csharpnamespace Orders.Core.Domain;
internal sealed class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
// ...
}A face pública do módulo mora em Orders.Contracts, e ela fala em DTOs, nunca
em entidades:
csharpnamespace Orders.Contracts;
public interface IOrdersModule
{
Task<OrderSummaryDto?> GetSummaryAsync(Guid orderId, CancellationToken ct);
}
public sealed record OrderSummaryDto(Guid Id, string Status, decimal Total);A implementação de IOrdersModule fica em Orders.Core e é internal. Um
efeito colateral bom do grafo de projetos: um contrato não consegue vazar uma
entidade nem por acidente, porque Orders.Contracts não referencia
Orders.Core, então Order simplesmente não existe do ponto de vista dele.
Os testes ainda precisam dos internals. O SDK tem um item de MSBuild para
isso, sem precisar de AssemblyInfo.cs:
plaintext<!-- Orders.Core.csproj --> <ItemGroup> <InternalsVisibleTo Include="Orders.Core.Tests" /> </ItemGroup>
Cada módulo se registra sozinho
O host não deveria saber quais handlers, DbContexts ou options um módulo
precisa. Cada módulo expõe um método de extensão para serviços e outro para
endpoints (o projeto Core precisa de
<FrameworkReference Include="Microsoft.AspNetCore.App" /> para usar os tipos
de roteamento):
csharpnamespace Orders.Core;
public static class OrdersModule
{
public static IServiceCollection AddOrdersModule(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<OrdersDbContext>(options =>
options.UseNpgsql(
configuration.GetConnectionString("Main"),
npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "orders")));
services.AddScoped<IOrdersModule, OrdersModuleApi>();
services.AddScoped<PlaceOrderHandler>();
return services;
}
public static IEndpointRouteBuilder MapOrdersEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/orders").WithTags("Orders");
group.MapPost("/", PlaceOrderEndpoint.HandleAsync);
group.MapGet("/{id:guid}", GetOrderEndpoint.HandleAsync);
return app;
}
}OrdersModuleApi, PlaceOrderHandler e as classes de endpoint são todas
internal; registrá-las de dentro do próprio assembly funciona normalmente. O
host continua sendo uma lista curta de módulos:
csharpvar builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOrdersModule(builder.Configuration)
.AddCatalogModule(builder.Configuration);
var app = builder.Build();
app.MapOrdersEndpoints();
app.MapCatalogEndpoints();
app.Run();Posse dos dados: um schema e um DbContext por módulo
Um módulo é dono das próprias tabelas. A forma mais simples de deixar isso
visível é um schema de banco por módulo e um DbContext que só conhece esse
schema:
csharpinternal sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options)
: DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
}
}Junto com a chamada MigrationsHistoryTable(..., "orders") no registro, cada
módulo também ganha o próprio histórico de migrations, então Orders e Catalog
evoluem seus schemas de forma independente.
A regra que vem daí é rígida: nada de join entre módulos e nada de ler tabelas
de outro módulo. Este é o atalho que um AppDbContext único e compartilhado
torna possível:
csharp// Dentro de Orders, com um DbContext compartilhado que mapeia todas as tabelas
var lines = await db.OrderLines
.Where(l => l.OrderId == orderId)
.Join(db.Products, l => l.ProductId, p => p.Id,
(l, p) => new { l.Quantity, p.Name })
.ToListAsync(ct);E a versão que respeita a fronteira, perguntando ao Catalog pelo contrato dele:
csharp// OrdersDbContext só mapeia tabelas de Orders; Catalog responde via ICatalogModule
var lines = await db.OrderLines
.Where(l => l.OrderId == orderId)
.ToListAsync(ct);
var names = await catalog.GetProductNamesAsync(
lines.Select(l => l.ProductId).Distinct().ToArray(), ct);Duas consultas em vez de uma é o preço. Quando esse preço é alto demais num caminho quente, a resposta usual é Orders manter uma pequena cópia local dos dados de produto que precisa, atualizada pelos eventos de Catalog (assunto do próximo conceito), em vez de voltar a ler o schema de Catalog.
Onde as fronteiras vazam na prática
- O shared kernel vira um segundo monólito.
Shared.Kernelcomeça com um tipoResulte termina guardando entidades "que dois módulos precisam". Mantenha ali só coisas sem significado de negócio. - Um
AppDbContextpara tudo. É o jeito mais rápido de desfazer a divisão em módulos, porque qualquer módulo consulta qualquer tabela. - Contratos que espelham entidades. Um DTO com as mesmas vinte propriedades da entidade acopla o consumidor ao modelo interno do produtor tanto quanto referenciar a entidade acoplaria.
- Helpers estáticos e singletons em
Sharedque guardam estado de um módulo e são lidos por outro: uma dependência invisível que o grafo de projetos não enxerga.
Trade-offs
- O número de projetos cresce rápido. Dois ou três projetos por módulo
vezes uma dúzia de módulos é uma solução grande, com builds mais lentos e
mais cerimônia. Uma variante mais leve é um projeto por módulo em que os
únicos tipos
publicsão os contratos; a fronteira se mantém, mas todo consumidor passa a referenciar o assembly inteiro do módulo e as dependências de pacote dele, de forma transitiva. internalé uma cerca de compilação, não uma fronteira de segurança. Reflection,InternalsVisibleToe um friend assembly "temporário" passam direto por ela, e o build continua verde.csharp// Orders.Core/AssemblyInfo.cs, adicionado "só para destravar o relatório" [assembly: InternalsVisibleTo("Reporting.Core")] // Reporting agora consegue construir OrdersDbContext direto. A fronteira acabou.- Um único banco físico convida a transações entre módulos. Como todos os schemas estão no mesmo banco, é tecnicamente possível compartilhar uma conexão e uma transação entre os DbContexts de dois módulos. Funciona hoje e vira uma transação distribuída no dia em que um módulo for extraído, então vale tratar isso como proibido desde o começo.
- Fronteiras se desgastam em silêncio. Nada no runtime reclama quando uma nova referência de projeto cruza a linha de um módulo. Sem testes de arquitetura no CI (próximo conceito), o layout acima se degrada um pull request razoável de cada vez.
- Chamadas em memória escondem custos de sistema distribuído. Uma chamada
via
IOrdersModulenão tem latência, falha parcial nem problema de versionamento. A extração traz os três de uma vez, então um módulo cujos chamadores dependem de chamadas síncronas e frequentes é mais difícil de extrair do que o grafo de projetos limpo sugere.