Anti-corruption layer в учебниках выглядит как одна аккуратная коробка между чужой системой и вашей: всё чужое остаётся снаружи, внутрь проходит только то, что уже переведено на язык вашего домена. Граница ровная, как по линейке. Почти за год, что мы вытесняли 10-летний Rails-монолит на .NET, такой ровной границы я не увидел ни разу – ни у себя, ни, кажется, у кого-то ещё на живом проекте.

Понятно это стало в первую же неделю. Мы открыли таблицу exercises и нашли в одной JSONB-колонке 14 разных форм одного типа задания, слепленных за 10 лет. Смаппить это в новые DTO как есть – и новая модель отрастит ровно ту же форму, что у Rails. “Нужен ACL”, подумали мы. Только, как выяснилось на практике, не один.

Контекст такой. EdTech-LMS, монолит на Ruby on Rails живёт 10 лет. Мы заменяем application-слой на .NET 9 – не переписываем всё, а вытесняем по эндпоинту через гейтвей: новый эндпоинт идёт в .NET, старый остаётся в Rails до явного переключения. БД пока одна общая. В новую схему переезжаем не сразу, а по факту: трогаем таблицу – тогда и разбираемся с её настоящей формой. И почти на каждой таблице эта форма не совпадала с тем, что записано в схеме.

Швов получилось 3, и все в разных местах: один тонкий, на чтении – на nullable-комбинациях, один с типизирующим парсером, под тот самый зоопарк JSONB, один с инвариантами, на записи. Каждый из них по-сути ACL, и каждый выглядит по-разному, потому что боль в трёх местах была разная.

Дальше – три кейса. У каждого один и тот же каркас: что мы написали в наивной версии, где это пробило, и какой шов вырос на этом месте.

Шов первый: прогресс студента (read-side)

В Rails прогресс студента по уроку лежит в таблице progresses нескольких полей. score – integer nullable. completed_at – datetime nullable. attempts – integer с default 0. Плюс JSON-колонка metadata, в которую за годы залили всё, что не помещалось в нормальную схему.

В этих четырёх полях прячутся три разных состояния. Когда студент прошёл урок, заполнены score и completed_at. Когда не начинал – все три nullable пустые и attempts = 0. Когда начал, потыкал и бросил – score = 0, completed_at = null, attempts > 0. Эта триада в Rails-коде размазана по if-каскадам в десятке мест: одна view знает её одним способом, другой report – другим, мобильное API – третьим. Когда мы пришли с .NET за этими данными, первая попытка выглядела максимально невинно.

// BAD: наружу торчат те же четыре nullable-поля, что в БД – legacy-форма утекает в домен
public class StudentProgressDto
{
    public int? Score { get; set; }
    public DateTime? CompletedAt { get; set; }
    public int Attempts { get; set; }
    public string? MetadataJson { get; set; }
}

public async Task<List<StudentProgressDto>> GetProgress(int studentId)
    => await _db.Progresses
        .Where(p => p.StudentId == studentId)
        .Select(p => new StudentProgressDto
        {
            Score = p.Score,
            CompletedAt = p.CompletedAt,
            Attempts = p.Attempts,
            MetadataJson = p.Metadata
        })
        .ToListAsync();

В коде, который этот DTO потребляет, через две недели появились занятные строчки. В отчёте о прохождении курса: if (p.Score == null && p.Attempts == 0) status = "not_started";. В виджете “продолжить с того места”: if (p.Score == null && p.Attempts > 0) .... В экспорте: ещё одна вариация того же самого. Три места – одна логика, повторённая немного разными словами. Логика, разумеется, расходилась. В одном месте Score == 0 считалось как “не начал”, в другом – как “начал, ничего не набрал”. Тот же баг, что 10 лет жил в Rails-коде, аккуратно переехал в новый.

Это и есть legacy-форма, протекающая через границу. На уровне БД она оправдана – у вас один тип и три состояния, кодируйте как умеете. На уровне домена эта форма не должна существовать вообще. Доменный код хочет знать “не начал” / “в процессе” / “пройдено”, а не комбинацию null’ов в четырёх полях.

Самое неприятное в этой истории – её срок жизни. В Rails-коде эта же интерпретация расходилась годами и не болела: код Rails жил с ней изо дня в день, команда привыкла, тесты, которых не было, не падали. На новой системе мы писали заново – и каждое новое место с собственной интерпретацией становилось видно почти сразу.

Когда мы пересмотрели уже написанный код через спринт, нашли в нём четыре места с этой ветвящейся логикой, а не три, как казалось. Четвёртое отличалось трактовкой Score == 0 от трёх первых – нашли его не grep’ом, а через расхождение в еженедельном отчёте на проде. С этого момента стало ясно, что репозиторий не должен отдавать наружу четыре nullable-поля. Он должен отдавать состояние.

