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_v2 – kind, который кто-то добавил полгода назад под клиента с другим набором метаданных, обновив форму на фронте, но не оповестив нас. На странице теперь рендерился пустой div.
Валидация упала на mc_polyglot – kind, который добавили три года назад под одного крупного клиента и так и забыли. В 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-команда завела пятнадцатый kind – mc_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 это была одна форма в админке методиста: выбрать курс, преподавателя, день недели, время начала, время конца, нажать “создать”. Всё это превращалось в одну строчку в таблице groups – teacher_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 – место, куда переезжают подразумеваемые правила. Шов проходит там, где раньше держалась дисциплина – она перестаёт быть устной.
Три шва на одной картинке

Что получилось
Три шва оказались в разных местах: типизирующий маппер на 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 и вытеснение монолита по эндпоинту – тот самый режим миграции, в котором эти три шва и понадобились.
miharionov
На практике ACL почти никогда не выглядит, как аккуратная коробка из учебника. Это не один слой, а несколько точек, где ты буквально отрезаешь легаси-хаос от домена2 где-то маппер, где-то парсер JSON, где-то правило в агрегате. И смысл не в красоте, а в том, чтобы этот мусор не расползался по коду, собрал в одном месте, перевёл в нормальные типы и дальше работаешь уже с понятной моделью, а не с наследием 10 лет боли.