Во второй статье я остановился на неприятном месте. Модули на IHostingStartup собраны, хост о них не знает, состав процесса задаёт переменная HOSTINGSTARTUPASSEMBLIES, а один образ обслуживает любую топологию. Но отчёт «неоплаченные заказы с клиентами» по-прежнему собирается в приложении: два запроса, словарь в памяти и DTO, копия чужой сущности. Ровно то, за что я ругал микросервисы в первой статье.

Сегодня это исправим.

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

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

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

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

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

  5. eShop: запилить микросервисы в модульный монолит.

Все примеры из репозитория доклада, папка modular-data, .NET 10.

TL;DR

  • Данные модулей можно хранить четырьмя способами: база на модуль, свой DbContext у каждого модуля в общей базе, копия чужих данных и одна модель из частей модулей. Только последний даёт SQL JOIN и один SaveChanges для сущностей разных модулей.

  • На отчёте «неоплаченные заказы с клиентами» JOIN в базе обслуживает на 12% больше запросов, чем JOIN в приложении по HTTP, а кода в нём вдвое меньше.

  • Модель собирается из IEntityTypeConfiguration загруженных модулей. Модуль получает базовый DbContext и ничего не знает о контексте приложения.

  • Ссылки между модулями делать можно. Но надо доработать ModuleBase, чтобы сверять ссылки с реестром загруженных модулей и не давать топологии стартовать без всех необходимых модулей.

  • Миграции генерирует design-time фабрика по объединению всех модулей. Применять их желательно вне программы.

Где живут данные модулей

Я насчитал четыре варианта.

База на модуль. Как database per service у микросервисов. Границы физические, но отчёт «заказы с клиентами» джойнится в приложении, а транзакция между модулями превращается в сагу. Это оправдано, когда модули действительно разъедутся по разным командам и разным СУБД.

Общая база, свой DbContext у каждого модуля. Схема на модуль, у каждого контекста своя история миграций. Так по умолчанию устроен ABP Framework и многие рукопашные реализации модульности. Границы видны в коде, но JOIN между контекстами EF Core не построит: один LINQ-запрос работает с одним контекстом. Остаются два запроса и склейка в памяти или SQL руками. Общая транзакция возможна, только если контексты делят соединение. Миграции у каждого контекста свои.

Копия чужих данных. Это денормализация: модуль заказов держит у себя таблицу клиентов, которую обновляют события модуля клиентов. JOIN снова в базе, но копия отстаёт, а синхронизация требует кода, очереди, дополнительной нагрузки и сценариев восстановления после сбоя. Транзакции между модулями нет, копия догоняет оригинал по событиям.

Одна модель из частей. Каждый модуль приносит свои IEntityTypeConfiguration, хост собирает из них один DbContext. JOIN в базе, один SaveChanges атомарно сохраняет сущности разных модулей, одна история миграций на всё приложение. Платить за это придётся тем, что описано в остальной статье.

Меня интересует последний вариант, про него и пойдёт речь.

Собираем модель из частей

Модель EF Core собирается по частям, и каждая часть живёт в своём модуле.

Сущность клиента можно описать прямо в одном файле:

// Customers.Entities
public class Customer
{
    public int Id { get; set; }
    [MaxLength(50)] public string Name { get; set; } = null!;
    [MaxLength(50)] [EmailAddress] public string Email { get; set; } = null!;
}

class CustomerEntityConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> entity) =>
        entity.ToTable(nameof(Customers), "Marketing");
}

// и модуль-заглушка: никаких сервисов, только вклад в модель
[assembly: HostingStartup(typeof(Module))]
class Module : ModuleBase { }

Аналогично для заказа:

// Orders.Entities: таблица заказов
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    // другие свойства
}

class OrderEntityConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> entity) =>
        entity.ToTable(nameof(Orders), "Sales");
}

[assembly: HostingStartup(typeof(Module))]
class Module : ModuleBase { }