public abstract record LessonProgress
{
    public sealed record NotStarted : LessonProgress;
    public sealed record InProgress(int Attempts) : LessonProgress;
    public sealed record Completed(int Score, DateTime At) : LessonProgress;
}

internal static class LessonProgressMapper
{
    // шов: единственное место, где комбинации null'ов превращаются в состояние
    public static LessonProgress From(ProgressRow row) => row switch
    {
        { CompletedAt: { } at, Score: { } s } => new LessonProgress.Completed(s, at),
        { Attempts: > 0 } => new LessonProgress.InProgress(row.Attempts),
        _ => new LessonProgress.NotStarted()
    };
}

Шов прошёл в одной строчке: LessonProgressMapper.From. Это вся ACL для этого кейса. Снаружи репозиторий возвращает IReadOnlyList<LessonProgress> – три типа, закрытая иерархия, исчерпывающий pattern match. Внутри – один switch на nullable-полях. Никакой “слой ACL” по этому случаю писать не нужно: легаси-форма (комбинации null’ов) живёт за маппером, а наружу торчит только домен.

Этот маппер хочется накрыть тестом, и тест получается дешёвым. На вход – ProgressRow с одной из комбинаций null’ов. На выходе – ожидаемый подтип LessonProgress. Никакой in-memory БД, никаких моков. Закрытая иерархия даёт второе преимущество, не такое очевидное: когда через полгода кто-то добавит четвёртое состояние (например, ResetAfterFailure под новый процесс пересдач), компилятор предупреждением CS8509 подсветит каждый switch по LessonProgress, где нет catch-all ветки _. Настоящих закрытых union’ов C# не даёт и в ошибку это сам не превратит – но если держать CS8509 как build error (а на таком коде стоит), пропустить место уже не выйдет. Шов держится не только маппером – он держится самой формой типа.

Это маленький шов. Он не похож на то, что рисуют в книжках под словом “anti-corruption layer”. Но он делает ровно то, что от ACL и ожидается: легаси-форма ниже шва, доменная – выше. Просто на этом кейсе шов получился размером в одну функцию.

Read-side ACL – не обёртка над таблицей, а вычитание неопределённости: на входе nullable-комбинации, на выходе типизированные состояния.

Шов второй: парсер на стыке

С таблицей progresses нам повезло. У неё была плоская схема, состояний всего три. С таблицей exercises повезло меньше.

В exercises лежит две интересные колонки. Первая – kind, строка. За 10 лет в ней накопилось 14 различных значений. Вторая – data, JSONB. У каждого kind своя структура data, никогда нигде не зафиксированная типом. Multiple choice держит массив вариантов и индекс верного: { "options": ["A","B","C"], "answer_idx": 1 }. Open answer – regexp для проверки: { "regex": "^кошк[аи]$" }. Listening – ссылку на аудио, массив таймкодов и текст: { "url": "...", "cues": [...], "transcript": "..." }. И так 14 разных форм.

В .NET-коде сюда смотрели 3 разных модуля: рендер задания для фронта, движок проверки ответа, экспорт прогресса в отчёты. Каждому нужен был свой срез данных. Каждый норовил вытащить нужное из JSON по месту.

public class ExerciseService
{
    public async Task<object?> RenderExercise(int id)
    {
        var row = await _db.Exercises.SingleAsync(e => e.Id == id);
        // BAD: разбор JSONB по месту – и так в каждом из трёх модулей, по-своему
        using var data = JsonDocument.Parse(row.Data);

        return row.Kind switch
        {
            "mc" => new {
                variants = data.RootElement.GetProperty("options"),
                correct  = data.RootElement.GetProperty("answer_idx").GetInt32()
            },
            "open" => new {
                pattern = data.RootElement.GetProperty("regex").GetString()
            },
            "listening" => new {
                audio = data.RootElement.GetProperty("url").GetString(),
                times = data.RootElement.GetProperty("cues")
            },
            _ => null
        };
    }
}

Дублирование пока не ощущается – три сервиса написаны в разные спринты, разными людьми, каждый с ощущением, что делает правильно. Это и есть главный признак утечки легаси-формы через границу: она не вопит, а тихо расходится по кодовой базе. Render, validate, export – каждый смотрел в тот же kind и тот же data, и каждый интерпретировал по-своему. Что было дальше, угадывается с двух нот.

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

