Anti-corruption layer: как интегрироваться с legacy и не размазать его по своему сервису
Когда новый сервис должен жить в одной экосистеме со старой системой, у разработчика обычно два варианта. Первый — взять формат данных и понятия легаси как есть, протащить их сквозь свой код и через год понять, что половина новых классов носит чужие имена и подчиняется чужим правилам. Второй — поставить на границе перевод с одного языка на другой, чтобы внутри сервиса жили твои собственные понятия, а наружу шёл диалект, понятный соседу. Этот второй вариант в DDD называют anti-corruption layer (ACL), и я применяю его почти на всех интеграциях, где legacy-систему нельзя переписать прямо сейчас.
Идея слышится умозрительной, пока не увидишь, во что превращается код без неё. Поэтому разберу, как ACL устроен на практике, где он избыточен, и какие типичные ошибки превращают его в очередной слой пересылки полей.
Зачем вообще отдельный слой
Главная причина — ваша модель и модель соседа описывают мир по-разному. У них «клиент» — это запись с десятью историческими полями, у вас — это аккаунт с подписками. У них статусы заказа кодируются числами 0..9 с забытым смыслом, у вас это явное состояние с понятным переходом. Если вы пускаете чужие термины внутрь, вы начинаете спорить о том, что значит «отменён», по два раза на каждом ревью.
ACL — это место, где чужой словарь превращается в ваш. Один раз, на границе, под контролем. Внутри сервиса дальше живёт только ваша модель, и решения принимаются в её терминах. Если завтра старую систему меняют — переписывается слой перевода, не половина проекта.
Вторая причина — устойчивость к изменениям соседа. Поведение легаси редко стабильно: то поле меняет тип, то добавляется флаг, то одно и то же значение начинает приходить в двух местах. Если ваш код общается с легаси напрямую, такие изменения протекают везде. ACL локализует протечку.
Когда ACL не нужен
Соседняя система может оказаться достаточно близкой по модели, чтобы перевод был лишним. Признаки того, что ACL избыточен:
- Чужой контракт стабилен и не меняется без согласования.
- Понятия совпадают: «заказ» там и тут означают одно и то же.
- Вы делаете тонкого посредника, и своей доменной логики у сервиса почти нет.
В таких случаях прослойка только добавляет код и затрудняет дебаг. Лучше явно сказать «у нас shared kernel» (если речь о DDD) или просто использовать общий клиент. ACL имеет смысл там, где у вас есть собственная модель, которую стоит защищать.
Как устроен ACL внутри
Базовая структура — три части.
- Adapter (или клиент). Знает, как говорить с легаси: HTTP, gRPC, очередь, прямые SQL-запросы. Возвращает чужие типы, ничего не знает о вашей модели.
- Translator. Превращает чужие типы в ваши и обратно. Здесь живёт логика «у них поле
cust_typ=2означает нашеCustomerKind.Vip». Это самое важное и самое скучное место. - Facade (порт). Интерфейс, который видит остальной код. Возвращает только ваши типы, скрывает все детали транспорта и перевода.
Минимальный пример на Kotlin, без обвязки и нюансов:
interface CustomerRepository {
fun findByEmail(email: String): Customer?
}
class LegacyCrmCustomerRepository(
private val client: LegacyCrmClient,
private val translator: LegacyCustomerTranslator,
) : CustomerRepository {
override fun findByEmail(email: String): Customer? {
val raw = client.searchByEmail(email) ?: return null
return translator.toDomain(raw)
}
}
class LegacyCustomerTranslator {
fun toDomain(raw: LegacyCustomerDto): Customer = Customer(
id = CustomerId(raw.cust_id.toString()),
email = raw.email_addr.lowercase(),
kind = when (raw.cust_typ) {
2 -> CustomerKind.Vip
1 -> CustomerKind.Regular
else -> CustomerKind.Unknown
},
)
}Снаружи код видит CustomerRepository и работает с Customer. О существовании cust_typ и email_addr знает только translator.
Перевод в обе стороны
Часто ACL рисуют как односторонний перевод чужих данных в свои. Это удобный, но неполный взгляд. Если вы не только читаете легаси, но и пишете в неё или отправляете команды, перевод нужен и в обратную сторону. Своя модель — наружу в их формат.
Тут вылезает несимметрия. Чужие данные обычно богаче, чем вам нужно: вы выбираете подмножество полей. А свои данные, наоборот, бывают беднее, чем требует легаси: приходится подбирать значения по умолчанию, заполнять обязательные поля, которых нет в вашей модели. Это не ошибка проектирования, это плата за интеграцию. Главное — фиксировать каждое такое значение в одном месте, не размазывая по всему коду.
fun toLegacy(order: Order): LegacyOrderDto = LegacyOrderDto(
ord_id = order.id.value,
ord_status = when (order.status) {
OrderStatus.Created -> 1
OrderStatus.Paid -> 3
OrderStatus.Cancelled -> 9
},
ord_channel = LEGACY_CHANNEL_DEFAULT, // в нашей модели нет, но требуется в легаси
cust_id = order.customerId.value.toLong(),
)Заметьте константу LEGACY_CHANNEL_DEFAULT. Она нужна потому, что легаси требует поле, у вас этого поля нет, и есть осмысленное значение по умолчанию. Лучше назвать её и положить в одно место, чем зашить «1» в десяти разных translator-ах.
Где ACL обычно протекает
Чтобы слой работал, он должен быть единственной точкой контакта. На практике этого добиваются не сразу.
- Чужие типы утекают в публичные методы. Кто-то для скорости возвращает
LegacyCustomerDtoпрямо в сервис. Через месяц десять других мест уже зависят от его полей. Лекарство — пакет с DTO легаси сделать internal, чтобы компилятор не дал утечь. - Логика просачивается в translator. Translator должен переводить, а не решать. Если в нём появляются проверки уровня бизнеса («если статус 9, то клиент VIP только для России»), вытаскивайте это в доменный сервис. Иначе translator превращается в скрытое место принятия решений.
- Несколько translator-ов в разных местах. На один и тот же чужой объект делают два перевода в разных модулях. Через год они расходятся, и где-то VIP — это
cust_typ=2, а где-тоcust_typ in (2, 4). Договоритесь на старте: один тип — один translator, на него все ссылаются. - Поведение зависит от чужих ошибок. Легаси возвращает странные коды или null в неожиданных местах. Если эти случаи всплывают как
NullPointerExceptionв доменном слое, ACL не справился. Все недоразумения должны быть вычищены или явно превращены в осмысленные исключения на границе.
Тестирование
Самое полезное тестирование ACL — не модульные тесты на translator (хотя они нужны), а контрактные тесты с легаси. Я записываю реальные ответы легаси на типичные запросы, кладу их как фикстуры и гоняю через адаптер и translator. Так находятся тонкие случаи: «оказывается, поле email иногда приходит с пробелом в конце», «в 0,1% случаев приходит несуществующий статус 14».
Сами фикстуры обновляются периодически, не раз в год. Легаси умеет тихо менять поведение, и без свежих фикстур вы об этом узнаете не на ревью, а в проде. Если есть возможность поднять локальный экземпляр или sandbox легаси — лучше тестировать против него, а не только против записей. Если нет — фикстуры с пометкой «снято такого-то числа из такого-то окружения».
Гранулярность: один ACL или несколько
Частый вопрос: делать один ACL на всю интеграцию с легаси или разные слои для разных доменных областей. Мой ответ — разные. Если ваш сервис ходит в легаси за «клиентами», «заказами» и «выставленными счетами», у каждой области свой translator и своя пара портов. Это утомляет на старте, зато позже даёт ровно то, ради чего ACL и придумывали: одну область можно поменять, не трогая другие.
Объединять имеет смысл только когда области сильно переплетены в самой легаси и разделить их означает дублировать вызовы. Тогда — один translator, но в нём явно отделены секции по доменам.
Эволюция: ACL как точка вытаскивания данных
Часто ACL становится отправной точкой постепенного отказа от легаси. Сначала он просто переводит, потом параллельно сохраняет переведённые данные в свою базу, потом начинает их обновлять локально и сверять с легаси, потом ваш сервис становится источником правды для своих данных, а легаси — потребителем. Это классический сценарий strangler fig pattern, в котором ACL играет роль изолирующего слоя.
Если такая эволюция в плане, проектируйте ACL с прицелом на неё. В частности, держите свои идентификаторы и не привязывайте бизнес-логику к id из легаси. Иначе при выходе из легаси вы будете ещё и переименовывать половину базы.
Чек-лист
- В публичном API сервиса нет чужих типов: только ваши.
- Перевод сосредоточен в одном пакете, у одного translator-а на сущность.
- Адаптер не делает решений — только запросы и ответы.
- Все «магические» значения в обратном переводе названы константами.
- Есть фикстуры с реальными ответами легаси, они обновляются раз в квартал.
- Каждый странный случай (null, неизвестный статус) явно обрабатывается на границе.
ACL — не отдельный архитектурный шаблон, а гигиена. Он не делает вас быстрее, не даёт магии, но позволяет держать в голове свою модель, не путая её с чужой. На длинной дистанции это обычно решает, доживёт ли сервис до следующего рефакторинга или нет.