В прошлой статье я сделал проверку зависимостей между модулями при запуске. Только срабатывает она уже на проде, а ловить такие ошибки хочется раньше.

В конце той статьи я привёл список поломок, которые проходят сборку, а иногда и локальную отладку. Здесь покажу, как превратить их в ошибки компиляции и красные тесты.

Это четвёртая статья по мотивам моего доклада «Модульность без микросервисов в ASP.NET из коробки» на DotNext 2026:

  1. Модульность без микросервисов: почему она того стоит.

  2. Модульный монолит в ASP.NET из коробки.

  3. Модульная модель данных в EF Core.

  4. Как ломается модульный монолит и как с этим бороться (вы здесь).

  5. 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.

Материалы