lenec ru

← все посты

Anti-corruption layer: как интегрироваться с legacy и не размазать его по своему сервису

15K

Когда новый сервис должен жить в одной экосистеме со старой системой, у разработчика обычно два варианта. Первый — взять формат данных и понятия легаси как есть, протащить их сквозь свой код и через год понять, что половина новых классов носит чужие имена и подчиняется чужим правилам. Второй — поставить на границе перевод с одного языка на другой, чтобы внутри сервиса жили твои собственные понятия, а наружу шёл диалект, понятный соседу. Этот второй вариант в 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 — не отдельный архитектурный шаблон, а гигиена. Он не делает вас быстрее, не даёт магии, но позволяет держать в голове свою модель, не путая её с чужой. На длинной дистанции это обычно решает, доживёт ли сервис до следующего рефакторинга или нет.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.