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

В прошлой статье мы собрали модули на IHostingStartup. Хост о них не знает, состав процесса задаёт переменная HOSTINGSTARTUPASSEMBLIES, а один образ обслуживает любую топологию. Закончили на том, что модули пока похожи на микросервисы и за JOIN между ними платят кодом в приложении. Сегодня это исправим.
Это третья статья по мотивам моего доклада «Модульность без микросервисов в ASP.NET из коробки» на DotNext 2026:
Модульная модель данных в EF Core(вы здесь).
Как ломается модульный монолит и как с этим бороться.
eShop: запилить микросервисы в модульный монолит.
Все примеры — из репозитория доклада, папка modular-data, .NET 10.
TL;DR
Данные модулей можно хранить четырьмя способами: база на модуль, свой
DbContextу каждого модуля в общей базе, копия чужих данных и одна модель из частей модулей. Только последний даёт SQL JOIN и одинSaveChangesдля сущностей разных модулей.На отчёте «неоплаченные заказы с клиентами» JOIN в базе обслуживает на 12% больше запросов, чем JOIN в приложении по HTTP, а кода в нём вдвое меньше.
Модель собирается из
IEntityTypeConfigurationзагруженных модулей. Модуль получаетDbContext, а не контекст приложения.Ссылки между модулями делать можно. Но надо доработать
ModuleBaseчтобы сверять ссылки с реестром загруженных модулей и не давать топологии стартовать без всех необходимых.Миграции генерирует design-time фабрика по объединению всех модулей. Их применение желательно выносить из програмы.
Где живут данные модулей
Вариантов четыре, и у каждого своя цена.
База на модуль. Как database per service у микросервисов. Границы физические, но отчёт «заказы с клиентами» джойнится в приложении, а транзакция между модулями превращается в сагу. Это оправдано, когда модули действительно разъедутся по разным командам и разным СУБД. (join в приложении)
Общая база, свой DbContext у каждого модуля. Схема на модуль, у каждого контекста своя история миграций. Так устроены ABP Framework по умолчанию и многие рукопашные реализации модульности. Границы видны в коде, но JOIN между контекстами EF Core не построит: один LINQ-запрос работает с одним контекстом. Остаются два запроса и склейка в памяти или SQL руками. Общая транзакция возможна, только если контексты делят соединение. (shared data model)
Копия чужих данных. Модуль заказов держит у себя таблицу клиентов, которую обновляют события модуля клиентов. JOIN снова в базе, но копия отстаёт, а синхронизация — это код, очередь, дополнительная нагрузка и сценарии восстановления после сбоя. (денормализация)
Одна модель из частей. Каждый модуль приносит свои IEntityTypeConfiguration, хост собирает из них один DbContext. JOIN в базе, один SaveChanges атомарно сохраняет сущности разных модулей, одна история миграций на всё приложение. Цена — всё, о чём пойдёт речь ниже. (modular data model)
Вариант | JOIN заказов и клиентов | Транзакция между модулями | Миграции |
|---|---|---|---|
База на модуль | В приложении, через сеть или контракт | Сага | У каждого модуля свои |
Свой | В приложении: два запроса | Только через общее соединение | Своя история у каждого контекста |
Копия чужих данных | В базе, по копии | Нет: копия догоняет по событиям | Свои плюс таблицы копий |
Одна модель из частей | В базе | Один | Одна история на приложение |
Дальше — про последний вариант.
Модульная модель
Идея: модель 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. В первой версии там был тип 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 migrate отдельно или запускать миграции только в топологии, где загружен модуль-мигратор.
Чем это отличается от готовых решений
Модульные монолиты в .NET строят давно, и у готовых решений свои ответы на те же вопросы. Сравню их по составу модулей, данным и раскладке по процессам.
ABP Framework. О составе модулей знает модуль хоста: он перечисляет зависимости в
[DependsOn]. У каждого модуля свойDbContext, приложение может свести их в один контекст через[ReplaceDbContext], и только тогда между модулями возможен JOIN. Миграции применяет отдельное консольное приложение DbMigrator. Разнести модули по процессам можно, но это другой шаблон решения с отдельными хостами.Orchard Core. Модули включаются как фичи в рантайме фреймворка, для каждого тенанта отдельно. Данные хранятся документами YesSql с индексными таблицами, а связи между модулями идут через индексы YesSql, не через EF Core. Миграция — класс
DataMigrationу фичи, выполняется при её включении. Модули и тенанты живут в одном приложении, по процессам они не разносятся.Самописный фреймворк на 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, руками пришлось сделать вот что:
ModuleBaseсIHostingStartupиIStartupFilterи явныйapp.UseRouting()в хосте.Выключить discovery Application Parts в хосте и подключать части в каждом модуле.
Реестр загруженных модулей в конфигурации и
GetLoadedModulesв правильном порядке.Проверку ссылок между модулями на старте.
Contracts-сборки для всего, что нужно двум модулям.
Design-time фабрику со списком всех модулей, понижение
PendingModelChangesWarningиhas-pending-model-changesв CI.
А вот что всё ещё может сломаться, и ни одна из этих мер этого не заметит:
опечатка в
HOSTINGSTARTUPASSEMBLIES—critв лог, зелёный health-check и 404 вместо страниц;забытый
[assembly: HostingStartup]— сборка загружена и не делает ничего;модуль сделал тип публичным, и сосед на него сослался — ссылка, о которой никто не договаривался;
хост использовал тип модуля — и модуль стал обязательным в любом деплое;
два модуля зарегистрировали один интерфейс — и один молча получает реализацию другого;
список модулей в design-time фабрике отстал от ссылок проекта — миграция без новых таблиц;
интеграционные тесты с разными топологиями модулей модели данных конфликтуют между собой;
hosted-сервис из сервиса с одной репликой теперь работает в каждой реплике каждой топологии.
Как все эти “тихие отказы” и падения в рантайме с непонятными причинами превратить в ошибки компиляции и красные тесты — будут следующие статьи.
Материалы
Доклад: Модульность без микросервисов в ASP.NET из коробки — DotNext 2026
Исходники и все демо: github.com/gandjustas/dotnext-2026, папка
modular-dataДокументация EF Core: IEntityTypeConfiguration, design-time DbContext creation, применение миграций и блокировка в EF Core 9,
Модульные монолиты: ABP Framework, Orchard Core
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.