Валидация упала на mc_polyglotkind, который добавили три года назад под одного крупного клиента и так и забыли. В data лежали те же options и answer_idx, но в коде стояло case "mc": без вариантов. Студенты не могли пройти задание – API возвращало 500.

Экспорт разбирал cues как массив, что было верно для большинства записей listening – но не для половины старых, где cues был объектом с парой свойств. NullReferenceException в job, который раз в неделю формирует отчёты для админов.

Три места, три бага, одна неделя. И все три – про одно и то же: легаси-форма kind + JSONB пролезла в новый код в трёх копиях и в каждой из них по-своему ошиблась. Лечить каждый switch по отдельности – значит лечить симптом.

public abstract record Exercise(int Id)
{
    public sealed record MultipleChoice(int Id, IReadOnlyList<string> Variants, int CorrectIdx) : Exercise(Id);
    public sealed record OpenAnswer(int Id, string AnswerPattern) : Exercise(Id);
    public sealed record Listening(int Id, Uri Audio, IReadOnlyList<Cue> Cues) : Exercise(Id);
}

internal static class LegacyExerciseParser
{
    // шов: единственная точка, где legacy kind+JSONB превращается в доменный тип
    public static Exercise Parse(ExerciseRow row) => row.Kind switch
    {
        "mc" or "mc_polyglot"  => MultipleChoice.From(row),
        "open" => OpenAnswer.From(row),
        "listening" or "listening_v2" => Listening.From(row),
        // неизвестный kind падает здесь, а не null'ом через неделю
        _ => throw new UnknownLegacyExerciseKindException(row.Id, row.Kind)
    };
}

Один switch. Одна точка, через которую обязан пройти каждый exercise из legacy. Синонимы (mc и mc_polyglot, listening и listening_v2) собраны в одно место – остальной код о них не знает. Неизвестный kind падает не как null в трёх местах через неделю, а как одна типизированная ошибка в одном месте, ровно тогда, когда он впервые попадает в систему.

Выше шва домен видит закрытую иерархию MultipleChoice / OpenAnswer / Listening. Render, validate и export просят у репозитория Exercise и делают pattern match по типу – ни про kind, ни про JSON они не знают, ни одного JsonDocument.Parse за пределами парсера.

Когда через два месяца Rails-команда завела пятнадцатый kindmc_image с картинками вместо текстовых вариантов, – мы добавили sealed record MultipleChoiceImage и одну строчку в switch. Ни render, ни validate, ни export не трогали: тот же CS8509 указал на каждый switch по Exercise без _-ветки, который нужно дополнить.

Это место – единственное в коде, где сходятся знание о legacy и знание о новой модели. Поэтому его же единственное имеет смысл сверять snapshot-тестами: берём двести строк exercises из обезличенного прод-снапшота, прогоняем через Parse, сравниваем с сериализованной правильной версией. В снапшоте – все четырнадцать kind, включая те, что встречаются в одной-двух строках на всю базу и никогда не попадали в ручное тестирование.

Первый запуск поймал тринадцать расхождений:

  • десяток забытых форм поля metadata

  • две комбинации kind, о которых не знал даже Rails-тимлид

  • одна запись с data = null от legacy-импорта десятилетней давности

Дальше любая неучтённая форма всплывает на CI красным, а не в проде пятисоткой. Это тесты не “на парсер” – на стабильность шва.

Эту картину Evans описывает в DDD, глава 14: ACL – шов, поверх которого модель не обязана знать о форме чужой модели. А вот что шов работает, только когда он один и конкретный, – это уже вывод из практики, у Evans такой оговорки нет.

Парсер на шве – не “слой”, а одна функция, через которую обязан пройти каждый legacy-объект. Тестируется дёшево, ловит больше, чем три сервиса по отдельности.

Шов третий: правило, которое жило в эксельке

Первые два кейса – про чтение. Здесь начинается запись.

Управление группами мы переписывали поздно. В Rails это была одна форма в админке методиста: выбрать курс, преподавателя, день недели, время начала, время конца, нажать “создать”. Всё это превращалось в одну строчку в таблице groupsteacher_id, course_id, day_of_week, start_time, end_time, is_active. Никаких проверок. Кто угодно мог создать сколько угодно групп с любыми пересечениями.

Это работало десять лет, потому что между формой и базой стоял методист. Он вёл табличку – расписание каждого преподавателя по дням недели – и сверял её каждый раз, когда добавлял группу. Если у Иванова в среду уже стояла группа с 18 до 19:30, вторую на это же время он не ставил. Правило держалось на человеке: пока он был в цепочке, системе нечего было проверять, и в Rails-коде не было ни одной причины что-то проверять. Его не зафиксировали ни в коде, ни в документации – оно и не подразумевалось нигде, кроме той таблички.

