InquirerValencia City allots P140M as it gears up vs. El NiñoThe Jerusalem PostJerusalem's L3 light rail line extended to Damascus Gate, stops added in downtown areaPunchFG moves to license scrap dealers, curb theftInquirer EntertainmentRussell Brand sentenced to 10 and a half years in prison for rape, sexual assaultZDF heuteEntdecken Sie das ZDF-Nachrichtenstudio3DNewsAnthropic хотела привлечь $70 млрд на бирже, а потом передумала7sur7Amélie Lens, Interpol, ADÉLA: Rock Werchter dévoile sa nouvelle salve de nomsХабрRockstar продаёт реальный спортивный автомобиль Grotti Cheetah из GTA VIIl Fatto QuotidianoPerturbazione atlantica in arrivo sull’Italia, piogge fino a 18 ore e venti a 80 km/h. Liguria e Toscana le più colpiteGolem.deJensen Huang: FBI ermittelt zum Spionageverdacht bei Nvidian-tvHöchster je erfasster Wert: Immer mehr Patienten suchen Notaufnahmen aufESPN Cricinfo'IPL has earned the right to influence the calendar' - KKR co-owner wants shift to January
The Daily Newsstand · Free, Always
Tuesday, November 10, 2026

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

Translate

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

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

Это четвёртая статья по мотивам моего доклада «Модульность без микросервисов в 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.

Материалы

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.