Обратите внимание, что свойство внешнего ключа есть, а navigation property нет. Это, к сожалению, цена, которую надо заплатить за модульность модели. Но сам внешний ключ в базе при этом будет, об этом ниже.

Хост собирает единую модель из реестра:

builder.Services.AddDbContext<AppDbContext>(b =>
    b.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"))
     // предупреждение о расхождении модели пишем в лог, не подавляем (см. «Миграции»)
     .ConfigureWarnings(o => o.Log(RelationalEventId.PendingModelChangesWarning)));

// эндпоинтам не нужен именно ваш контекст, им нужен любой DbContext;
// в пределах scope это тот же экземпляр, что и AppDbContext
services.AddScoped<DbContext>(sp => sp.GetRequiredService<AppDbContext>());

class AppDbContext(DbContextOptions options, IConfiguration configuration) : DbContext(options)
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        foreach (var assembly in ModuleBase.GetLoadedModules(configuration))
            modelBuilder.ApplyConfigurationsFromAssembly(assembly);
    }
}

Чтобы собрать модель, хосту нужно знать, какие модули загружены в этом процессе. Добавим в ModuleBase из прошлой статьи реестр: при активации каждый модуль записывает себя в конфигурацию, в секцию Modules.

public abstract class ModuleBase : IHostingStartup, IStartupFilter
{
    public const string ModulesSection = "Modules";

    void IHostingStartup.Configure(IWebHostBuilder builder)
    {
        // ConfigureServices: как в прошлой статье

        builder.ConfigureAppConfiguration((ctx, configuration) =>
        {
            // модуль записывает себя в реестр активированных модулей
            configuration.AddInMemoryCollection(
                [new($"{ModulesSection}:{GetType().Assembly.GetName().Name}", GetType().FullName)]);
            ConfigureAppConfiguration(ctx, configuration);
        });
    }

    public static IReadOnlyList<Assembly> GetLoadedModules(IConfiguration configuration)
    {
        var order = (configuration[WebHostDefaults.HostingStartupAssembliesKey] ?? string.Empty)
            .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

        // Ранг −1 получает модуль, которого нет в переменной. Такой модуль ровно один:
        // сборка приложения, механизм активирует её сам, первой.
        return [.. configuration.GetSection(ModulesSection).GetChildren()
            .OrderBy(m => Array.FindIndex(order, n =>
                string.Equals(n, m.Key, StringComparison.OrdinalIgnoreCase)))
            .Select(m => Assembly.Load(m.Key))];
    }
}

GetLoadedModules возвращает модули в порядке HOSTINGSTARTUPASSEMBLIES. В том же порядке EF Core применяет конфигурации, поэтому модуль, который связывает сущности, должен идти последним.

// Monolith: composing-модуль, добавляет FK между чужими таблицами.
// Должен идти последним: Orders.Entities;Customers.Entities;Monolith
using Customers;
using Orders;

class OrderEntityConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> entity) =>
        entity.HasOne<Customer>().WithMany().HasForeignKey(e => e.CustomerId);
}

Теперь в базе появляется внешний ключ между сущностями, и код для проверки целостности писать не нужно. При этом модули, которые используют Order или Customer по отдельности, даже не знают о существовании этого ключа и других таблиц.

А модуль с эндпоинтом ссылается на обе сущности и делает настоящий SQL JOIN между сущностями двух модулей:

group.MapGet("/unpaid", (DbContext ctx) =>
    from o in ctx.Set<Order>()
    join c in ctx.Set<Customer>() on o.CustomerId equals c.Id
    where o.PaymentDate == null
    where o.OrderDate < DateTime.UtcNow.AddDays(-3)
    select new { OrderId = o.Id, o.OrderDate, Customer = new { c.Id, c.Name, c.Email } });