В апреле 2025-го методист уволился. Замена начала ту же работу с нуля, по своему пониманию, не зная про эксельку. Через две недели у преподавателя Иванова в среду в 18:00 оказалось две группы. Студенты пришли в обе аудитории, Иванов узнал об этом по звонку из одной – и опоздал на полтора часа в другую.

В этот момент в Rails-коде проверки не было. И в нашем новом коде – тоже не было.

public class GroupService
{
    public async Task<int> CreateGroup(int teacherId, int courseId, ScheduleDto schedule)
    {
        var row = new GroupRow
        {
            TeacherId  = teacherId,
            CourseId   = courseId,
            DayOfWeek  = (int)schedule.DayOfWeek,
            StartTime  = schedule.Start,
            EndTime    = schedule.End,
            IsActive   = true
        };
        // BAD: ни одной проверки – правило пересечений осталось в голове методиста
        _db.Groups.Add(row);
        await _db.SaveChangesAsync();
        return row.Id;
    }
}

Этот код повторяет ровно то, что делал Rails. Принимает входные данные, складывает в БД, отдаёт id. Если правила нет в коде Rails, разработчик нового кода о нём не догадается – а если и догадается, ему некуда его поставить. У него есть GroupService поверх DbContext. Поверх DbContext ставить домен не получится без боли – DbContext диктует свой ритм: каждая сущность – это объект в change tracker, каждый инвариант хочет дополнительный запрос внутри транзакции, и вся эта конструкция начинает тянуть инфраструктуру туда, где должна быть только логика. В итоге тест на одно правило превращается в интеграционный тест с настоящей или in-memory БД, потому что логику от хранилища уже не оторвать.

Это распространённый паттерн ошибки в миграциях: переписать форму, не переписав правила. Особенно когда правила нигде не записаны. Особенно когда они никогда не подразумевались в коде, а держались на отдельном человеке. Такой код идеально проходит code review – он делает ровно то, что говорит форма. Только форма не говорит всего: она описывает поля, но не предусловия. Этот человек может работать с вами десять лет – и уйти за три недели до того, как вы доберётесь до этого модуля.

public sealed record TimeSlot(DayOfWeek Day, TimeOnly Start, TimeOnly End)
{
    public bool OverlapsWith(TimeSlot other)
        => Day == other.Day && Start < other.End && other.Start < End;
}

public sealed class Group
{
    public int Id { get; }
    public TeacherId Teacher { get; }
    public CourseId Course { get; }
    public TimeSlot Slot { get; }

    private Group(int id, TeacherId teacher, CourseId course, TimeSlot slot)
        => (Id, Teacher, Course, Slot) = (id, teacher, course, slot);

    // шов: правило живёт здесь – ни DbContext, ни запроса, ни await; контекст приходит параметром
    public static Group Schedule(
        TeacherId teacher,
        CourseId course,
        TimeSlot slot,
        IReadOnlyList<TimeSlot> activeSlotsOfTeacher)
    {
        var conflict = activeSlotsOfTeacher.FirstOrDefault(s => s.OverlapsWith(slot));
        if (conflict is not null)
            throw new ScheduleConflictException(teacher, conflict);
        return new Group(id: 0, teacher, course, slot);
    }
}

internal sealed class GroupFacade
{
    // фасад знает про БД, агрегат – про правило; граница проходит между ними
    public async Task<int> CreateGroup(TeacherId teacher, CourseId course, TimeSlot slot)
    {
        var existing = await _groups.LoadActiveSlotsFor(teacher);
        var group    = Group.Schedule(teacher, course, slot, existing);
        return await _groups.Persist(group);
    }
}

Шов прошёл в Group.Schedule. Это фабрика агрегата, и она знает правило – ни одного DbContext, ни одного запроса, ни одного await. Контекст, нужный для решения (активные расписания этого преподавателя), приходит явным параметром. Снаружи – GroupFacade с одной публичной операцией CreateGroup. Она читает контекст из репозитория, передаёт его в фабрику, сохраняет результат. ACL живёт между фасадом и агрегатом: фасад знает про БД, агрегат – про правило, между ними проходит граница.

Здесь внимательный читатель заметит гонку: между LoadActiveSlotsFor и Persist второй параллельный запрос успевает пройти ту же проверку и тоже записаться – два пересекающихся слота проскочат наперегонки. Проверка в агрегате – первая линия, а не единственная: от двойной записи в гонке страхует уже сама БД (в Postgres – EXCLUDE USING gist на пересечение интервалов). Это соседняя тема и отдельное укрепление; здесь важно одно – правило перестало жить в голове методиста и стало явным в коде.

