В прошлой статье я сделал проверку зависимостей между модулями при запуске. Только срабатывает она уже на проде, а ловить такие ошибки хочется раньше.
В конце той статьи я привёл список поломок, которые проходят сборку, а иногда и локальную отладку. Здесь покажу, как превратить их в ошибки компиляции и красные тесты.
Это четвёртая статья по мотивам моего доклада «Модульность без микросервисов в 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