Модуль с эндпоинтом зависит от DbContext, а не от вашего контекста. Сущности он достаёт через Set<T>(): ему нужно место для таблиц, полная модель данных ему ни к чему. Расплачиваться приходится тем, что в модуле нет DbSet-свойств и конвенций уровня контекста.

Меряем JOIN в базе и JOIN в приложении

Для сравнения код соединения данных из разных сервисов
group.MapGet("/unpaid", async (DbContext ctx, HttpClient http, CancellationToken ct) =>
{
    var orders = await (from o in ctx.Set<Order>()
                        where o.PaymentDate == null
                        where o.OrderDate < DateTime.UtcNow.AddDays(-3)
                        select new { o.Id, o.OrderDate, o.CustomerId }).ToListAsync(ct);

    var ids = orders.Select(o => o.CustomerId).Distinct();
    var customers = await http.GetFromJsonAsync<CustomerDto[]>(
        $"http://customers/customers?{string.Join('&', ids.Select(i => "id=" + i))}",
        JsonSerializerOptions.Web, ct);
    var byId = customers!.ToFrozenDictionary(c => c.Id);

    return orders.Select(o => new
    {
        OrderId = o.Id,
        o.OrderDate,
        Customer = byId.TryGetValue(o.CustomerId, out var c)
            ? new { c.Id, c.Name, c.Email }
            : null   // клиент не нашёлся: у JOIN такого вопроса не было
    });
});

// Копия чужой сущности: её надо держать в согласии с оригиналом
record CustomerDto(int Id, string Name, string Email);

Оба варианта живут в одном образе и различаются топологией:

services:
  monolith:                      # всё в одном процессе, JOIN в базе
    image: modular-data
    environment:
      ConnectionStrings__Postgres: Server=postgres;Database=modular;...
      # порядок важен: Monolith дополняет конфигурации двух других модулей
      HOSTINGSTARTUPASSEMBLIES: Orders.Entities;Customers.Entities;Monolith

  orders:                        # только заказы, клиенты по HTTP
    image: modular-data          # тот же образ!
    environment:
      ConnectionStrings__Postgres: Server=postgres;Database=modular;...
      HOSTINGSTARTUPASSEMBLIES: Orders.Entities;OrdersMicroservice
      Services__customers__http__0: customers:8080

  customers:
    image: modular-data
    environment:
      ConnectionStrings__Postgres: Server=postgres;Database=modular;...
      HOSTINGSTARTUPASSEMBLIES: Customers.Entities;CustomersMicroservice

  postgres:
    image: postgres

Стенд: приложение и Postgres в Docker, k6, 50 VUs, 30 секунд, снимок docker stats под нагрузкой.

                             Монолит,         Микросервисы,
                             JOIN в базе      JOIN в приложении
Запросов в секунду           2011             1796
p95                          33,5 мс          43,5 мс
CPU веб-приложений, %        509              224 + 413
Память веб-приложений, MiB   143              119 + 149
CPU Postgres, %              236              206
Память Postgres, MiB         126              183

Кода в варианте «JOIN в приложении» вдвое больше, и это без ретраев, таймаутов и пагинации. Монолит обслуживает на 12% больше запросов (у микросервисов RPS на 11% ниже), а веб-приложения микросервисного варианта тратят на четверть больше CPU и почти вдвое больше памяти. Postgres в микросервисном варианте получает два простых запроса вместо одного с JOIN и тратит немного меньше CPU, но больше памяти.

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

Как модули обращаются к чужим частям модели

Модуль отчётов из примера выше читает и заказы, и клиентов. Получить чужие данные модуль может тремя способами, и они различаются силой связи.

Сослаться на модуль сущностей. Set<Customer>() требует, чтобы модуль видел тип Customer, то есть ссылался на Customers.Entities. Самый простой путь и самый жёсткий: такой модуль без соседа не стартует, об этом в следующем разделе.