Этот разрыв важен не из эстетических соображений. Когда правило сидит в фабрике, у которой нет зависимостей, его можно протестировать в одну строку: создать массив TimeSlot для воображаемого преподавателя, передать в Group.Schedule, проверить, что бросается ScheduleConflictException. Тест выглядит дословно так:

[Fact]
public void Schedule_throws_when_teacher_slot_overlaps()
{
    var existing = new[] { new TimeSlot(DayOfWeek.Wednesday, new(18, 0), new(19, 30)) };
    var newSlot  = new TimeSlot(DayOfWeek.Wednesday, new(18, 30), new(20, 0));

    Assert.Throws<ScheduleConflictException>(() =>
        Group.Schedule(IvanovId, English101Id, newSlot, existing));
}

Никакой in-memory БД, никаких моков, никакого SaveChanges. Unit-тест на правило – чистая функция: его может написать любой участник команды, не только тот, кто знает persistence-слой. Это меняет и сам темп тестирования: правило получает тест в тот же день, когда его формулируют, а не через спринт, когда наконец настроена тестовая БД. На том проекте инцидент с Ивановым случился до того, как мы дошли до этого модуля. Когда дошли, первое, что появилось – именно этот тест. Из самой истории, не из чьей-то выдумки про возможное пересечение.

В БД, куда сохраняется агрегат, по-прежнему лежит та же таблица groups, с теми же шестью полями. Persist превращает Group обратно в GroupRow. Структуру таблицы не меняли. ACL не означает новую базу – ACL означает, что выше шва правила существуют, а ниже шва о них никто не знает.

Write-side ACL – место, куда переезжают подразумеваемые правила. Шов проходит там, где раньше держалась дисциплина – она перестаёт быть устной.

Три шва на одной картинке

Три шва на одной картинке. Каждый стоит там, где legacy-форма ещё может появляться, а доменная уже не должна. Пунктир – сам шов: маппер, парсер, фасад с агрегатом. БД под обоими слоями одна и та же – ACL не означает новую базу.
Три шва на одной картинке. Каждый стоит там, где legacy-форма ещё может появляться, а доменная уже не должна. Пунктир – сам шов: маппер, парсер, фасад с агрегатом. БД под обоими слоями одна и та же – ACL не означает новую базу.

Что получилось

Три шва оказались в разных местах: типизирующий маппер на read-path, парсер на read-path другого ранга, фабрика-агрегат с фасадом на write-path. Объединяет их одно – каждый стоит в той точке, где legacy-форма ещё может появляться, а доменная уже не должна.

Свести ACL в один цельный слой, как на книжных схемах, не получилось. И я не встречал, чтобы это удалось кому-то на живом проекте. Получается всегда множество мелких швов, каждый в своей точке боли. Кто-то закрывает switch на nullable-полях, кто-то – зоопарк JSONB, кто-то – правило, которое жило в эксельке методиста. У всех трёх швов есть одно общее свойство, не зашитое в паттерн, но обнаруженное на практике: каждый из них стал самым лёгким для тестирования местом в своей области. Тип, маппер, фабрика – их можно проверить без in-memory БД и без моков, потому что в них живёт только форма и правило, ничего больше. Это и есть признание того, что границу домена нельзя нарисовать заранее – её можно только проводить по мере того, как domain знание прорастает в коде.

Что почитать

  • Eric Evans – “Domain-Driven Design” (2003), глава 14 “Maintaining Model Integrity”. Откуда взят anti-corruption layer как понятие: глава про границы контекстов, ACL там – способ держать чужую модель снаружи. То, что на живом проекте он распадается на несколько мелких швов, в книге не оговорено – это уже из практики.

  • Michael Feathers – “Working Effectively with Legacy Code” (2004). Про швы (seams) как таковые: место в коде, где можно подменить поведение, не переписывая всё вокруг. Слово “шов” в этом тексте я заимствовал оттуда.

  • Sam Newman – “Monolith to Microservices” (2019). Strangler Fig и вытеснение монолита по эндпоинту – тот самый режим миграции, в котором эти три шва и понадобились.

Комментарии (1)


  1. miharionov
    09.06.2026 05:53

    На практике ACL почти никогда не выглядит, как аккуратная коробка из учебника. Это не один слой, а несколько точек, где ты буквально отрезаешь легаси-хаос от домена2 где-то маппер, где-то парсер JSON, где-то правило в агрегате. И смысл не в красоте, а в том, чтобы этот мусор не расползался по коду, собрал в одном месте, перевёл в нормальные типы и дальше работаешь уже с понятной моделью, а не с наследием 10 лет боли.