Как ломается модульный монолит и как с этим бороться

В прошлой статье я сделал проверку зависимостей между модулями при запуске. Только срабатывает она уже на проде, а ловить такие ошибки хочется раньше.
В конце той статьи я привёл список поломок, которые проходят сборку, а иногда и локальную отладку. Здесь покажу, как превратить их в ошибки компиляции и красные тесты.
Это четвёртая статья по мотивам моего доклада «Модульность без микросервисов в ASP.NET из коробки» на DotNext 2026:
Как ломается модульный монолит и как с этим бороться (вы здесь).
eShop: запилить микросервисы в модульный монолит.
TL;DR
Тест поднимает топологию той же переменной
HOSTINGSTARTUPASSEMBLIES, что и деплой, и проверяет её таблицы, маршруты и сервисы.Топологии в одном тестовом процессе делят модель EF Core, пока в ключе кэша модели нет набора модулей.
Анализаторы Roslyn запрещают модулю публичные типы, хосту и библиотекам использовать модули, а классу модуля обходиться без
ModuleBaseи атрибута.Список модулей для design-time фабрики пишет генератор, и тот же список проверяет переменную на старте.
Генератор в модулях и анализатор в хосте вместе следят, чтобы у таблицы и сущности был один модуль-владелец.
Для примеров тестов я взял TUnit. У него есть свой API для веб-тестов, но я им не пользуюсь: хочу, чтобы код переносился между тест-фреймворками без переделок.
Тесты топологий
Чтобы проверить, что топология не упадёт на проде и получит доступ ко всем нужным данным, можно запустить модульный хост прямо в тесте.
public class TopologyFactory(params string[] modules) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder) => builder
.UseSetting(WebHostDefaults.HostingStartupAssembliesKey, string.Join(';', modules))
// База не нужна: модель EF Core строится без подключения, а миграции тесту ни к чему
.UseSetting("ConnectionStrings:Postgres", "Host=localhost;Database=topology")
.UseSetting("Database:Migrate", "false");
// Таблицы модели: "схема.таблица"
public string[] Tables() =>
[.. Model().GetEntityTypes().Select(e => $"{e.GetSchema()}.{e.GetTableName()}")];
// Внешние ключи: "откуда → куда"
public string[] ForeignKeys() =>
[
.. from entity in Model().GetEntityTypes()
from key in entity.GetForeignKeys()
select $"{entity.GetTableName()} → {key.PrincipalEntityType.GetTableName()}"
];
// Маршруты: "[http_method] [route_pattern]", по строке на каждый метод
public string[] Routes() =>
[
.. from endpoint in Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>()
from method in endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods ?? ["ANY"]
select $"{method} {endpoint.RoutePattern.RawText}"
];
// Контекст берём как DbContext, так же, как его берут модули
IModel Model()
{
using var scope = Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<DbContext>().Model;
}
}
На каждую топологию по одному тесту:
public class TopologyTests
{
static readonly string[] Monolith = ["Orders.Entities", "Customers.Entities", "Monolith"];
static readonly string[] Orders = ["Orders.Entities", "OrdersMicroservice"];
static readonly string[] Customers = ["Customers.Entities", "CustomersMicroservice"];
[Test]
public async Task Monolith_HasBothTables_AndTheForeignKey()
{
await using var factory = new TopologyFactory(Monolith);
await Assert.That(factory.Tables()).IsEquivalentTo(new[] { "Marketing.Customers", "Sales.Orders" });
await Assert.That(factory.ForeignKeys()).IsEquivalentTo(new[] { "Orders → Customers" });
}
[Test]
public async Task Orders_HasOnlyItsTable_AndItsRoutes()
{
await using var factory = new TopologyFactory(Orders);
await Assert.That(factory.Tables()).IsEquivalentTo(new[] { "Sales.Orders" });
await Assert.That(factory.ForeignKeys()).IsEmpty();
await Assert.That(factory.Routes()).IsEquivalentTo(new[] { "GET /orders/unpaid" });
}
}
Тест сравнивает строки, поэтому ему не нужен ни один тип из модулей, а IsEquivalentTo не смотрит на порядок.
Если при старте вызывается MigrateAsync, его надо уметь выключать. Я завёл для этого параметр конфигурации Database:Migrate, фабрика выше ставит его в false:
var app = builder.Build();
if (app.Configuration.GetValue("Database:Migrate", true))
{
await using var scope = app.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.MigrateAsync();
}
Сравнение целиком заодно работает как негативный тест. Маршрут POST /seed и таблица клиентов в образе есть, но в топологии заказов их быть не должно, и Orders_HasOnlyItsTable_AndItsRoutes их не пропустит.
С сервисами то же самое. Вот тест для калькулятора из второй статьи, у которого четыре транспорта в одном образе. Хост у калькулятора другой, из нагрузочного теста, поэтому и фабрика для этого теста своя, над его Program, а устроена она так же. RemoteRmq в тесте нет: его hosted-сервис на EasyNetQ при старте фабрики полезет к RabbitMQ.
[Test]
[Arguments("Local")]
[Arguments("RemoteRest")]
[Arguments("RemoteGrpc")]
public async Task Calculator_ComesFromThisTopology(string module)
{
await using var factory = new TopologyFactory(module);
// Зависимости контроллеров контейнер на старте не проверяет:
// без транспорта топология стартует и падает на первом запросе
var isService = factory.Services.GetRequiredService<IServiceProviderIsService>();
await Assert.That(isService.IsService(typeof(ICalculator))).IsTrue();
// Два модуля с одним интерфейсом не падают, побеждает последний. Проверяем, чей
await using var scope = factory.Services.CreateAsyncScope();
var calculator = scope.ServiceProvider.GetRequiredService<ICalculator>();
await Assert.That(calculator.GetType().Assembly.GetName().Name).IsEqualTo(module);
}
Ещё я бы сравнивал с ожидаемым список IHostedService: AddHostedService в модуле запускает воркер в каждой реплике каждой топологии, где этот модуль загружен.
Несколько топологий в одном процессе
Можно написать тест моделей данных:
[Test]
public async Task ThreeTopologiesInOneProcess_EachHasItsOwnModel()
{
await using var monolith = new TopologyFactory(Monolith);
await using var orders = new TopologyFactory(Orders);
await using var customers = new TopologyFactory(Customers);
await Assert.That(monolith.Tables()).IsEquivalentTo(new[] { "Marketing.Customers", "Sales.Orders" });
await Assert.That(orders.Tables()).IsEquivalentTo(new[] { "Sales.Orders" });
await Assert.That(customers.Tables()).IsEquivalentTo(new[] { "Marketing.Customers" });
}
Без доработок этот тест будет красным.
EF Core кэширует модель по типу контекста, и второй хост получает модель первого. Ничего не падает, просто в модели не те таблицы. Лечится своим ключом кэша модели:
class AppDbContext(DbContextOptions options, IConfiguration configuration) : DbContext(options)
{
// Модули, из которых собрана модель
IReadOnlyList<Assembly> Modules { get; } = ModuleBase.GetLoadedModules(configuration);
// Состав модулей для ключа кэша модели
public string ModuleFingerprint => string.Join(';', Modules.Select(a => a.GetName().Name));
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var assembly in Modules)
modelBuilder.ApplyConfigurationsFromAssembly(assembly);
}
}
class ModuleAwareModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime) => context is AppDbContext app
? (typeof(AppDbContext), app.ModuleFingerprint, designTime)
: (object)(context.GetType(), designTime);
}
builder.Services.AddDbContext<AppDbContext>(b => b
.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"))
.ConfigureWarnings(o => o.Log(RelationalEventId.PendingModelChangesWarning))
.ReplaceService<IModelCacheKeyFactory, ModuleAwareModelCacheKeyFactory>());
Список модулей я читаю один раз в свойство Modules. Если звать GetLoadedModules и в инициализаторе, и в OnModelCreating, параметр первичного конструктора захватывается дважды, и компилятор ругается предупреждением CS9124.
Набор модулей GetLoadedModules берёт из реестра в конфигурации хоста. Брать загруженные сборки из AppDomain нельзя: в тестовом процессе загружены сборки всех топологий, которые уже поднимались.
Без ReplaceService падает ещё и Orders_HasOnlyItsTable_AndItsRoutes. Кэш модели общий на весь процесс, и модель того теста, который запустился первым, уже лежит в нём. Если тест проходит в одиночку и падает в общем прогоне, первым делом проверьте ключ кэша.
Контроль модульности при компиляции
Тесты проверяют поведение. Публичный тип в модуле они не заметят, пока на него кто-нибудь не сошлётся. Обычно такое закрывают «архитектурными тестами», но правильнее для этого сделать анализатор.
Вот что хочется получить:
[assembly: HostingStartup(typeof(Module))]
class Module : ModuleBase { }
public class Leak { } // ошибка компиляции
public record struct AddRequest(int A, int B); // ошибка. Моя первая версия смотрела только на классы и это пропустила
public class Outer { public class Nested { } } // ошибка только на Outer
public class Order { public int Id { get; set; } } // можно: это сущность
class OrderConfiguration : IEntityTypeConfiguration<Order> { public void Configure(EntityTypeBuilder<Order> b) { } }
Сущность узнаётся по IEntityTypeConfiguration<T> в той же сборке. Остальные типы, которые нужны больше чем одному модулю, переезжают в contracts-сборку без [HostingStartup].
Анализатор - библиотека под netstandard2.0:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" PrivateAssets="all" />
</ItemGroup>
</Project>
Версия Microsoft.CodeAnalysis.CSharp задаёт минимальный компилятор: анализатор, собранный под 5.3.0, не загрузится в SDK или Visual Studio со старым Roslyn (будет CS8032). Пока анализатор живёт проектом в своём решении, это неважно. Для пакета я бы ставил минимально нужную версию.
Подключается анализатор ко всем проектам сразу, в Directory.Build.props:
<ItemGroup>
<ProjectReference Include="$(MSBuildThisFileDirectory)../modular-analyzer/modular-analyzer.csproj"
ReferenceOutputAssembly="false"
OutputItemType="Analyzer" />
<!-- анализатору нужно отличать хост от тестового проекта: точка входа есть у обоих -->
<CompilerVisibleProperty Include="IsTestProject" />
</ItemGroup>
Отдельно помечать модули не нужно. Анализатор сам определяет: модуль - сборка с [assembly: HostingStartup], хост - сборка с точкой входа.
Код анализатора:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ModuleAnalyzer : DiagnosticAnalyzer
{
static readonly DiagnosticDescriptor PublicType = new(
"MOD0001", "Модуль не отдаёт публичных типов",
"Тип '{0}' публичный, а сборка является модулем. Сделайте его internal или перенесите в contracts-сборку.",
"Design", DiagnosticSeverity.Error, isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [PublicType];
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(start =>
{
var compilation = start.Compilation;
// Модуль - сборка с [assembly: HostingStartup] и без точки входа
if (!HasHostingStartup(compilation.Assembly) ||
compilation.GetEntryPoint(start.CancellationToken) is not null)
return;
// Сущности - типы T, для которых в этой же сборке есть IEntityTypeConfiguration<T>
var configuration = compilation.GetTypeByMetadataName(
"Microsoft.EntityFrameworkCore.IEntityTypeConfiguration`1");
var entities = compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type, start.CancellationToken)
.OfType<INamedTypeSymbol>()
.SelectMany(type => type.AllInterfaces)
.Where(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, configuration))
.Select(i => i.TypeArguments[0])
.ToImmutableHashSet<ITypeSymbol>(SymbolEqualityComparer.Default);
start.RegisterSymbolAction(ctx =>
{
var type = (INamedTypeSymbol)ctx.Symbol;
// Вложенный тип виден снаружи только вместе с внешним, поэтому хватает типов верхнего уровня
if (type is { DeclaredAccessibility: Accessibility.Public, ContainingType: null } &&
!entities.Contains(type))
ctx.ReportDiagnostic(Diagnostic.Create(PublicType, type.Locations[0], type.Name));
}, SymbolKind.NamedType);
});
}
internal static bool HasHostingStartup(IAssemblySymbol assembly) =>
assembly.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == "Microsoft.AspNetCore.Hosting.HostingStartupAttribute");
}
Контроллеры тоже internal
Первая версия правила, та, что в репозитории доклада, разрешала публичные контроллеры: ASP.NET MVC по умолчанию находит только их. Но публичный контроллер тянет за собой наружу всё, что принимает:
public class HomeController(IOrderQueries orders) : Controller // CS0051: IOrderQueries internal
{
public async Task<IActionResult> Index() => View(await orders.Unpaid());
}
Компилятор заставит сделать публичным IOrderQueries, за ним его DTO, и в итоге правило формально соблюдено, а публичной стала половина модуля. Проще научить MVC находить internal-контроллеры. Для этого нужен свой провайдер:
sealed class InternalControllerProvider : IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
var types = parts.OfType<IApplicationPartTypeProvider>().SelectMany(part => part.Types);
foreach (var type in types.Where(IsInternalController))
if (!feature.Controllers.Contains(type))
feature.Controllers.Add(type);
}
// Условия стандартного провайдера, кроме публичности. IsNotPublic бывает только
// у типов верхнего уровня, так что вложенный класс контроллером по-прежнему не станет
static bool IsInternalController(TypeInfo type) =>
type is { IsNotPublic: true, IsClass: true, IsAbstract: false, ContainsGenericParameters: false } &&
!type.IsDefined(typeof(NonControllerAttribute)) &&
(type.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) ||
type.IsDefined(typeof(ControllerAttribute)));
}
public static class InternalControllers
{
public static IMvcBuilder AllowInternalControllers(this IMvcBuilder mvc) =>
mvc.ConfigureApplicationPartManager(manager =>
{
// Контроллеры могут быть у нескольких модулей, а провайдер нужен один
if (!manager.FeatureProviders.OfType<InternalControllerProvider>().Any())
manager.FeatureProviders.Add(new InternalControllerProvider());
});
}
В модуле добавляется одна строка, и контроллер становится internal:
protected override void ConfigureServices(WebHostBuilderContext ctx, IServiceCollection services)
{
services.AddControllersWithViews()
.AddApplicationPart(typeof(Module).Assembly)
.AllowInternalControllers();
}
[Area(Module.AreaName)]
class HomeController(IOrderQueries orders) : Controller // internal, как и IOrderQueries
{
public async Task<IActionResult> Index() => View(await orders.Unpaid());
}
Без AllowInternalControllers() такой контроллер компилируется и отдаёт 404.
Есть и обратная сторона: любой вспомогательный класс с именем на Controller теперь тоже станет контроллером, и его надо пометить [NonController].
Код здесь упрощён, чтобы уместиться в статью. Кроме контроллеров, публичными в модуле должны оставаться тег-хелперы (internal-хелпер Razor молча выведет обычным HTML) и ViewComponent. Для Web API показанного хватает.
Связи между сборками
Хост ссылается на модули, чтобы они попали в образ, но использовать их не должен, иначе модуль станет обязательным в любом деплое:
// Program.cs хоста
using Customers; // можно: импорт ещё не использование
var app = WebApplication.CreateBuilder(args).Build();
var type = typeof(Customer); // ошибка: хост использует модуль Customers.Entities
app.Run();
/// <summary>См. <see cref="Customer"/></summary> // можно: комментарий модуль в деплой не тянет
static class Docs { }
Обычной библиотеке тоже нельзя ссылаться на модуль: она затащит сборку модуля туда, где его IHostingStartup не выполнялся.
Компилятор знает, какие ссылки он использовал: GetUsedAssemblyReferences.
Моя первая версия верила ему на слово и ругалась на using, на cref с /doc и на каждый модуль в сломанной компиляции. Теперь его ответ для меня только подозрение, а подтверждает его поиск конкретной строки:
context.RegisterCompilationAction(ctx =>
{
var compilation = ctx.Compilation;
if (IsTestProject(ctx.Options)) return; // тестам ссылаться можно
var isHost = compilation.GetEntryPoint(ctx.CancellationToken) is not null;
if (!isHost && ModuleAnalyzer.HasHostingStartup(compilation.Assembly))
return; // модулю ссылаться на модули можно: это сверяет ValidateReferences на старте
// Первый вопрос, дешёвый: какие сборки-модули компилятор считает использованными
var suspects = compilation.GetUsedAssemblyReferences(ctx.CancellationToken)
.Select(compilation.GetAssemblyOrModuleSymbol)
.OfType<IAssemblySymbol>()
.Where(a => !a.Name.StartsWith("Microsoft.AspNetCore.")) // IIS Integration тоже hosting startup
.Where(ModuleAnalyzer.HasHostingStartup);
// Второй, точный: где именно
foreach (var module in suspects)
if (FindUse(compilation, module, ctx.CancellationToken) is { } location)
ctx.ReportDiagnostic(Diagnostic.Create(
isHost ? HostUsesModule : LibraryUsesModule, // MOD0004 : MOD0005
location, module.Name));
});
// Первый узел кода, который обращается к символу из сборки. DescendantNodes не заходит в trivia,
// поэтому <see cref> из doc-комментария не в счёт. using пропускаем сами: импорт ещё не использование
static Location? FindUse(Compilation compilation, IAssemblySymbol assembly, CancellationToken ct)
{
bool FromAssembly(ISymbol? symbol) =>
SymbolEqualityComparer.Default.Equals(symbol?.ContainingAssembly, assembly);
foreach (var tree in compilation.SyntaxTrees)
{
#pragma warning disable RS1030 // только в конце компиляции и только для подозреваемых сборок
var model = compilation.GetSemanticModel(tree);
#pragma warning restore RS1030
foreach (var node in tree.GetRoot(ct).DescendantNodes(n => n is not UsingDirectiveSyntax))
if (FromAssembly(model.GetSymbolInfo(node, ct).Symbol) ||
FromAssembly(model.GetTypeInfo(node, ct).Type))
return node.GetLocation();
}
return null;
}
static bool IsTestProject(AnalyzerOptions options) =>
options.AnalyzerConfigOptionsProvider.GlobalOptions
.TryGetValue("build_property.IsTestProject", out var value) &&
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
GetSemanticModel в анализаторе дорогой, о чём и предупреждает RS1030. Здесь он вызывается только для подозреваемых модулей, то есть в чистом проекте почти никогда. Если хост использует библиотеку, а та модуль, ошибку получит библиотека: исправлять надо её строку.
ModuleBase и атрибут
[assembly: HostingStartup(typeof(PlainStartup))]
class PlainStartup : IHostingStartup { ... } // ошибка: не попадёт в реестр, сущностей не будет в модели
class Forgotten : ModuleBase { } // ошибка: нет атрибута, сборку загрузят, а модуль пропустят
abstract class CompanyModule : ModuleBase { } // можно: общая база сама не модуль
Хуже всего Forgotten: приложение стартует, health-check зелёный, а модуль отвечает 404. Оба случая ловит одна проверка классов, которые реализуют IHostingStartup:
context.RegisterCompilationStartAction(start =>
{
var compilation = start.Compilation;
var hostingStartup = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Hosting.IHostingStartup");
var moduleBase = compilation.GetTypeByMetadataName("ModuleBase");
if (hostingStartup is null) return;
// Классы, названные в [assembly: HostingStartup(typeof(...))]
var named = compilation.Assembly.GetAttributes()
.Where(a => a.AttributeClass?.ToDisplayString() == "Microsoft.AspNetCore.Hosting.HostingStartupAttribute")
.SelectMany(a => a.ConstructorArguments) // пусто, если атрибут написан с ошибкой
.Select(argument => argument.Value)
.OfType<INamedTypeSymbol>()
.ToImmutableHashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
start.RegisterSymbolAction(ctx =>
{
var type = (INamedTypeSymbol)ctx.Symbol;
if (type.IsAbstract || !type.AllInterfaces.Contains(hostingStartup, SymbolEqualityComparer.Default))
return;
if (!named.Contains(type))
ctx.ReportDiagnostic(Diagnostic.Create(NotNamed, type.Locations[0], type.Name)); // MOD0003
else if (moduleBase is null || !DerivesFrom(type, moduleBase))
ctx.ReportDiagnostic(Diagnostic.Create(NotModuleBase, type.Locations[0], type.Name)); // MOD0002
}, SymbolKind.NamedType);
});
static bool DerivesFrom(INamedTypeSymbol type, INamedTypeSymbol baseType) =>
type.BaseType is { } parent &&
(SymbolEqualityComparer.Default.Equals(parent, baseType) || DerivesFrom(parent, baseType));
Что ещё проверять при сборке
Этот список у меня растёт из реальных отказов:
AddSingleton<IStartupFilter>(this)в модуле:ModuleBaseэто уже сделал, и повторный маршрут уронит старт.Забытый параметр
GenerateMvcApplicationPartsAssemblyAttributesв хосте притащит контроллеры всех модулей во все топологии. Анализатором надо ловитьApplicationPartAttributeв сборке хоста.CreateSlimBuilderиCreateEmptyBuilderигнорируютHOSTINGSTARTUPASSEMBLIES, и модули молча не загрузятся.Hub<TClient>сinternal-интерфейсом клиента падает сTypeLoadExceptionна первом клиенте. НуженInternalsVisibleToдля динамической сборки SignalR.Assembly.GetEntryAssembly()в модуле вернёт сборку хоста. Это ломает код, который добавляет типы в DI, когда такой код переезжает в отдельный модуль.
Анализаторы для паттернов, которые недопустимы именно в вашем коде, вы можете написать сами. Главное, не подавляйте эти правила через NoWarn. Для случаев «так и задумано» есть #pragma с комментарием у конкретной строки.
Улучшаем модульность с помощью Source Generator
Design-time фабрика для EF из прошлой статьи перечисляет модули руками:
static readonly string[] AllModules = ["Customers.Entities", "Orders.Entities", "Monolith"];
Добавили новый модуль с моделью для EF и забыли вписать его сюда - миграция для него не создастся. А если несколько веток правят этот список параллельно, будут ещё и конфликты.
Этот список можно генерировать для хоста с помощью Source Generator:
[Generator(LanguageNames.CSharp)]
public sealed class KnownModulesGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Список нужен только запускаемым сборкам: хосту и тестам. Результат - строка,
// а строки сравниваются по значению: правка, не меняющая набор модулей, файл не перегенерирует
var names = context.CompilationProvider.Select(static (compilation, _) =>
compilation.Options.OutputKind == OutputKind.DynamicallyLinkedLibrary
? null
: string.Join(", ", ModuleNames(compilation).Select(name => $"\"{name}\"")));
context.RegisterSourceOutput(names, static (spc, names) =>
{
if (names is null) return;
spc.AddSource("KnownModules.g.cs", $$"""
// <auto-generated/>
internal static class KnownModules
{
public static readonly string[] Names = [{{names}}];
}
""");
});
}
// Сборки среди ссылок проекта, помеченные [assembly: HostingStartup]
static IEnumerable<string> ModuleNames(Compilation compilation) => compilation.References
.Select(compilation.GetAssemblyOrModuleSymbol)
.OfType<IAssemblySymbol>()
.Where(a => !a.Name.StartsWith("Microsoft.AspNetCore.")) // IIS Integration тоже hosting startup
.Where(ModuleAnalyzer.HasHostingStartup)
.Select(a => a.Name)
.OrderBy(name => name, StringComparer.Ordinal);
}
Фильтр Microsoft.AspNetCore. я дописал после первого запуска, когда в списке оказался IIS Integration. Фабрика больше не знает имён модулей:
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.AddInMemoryCollection([new(WebHostDefaults.HostingStartupAssembliesKey, string.Join(';', KnownModules.Names))])
.AddInMemoryCollection(ModuleBase.CreateModuleRegistry(KnownModules.Names))
.Build();
Генератор работает и в тестовом проекте. Тестовый проект тоже exe, анализатор подключён к нему через Directory.Build.props, и там появляется свой KnownModules, собранный из транзитивных ссылок. Раз тесты ссылаются на хост, список совпадёт с хостовым. Если хост открыл тестам internals через InternalsVisibleTo, компилятор предупредит о двух одинаковых типах (CS0436) и возьмёт свой.
Порядок модулей
Генератор отдаёт имена по алфавиту, и Monolith оказывается раньше Orders.Entities.
// Monolith
class OrderEntityConfiguration : IEntityTypeConfiguration<Order> // Order в сигнатуре: ссылка на Orders.Entities есть в reference-сборке
{
public void Configure(EntityTypeBuilder<Order> entity)
{
entity.HasOne<Customer>() // Customer только в теле метода: ссылки на Customers.Entities там нет
.WithMany()
.HasForeignKey(e => e.CustomerId);
}
}
При таком порядке создание внешнего ключа в Monolith упадёт с ошибкой. Нужна топологическая сортировка модулей, но в генераторе её не сделать: компилятор видит соседние проекты как reference-сборки без тел методов и зависимостей.
В третьей статье порядок задавала переменная, и Monolith надо было ставить в ней последним. Теперь порядок строит GetLoadedModules во время работы, по настоящим сборкам, и порядок в переменной больше не важен:
public static IReadOnlyList<Assembly> GetLoadedModules(IConfiguration configuration)
{
var order = (configuration[WebHostDefaults.HostingStartupAssembliesKey] ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var modules = configuration.GetSection(ModulesSection).GetChildren()
.ToDictionary(m => m.Key, m => Assembly.Load(m.Key), StringComparer.OrdinalIgnoreCase);
int Depth(Assembly module) => module.GetReferencedAssemblies()
.Where(r => modules.ContainsKey(r.Name!))
.Select(r => Depth(modules[r.Name!]) + 1)
.DefaultIfEmpty(0)
.Max();
// Сначала то, на что ссылаются, при равной глубине по порядку переменной.
// Сборки приложения в переменной нет, её индекс −1, и она остаётся первой
return [.. modules.Values
.OrderBy(Depth)
.ThenBy(m => Array.FindIndex(order, n =>
string.Equals(n, m.GetName().Name, StringComparison.OrdinalIgnoreCase)))];
}
Depth - длина самой длинной цепочки ссылок от модуля до модулей без зависимостей. В design-time фабрике в реестре все модули проекта, поэтому и порядок там такой же, как в любой топологии с теми же модулями.
С переменной Monolith;Customers.Entities;Orders.Entities модель собирается в порядке Customers.Entities;Orders.Entities;Monolith, и внешний ключ на месте.
Опечатка в переменной HOSTINGSTARTUPASSEMBLIES
Если указать имя несуществующего модуля, ASP.NET Core пишет в лог с уровнем crit и стартует дальше. Список от генератора позволяет это поймать:
public static void ValidateModules(IConfiguration configuration, IEnumerable<string> modules)
{
var registry = configuration.GetSection(ModulesSection);
var requested = configuration[WebHostDefaults.HostingStartupAssembliesKey] ?? string.Empty;
// Модуль хоста обязан оказаться в реестре, чужому hosting startup достаточно загрузиться
var broken = requested.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(name => registry[name] is null &&
(modules.Contains(name, StringComparer.OrdinalIgnoreCase) || !CanLoad(name)))
.ToArray();
if (broken.Length > 0)
throw new InvalidOperationException(
$"HOSTINGSTARTUPASSEMBLIES = '{requested}': не активированы {string.Join(", ", broken)}");
}
static bool CanLoad(string name)
{
try { Assembly.Load(name); return true; }
catch (IOException) { return false; } // FileNotFoundException, FileLoadException
}
Хост вызывает проверку сразу после создания билдера:
var builder = WebApplication.CreateBuilder(args);
ModuleBase.ValidateModules(builder.Configuration, KnownModules.Names);
Чужой hosting startup вроде Application Insights в реестр не пишет, от него требуется только загрузиться. Тест:
[Test]
public async Task Typo_FailsStartup()
{
await using var factory = new TopologyFactory("Orders.Entitys", "OrdersMicroservice");
await Assert.That(() => factory.Services).Throws<InvalidOperationException>()
.WithMessageContaining("Orders.Entitys");
}
Текст сообщения я проверяю не просто так. InvalidOperationException фабрика бросает и сама, например когда хост вообще не построился, и без проверки сообщения тест прошёл бы не по той причине.
Бонус: проверка владения сущностью и таблицей
Генератор и анализатор хороши поодиночке, но интереснее всего они работают в паре. В комментариях под прошлой статьёй заметили, что модульная модель позволяет описать одну и ту же таблицу в разных модулях.
Например, новому модулю срочно нужны заказы. Ссылаться на Orders нельзя: это поменяет топологию, и упадут тесты.
Зато можно так:
// Модуль Billing
class Order { public int Id { get; set; } public decimal Amount { get; set; } } // своя копия заказа
class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> entity) => entity.ToTable("Orders", "Sales"); // таблица модуля Orders
}
Ссылок между сборками нет, компилятор доволен, всё работает, пока Orders не поменяет схему. Такая же протечка бывает через общую сборку Shared: два модуля настраивают один класс сущности, и EF Core молча берёт конфигурацию того, кто применился последним.
Правило простое: у таблицы и у сущности один модуль-владелец, тот, кто вызывает ToTable. Связывающий модуль Monolith из демо настраивает Order, но ToTable не вызывает, поэтому владельцем не считается.
Проверять правило надо в хосте, только он видит все модули сразу. Но ToTable вызывается в теле метода, а хост видит модули как reference-сборки без тел.
Поэтому работу надо разделить: генератор в каждом модуле записывает в атрибуты сборки, чем модуль владеет, а анализатор хоста эти атрибуты читает. Атрибуты сборки в reference-сборке остаются, и анализатор их видит.
Очень много кода
[Generator(LanguageNames.CSharp)]
public sealed class OwnedTablesGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Вызовы entity.ToTable("Orders", "Sales") с константными аргументами
var owned = context.SyntaxProvider.CreateSyntaxProvider(
static (node, _) => node is InvocationExpressionSyntax
{
Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: "ToTable" }
},
static (ctx, ct) => Owned(ctx, ct))
.Where(static line => line is not null)
.Collect()
.Select(static (lines, _) => string.Join("\n", lines.Distinct().OrderBy(l => l, StringComparer.Ordinal)));
var isModule = context.CompilationProvider
.Select(static (compilation, _) => ModuleAnalyzer.HasHostingStartup(compilation.Assembly));
context.RegisterSourceOutput(owned.Combine(isModule), static (spc, input) =>
{
var (lines, isModule) = input;
if (!isModule || lines.Length == 0) return;
spc.AddSource("OwnedTables.g.cs", $$"""
// <auto-generated/>
{{lines}}
[System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)]
internal sealed class OwnsTableAttribute(string table, string entity) : System.Attribute { }
""");
});
}
static string? Owned(GeneratorSyntaxContext ctx, CancellationToken ct)
{
var call = (InvocationExpressionSyntax)ctx.Node;
var builder = ((MemberAccessExpressionSyntax)call.Expression).Expression;
// entity имеет тип EntityTypeBuilder<Order>, отсюда тип сущности
if (ctx.SemanticModel.GetTypeInfo(builder, ct).Type is not INamedTypeSymbol
{
Name: "EntityTypeBuilder", TypeArguments.Length: 1
} builderType)
return null;
var entity = builderType.TypeArguments[0];
var args = call.ArgumentList.Arguments
.Select(a => ctx.SemanticModel.GetConstantValue(a.Expression, ct))
.ToArray();
if (args.Length == 0 || args[0].Value is not string table) return null; // имя не константа: не угадываем
var schema = args.Length > 1 && args[1].Value is string s ? s + "." : "";
return $"[assembly: OwnsTable(\"{schema}{table}\", \"{entity.ToDisplayString()}\")]";
}
}
В сборке Orders.Entities появляется файл:
// <auto-generated/>
[assembly: OwnsTable("Sales.Orders", "Orders.Order")]
[System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)]
internal sealed class OwnsTableAttribute(string table, string entity) : System.Attribute { }
Атрибут internal, поэтому публичных типов у модуля не прибавилось, и правило про них молчит. Анализатор хоста собирает атрибуты со всех модулей. Таблица и сущность для него одинаковые ключи владения:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OwnedTablesAnalyzer : DiagnosticAnalyzer
{
static readonly DiagnosticDescriptor TwoOwners = new(
"OWN0001", "У таблицы и сущности один владелец",
"'{0}' владеют модули {1}",
"Design", DiagnosticSeverity.Error, isEnabledByDefault: true,
customTags: WellKnownDiagnosticTags.CompilationEnd);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [TwoOwners];
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationAction(ctx =>
{
var compilation = ctx.Compilation;
if (compilation.GetEntryPoint(ctx.CancellationToken) is null) return; // сверяет хост
// Каждый модуль сам записал, чем владеет
var owners = compilation.References
.Select(compilation.GetAssemblyOrModuleSymbol)
.OfType<IAssemblySymbol>()
.Where(ModuleAnalyzer.HasHostingStartup)
.SelectMany(module => module.GetAttributes()
.Where(a => a.AttributeClass?.Name == "OwnsTableAttribute")
.SelectMany(a => a.ConstructorArguments) // и таблица, и сущность
.Select(argument => (Key: (string)argument.Value!, Module: module.Name)));
foreach (var key in owners.GroupBy(o => o.Key))
{
var modules = key.Select(o => o.Module).Distinct().OrderBy(m => m).ToArray();
if (modules.Length > 1)
ctx.ReportDiagnostic(Diagnostic.Create(TwoOwners, Location.None, key.Key, string.Join(", ", modules)));
}
});
}
}
Хост с модулем Billing из примера больше не собирается:
error OWN0001: 'Sales.Orders' владеют модули Billing, Orders.Entities
Если Billing вместо копии настроит чужой Orders.Order под свою таблицу, ошибка будет про 'Orders.Order'.
Генератор видит только ToTable с константами. Таблицу, названную по соглашению из DbSet, или имя из конфигурации он пропустит, и это честная дыра. Закрыть её можно проверкой при старте или в тесте: у таблицы нет владельца - ошибка.
Проверка идёт по всем модулям хоста сразу, поэтому она строже любой топологии. Два модуля-альтернативы, которые никогда не грузятся вместе, но владеют одной таблицей, она тоже не пропустит. Явно разрешить такой случай пока нечем. Диагностика выдаётся без места в коде (Location.None), #pragma к ней не приложить, и остаётся только NoWarn на весь хост, от которого я выше отговаривал.
Тот же приём годится для всего, что живёт в телах методов и нужно хосту: регистрации сервисов, маршруты, имена очередей.
Выводы
Всё, что показано в статье, собрано из стандартных деталей: ASP.NET, EF Core, WebApplicationFactory и Roslyn. Проверки тестами и компилятором стоит добавить, даже если модульность у вас держится на папках, договорённостях или любом модульном фреймворке.
Есть популярное возражение:
В эпоху ИИ архитектура больше не важна. Говоришь клоду «сделай хорошо», он сделает, а значит, и модульность не нужна, клод разберётся.
Вместо “хорошо” было другое слово, но цензура.
Агент, конечно, сделает «хорошо». Сделает для той задачи, которую ему дали: тест зелёный, фича работает. О том, что модуль Orders перестал запускаться отдельно, он не узнает, потому что такую топологию никто не запускал. Архитектуру агент не отменяет. Он просто ломает её с той же скоростью, с какой пишет код.
Агент пишет код быстрее человека и очень любит срезать углы. Нужный класс internal - проще сделать его публичным. Нужны данные соседа - проще замапить его таблицу. Каждый такой шаг на ревью не отловишь. А ошибка компиляции или красный тест останавливают агента сразу, и он вынужден исправлять код ещё до того, как кожаный увидит дифф.
При разделении монолита на модули я бы начал с тестов топологий: их проще всего прикрутить к существующему проекту, и ловят они больше всего. Анализатор публичных типов и ссылок я бы прикрутил к любому решению, где вы хотите модульности, особенно если код пишет ИИ. Остальное можно добавлять по мере того, как вылезают грабли. Руками это всё писать, конечно, не нужно: весь код анализатора и генераторов в этой статье написал ИИ.
Анализаторы лучше сразу упаковать в отдельный NuGet-пакет. Если анализатор лежит проектом в решении, агент его видит и может поправить правило вместо своего кода, и вам придётся уговаривать его так не делать. Из пакета агенту видна только диагностика, а само правило не поменять. Заодно появляется стимул делать анализаторы универсальными, чтобы они подходили к разным проектам.
Я всё это уже проделал и собрал в библиотеку.
FusionModules
FusionModules - открытая библиотека под MIT для модульных приложений на ASP.NET Core. В ней базовый класс модуля, проверки топологии на старте, пакет для тестов топологий и анализаторы MOD0001-MOD0005 из этой статьи, с исправлениями. Есть и правила, которых в статье нет: MOD0011 ловит контроллеры модуля без application part, а MOD0014 - два модуля, которые зарегистрировали один интерфейс. Проверки владения таблицей из бонуса в пакете пока нет.
dotnet add package FusionModules
В следующей статье я пошагово покажу, как переделать микросервисный eShop от Microsoft в модульный монолит с помощью FusionModules.
Материалы
Доклад: Модульность без микросервисов в ASP.NET из коробки, DotNext 2026
Репозиторий доклада: github.com/gandjustas/dotnext-2026. Демо, на которых выросли примеры из статьи:
modular-data,modular-endpoints,modular-analyzer,load-test; в веткеfusionmodulesте же демо на FusionModules 0.3Библиотека: FusionModules на GitHub
Тесты: TUnit, интеграционные тесты в ASP.NET Core, IModelCacheKeyFactory
Roslyn: написание анализаторов, генераторы кода,
Compilation.GetUsedAssemblyReferences
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.