Composing-модуль. Связь между сущностями двух модулей знает третий модуль, Monolith из примера выше. Orders.Entities и Customers.Entities друг о друге не знают, и в топологии без Monolith между их таблицами нет даже внешнего ключа. Вся связанность собрана в одном месте и включается одной строчкой в переменной.

Контракт. Если модулю нужен ответ на вопрос, а чужие сущности ему ни к чему, лучше сделать интерфейс в contracts-сборке. У него может быть реализация через DbContext в процессе и через HttpClient в другом. В демо OrdersMicroservice получает клиентов по HTTP как CustomerDto и не знает о сущности Customer, поэтому разворачивается без модуля клиентов.

Первый способ запрещает любую топологию, где модуль отчётов есть, а модуля клиентов нет. Второй включает связь одной строкой в переменной. Третий вообще не требует модуля клиентов в процессе. Какой запрет вам подходит, решать осознанно.

Проверяем ссылки между модулями на старте

Если модуль A ссылается на тип из модуля B, то любая топология с A обязана загрузить сборку B. Но загрузка сборки не выполняет её IHostingStartup. Для обычных сервисов можно применить Dependency Inversion: вынести тип-контракт в отдельную сборку и сослаться на неё из обоих модулей. Так было в прошлой статье с интерфейсом ICalculator.

Было:  Модуль A ──→ Модуль B        (A загружает сборку B, `IHostingStartup` не выполняется)
Стало: Модуль A ──→ Contracts ←── Модуль B

Контракты при этом не замерзают: все реализации и потребители выкатываются одной версией, поэтому метод в ICalculator переименовывается обычным рефакторингом с подсказками компилятора, без новой версии API.

С моделями данных EF так сделать нельзя: DbSet по интерфейсу не получить. Значит, надо хотя бы добиться, чтобы правило «ссылка модуля на другой модуль = совместный деплой» не нарушалось молча.

Для этого ModuleBase на старте сверяет ссылки модуля с реестром. В IStartupFilter добавляется одна строка:

Action<IApplicationBuilder> IStartupFilter.Configure(Action<IApplicationBuilder> next) => app =>
{
    ValidateReferences(app); // модуль не стартует без модулей, на которые ссылается
    next(app);
    Configure(app);
};

ValidateReferences проверяет, что все модули, на которые ссылается текущий (то есть сборки с HostingStartupAttribute), загружены и добавили себя в реестр. Иначе говоря, что у них отработал IHostingStartup.

private void ValidateReferences(IApplicationBuilder app)
{
    var config = app.ApplicationServices.GetRequiredService<IConfiguration>();
    var loaded = config.GetSection(ModulesSection).GetChildren().Select(c => c.Value!).ToHashSet();

    var notLoaded = new List<string>();
    foreach (var reference in GetType().Assembly.GetReferencedAssemblies())
    {
        Assembly assembly;
        try { assembly = Assembly.Load(reference); }
        catch (FileNotFoundException e)
        {
            throw new InvalidOperationException(
                $"Module {GetType().Assembly.GetName().Name} references {reference.Name}, " +
                "which is missing from the application output", e);
        }

        var hsa = assembly.GetCustomAttribute<HostingStartupAttribute>();
        if (hsa is not null && !loaded.Contains(hsa.HostingStartupType.FullName!))
            notLoaded.Add(reference.Name!);
    }

    if (notLoaded.Count > 0)
        throw new InvalidOperationException(
            $"Referenced modules {string.Join(", ", notLoaded)} are not initialized");
}

Теперь, если модуль A ссылается на B, а в HOSTINGSTARTUPASSEMBLIES забыли указать B, приложение упадёт при старте. Хост через эту проверку проходит спокойно, даже когда он сам модуль: ссылок на модули в его метаданных нет.

В демо это сработало на мне самом. Сейчас OrdersMicroservice возвращает клиентов как CustomerDto. В первой версии там был тип Customer, и docker compose up orders не поднимался: ValidateReferences отказывался стартовать топологию orders без модуля customers и был прав.

