CNN TürkSON DAKİKA... Bakan Yumaklı'dan 'pestisit' açıklaması: AB Komiserine istatistikleri gösterdimSky TG24Nord Sud Ovest Est, stasera il sesto episodio della serie sugli 883InquirerMarbled monitor lizard rescued, released into wild in BatanesThe Jerusalem PostTrump could woo North Korea after midterm setback, but Kim may not respond - analysisPunchDavido had no role in Naira Marley, VDM Mohbad saga — AideBollywood HungamaRanveer Singh’s Pralay shoot sparks BIG controversy at Mumbai’s Azad Maidan; Under-14 and Under-16 cricket matches POSTPONED; MCA FUMES over disruptionZDF heuteEntdecken Sie das ZDF-NachrichtenstudioBusiness AMFranse autobouwer Renault en Belgisch defensiebedrijf John Cockerill bouwen samen militaire robotsGIGAZINE総カロリー1579kcal&総重量513gで圧倒的ガーリック臭が炸裂する「ギルティ ザ・ワンパウンダー」をバーガーキングで食べてきたRapplerFACT CHECK: Marcos didn’t say earning P96 a day means Filipinos aren’t poorThe Straits TimesCheating charges handed to man who allegedly claimed he was tortured, forced to make scam callsHet Laatste NieuwsAchter de schermen bij de nieuwe generiek van ‘Familie’: “De verhalen na 35 jaar op? Er ligt nog stof voor jaren klaar”
The Daily Newsstand · Free, Always
Friday, November 6, 2026

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

Translate

В прошлой статье мы собрали модули на IHostingStartup. Хост о них не знает, состав процесса задаёт переменная HOSTINGSTARTUPASSEMBLIES, а один образ обслуживает любую топологию. Закончили на том, что модули пока похожи на микросервисы и за JOIN между ними платят кодом в приложении. Сегодня это исправим.

Это третья статья по мотивам моего доклада «Модульность без микросервисов в 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 у микросервисов. Границы физические, но отчёт «заказы с клиентами» джойнится в приложении, а транзакция между модулями превращается в сагу. Это оправдано, когда модули действительно разъедутся по разным командам и разным СУБД. (join в приложении)

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

Копия чужих данных. Модуль заказов держит у себя таблицу клиентов, которую обновляют события модуля клиентов. JOIN снова в базе, но копия отстаёт, а синхронизация — это код, очередь, дополнительная нагрузка и сценарии восстановления после сбоя. (денормализация)

Одна модель из частей. Каждый модуль приносит свои IEntityTypeConfiguration, хост собирает из них один DbContext. JOIN в базе, один SaveChanges атомарно сохраняет сущности разных модулей, одна история миграций на всё приложение. Цена — всё, о чём пойдёт речь ниже. (modular data model)

Вариант

JOIN заказов и клиентов

Транзакция между модулями

Миграции

База на модуль

В приложении, через сеть или контракт

Сага

У каждого модуля свои

Свой DbContext в общей базе

В приложении: два запроса

Только через общее соединение

Своя история у каждого контекста

Копия чужих данных

В базе, по копии

Нет: копия догоняет по событиям

Свои плюс таблицы копий

Одна модель из частей

В базе

Один 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. В первой версии там был тип 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 строят давно, и у готовых решений свои ответы на те же вопросы. Сравню их по составу модулей, данным и раскладке по процессам.

  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-сервис из сервиса с одной репликой теперь работает в каждой реплике каждой топологии.

Как все эти “тихие отказы” и падения в рантайме с непонятными причинами превратить в ошибки компиляции и красные тесты — будут следующие статьи.

Материалы

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.