Миграции

Как создавать миграции

Грабли, на которые я наступил: dotnet ef поднимает настоящий хост. Значит, hosting startup отрабатывает, и модель EF Core повторяет HOSTINGSTARTUPASSEMBLIES той оболочки, из которой вы запустили команду:

  • переменная не задана → модель пустая → migrations add создаёт пустую миграцию с нулём ошибок;

  • задана подмножеством → миграция для подмножества. Тоже ноль ошибок.

Схема базы данных начинает зависеть от того, с чьей машины и из какой оболочки сгенерировали миграцию. Лечится это design-time фабрикой, которая сама перечисляет модули в коде и не смотрит в окружение:

class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
    // Миграции всегда генерируются по объединению всех модулей и применяются целиком.
    // Модуль, дополняющий чужие конфигурации (Monolith), идёт последним.
    static readonly string[] AllModules = ["Customers.Entities", "Orders.Entities", "Monolith"];

    public AppDbContext CreateDbContext(string[] args)
    {
        var configuration = new ConfigurationBuilder()
            .AddJsonFile("appsettings.json", optional: true)
            .AddEnvironmentVariables()
            .AddInMemoryCollection([
                new(WebHostDefaults.HostingStartupAssembliesKey, string.Join(';', AllModules))
            ])
            .AddInMemoryCollection(ModuleBase.CreateModuleRegistry(AllModules))
            .Build();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(configuration.GetConnectionString("Postgres"))
            .Options;

        return new AppDbContext(options, configuration);
    }
}

Фабрика заполняет тот же реестр, что и модули при старте, только явно. Хелпер в ModuleBase для этого совсем простой:

public static IEnumerable<KeyValuePair<string, string?>> CreateModuleRegistry(params string[] names) =>
    from name in names
    let attribute = Assembly.Load(name).GetCustomAttribute<HostingStartupAttribute>()
    select new KeyValuePair<string, string?>(
        $"{ModulesSection}:{name}", attribute?.HostingStartupType.FullName);

Команды обычные:

dotnet ef migrations add AddFk --project Web

Недостаток в том, что список модулей лежит в коде хоста, и хост неявно получает знание обо всех модулях данных. Это лечится кодогенерацией, о ней в четвёртой статье.

Почему падает неполная модель

Если модель EF Core в рантайме отличается от снапшота, падает ошибка PendingModelChangesWarning. А топология с частью модулей законно имеет модель меньше снапшота.

Пока отлаживаешь локально со всеми модулями, всё хорошо. На проде, где загружены не все модули, возникает ошибка. Она защищает от случая, когда поменяли модель и забыли миграцию, но для модульной модели становится проблемой. Можно ошибку отключить или понизить до warning в логе, хотя на полной модели она по-прежнему полезна. Тут надо смотреть на ваш процесс разработки: как приложение запускается локально и как эволюционирует база.

Где применять миграции

Как тогда отличить законное расхождение от забытой миграции? Вынести применение миграций из кода приложения в сценарий деплоя и проверять там полную модель вместо модели топологии. Команда has-pending-model-changes использует ту же design-time фабрику, то есть объединение всех модулей, и падает, если кто-то поменял сущность и не добавил миграцию:

dotnet ef migrations has-pending-model-changes --project Web

В демо-проекте миграции применяются прямо в Program.cs, до RunAsync:

var app = builder.Build();

await using (var scope = app.Services.CreateAsyncScope())
{
    var ctx = scope.ServiceProvider.GetRequiredService<AppDbContext>();
#if DEBUG
    // какие таблицы реально попали в модель этой топологии
    Console.WriteLine(ctx.Model.ToDebugString(MetadataDebugStringOptions.LongDefault));
#endif
    await ctx.Database.MigrateAsync();
}

Для локальной разработки этого хватает, и даже удобно: не надо отдельно накатывать миграции. В проде я бы так не делал. Приложению нужны права на создание таблиц, даже если миграций нет. Поэтому миграции лучше применять отдельно, через dotnet ef database update или бандл из dotnet ef migrations bundle, либо только в топологии, где загружен модуль-мигратор.

Как это сделано в готовых решениях

Модульные монолиты в .NET строят давно, и у готовых решений свои ответы на те же вопросы. Сравню их по составу модулей, данным и раскладке по процессам.

  1. ABP Framework. О составе модулей знает модуль хоста: он перечисляет зависимости в [DependsOn]. У каждого модуля свой DbContext, приложение может свести их в один контекст через [ReplaceDbContext], и только тогда между модулями возможен JOIN. Миграции применяет отдельное консольное приложение DbMigrator. Разнести модули по процессам можно, но это другой шаблон решения с отдельными хостами.

  2. Orchard Core. Модули включаются как фичи в рантайме фреймворка, для каждого тенанта отдельно. Данные хранятся документами YesSql с индексными таблицами, и связи между модулями идут через индексы YesSql, EF Core тут не участвует. Миграция - класс DataMigration у фичи, выполняется при её включении. Модули и тенанты живут в одном приложении, по процессам они не разносятся.

  3. Самописный фреймворк на MediatR, как в eShop или в Modular Monolith with DDD Гжибека. Хост инициализирует каждый модуль в коде. У модуля свой DbContext, своя схема и своя история миграций. JOIN между модулями нет: к соседу идут запросом через MediatR или событием и склеивают результат в памяти. Чтобы разнести модули по процессам, шину в памяти придётся заменить брокером.

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

С данными так же. ABP и самописные фреймворки начинают с изоляции: свой контекст у каждого модуля, а общая модель - особый случай, который надо собрать руками. Здесь наоборот: модель по умолчанию общая, с одной историей миграций и обычным LINQ join между модулями, а границу держат ссылки между сборками и проверка на старте.

Я не говорю, что готовые решения плохи. ABP и Orchard Core дают гораздо больше, чем модульность: UI, права, мультитенантность, админку. Но если вам нужна именно модульность без микросервисов, а не платформа, то в ASP.NET и EF Core уже есть всё необходимое.

Сколько всего пришлось написать

За две статьи, чтобы модульность заработала на штатном ASP.NET и EF Core, руками пришлось сделать вот что:

  1. ModuleBase с IHostingStartup и IStartupFilter и явный app.UseRouting() в хосте.

  2. Выключить discovery Application Parts в хосте и подключать части в каждом модуле.

  3. Реестр загруженных модулей в конфигурации и GetLoadedModules в правильном порядке.

  4. Проверку ссылок между модулями на старте.

  5. Contracts-сборки для всего, что нужно двум модулям.

  6. Design-time фабрику со списком всех модулей, понижение PendingModelChangesWarning и has-pending-model-changes в CI.

А вот что всё ещё может сломаться, и ни одна из этих мер этого не заметит:

  • опечатка в HOSTINGSTARTUPASSEMBLIES: crit в лог, зелёный health-check и 404 вместо страниц;

  • забытый [assembly: HostingStartup]: сборка загружена и не делает ничего;

  • модуль сделал тип публичным, и сосед на него сослался: появилась ссылка, о которой никто не договаривался;

  • хост использовал тип модуля, и модуль стал обязательным в любом деплое;

  • два модуля зарегистрировали один интерфейс, и один молча получает реализацию другого;

  • список модулей в design-time фабрике отстал от ссылок проекта, и миграция вышла без новых таблиц;

  • интеграционные тесты с разными топологиями модулей модели данных конфликтуют между собой;

  • hosted-сервис из сервиса с одной репликой теперь работает в каждой реплике каждой топологии.

Как все эти «тихие отказы» и падения в рантайме с непонятными причинами превратить в ошибки компиляции и красные тесты, расскажу в следующей, четвёртой статье.

Материалы