Перейти к содержимому

Стили интеграции

Как системы обмениваются данными: синхронно или через сообщения и файлы, по какому протоколу, как пережить сбои соседа и не создать дубли и как подключить ERP, учётные системы и EDI-провайдеров. На собеседовании здесь проверяют не знание API, а умение объяснить, чем платишь за каждый выбор.

Перед этой темой: Web API.

Junior+

Чем синхронная интеграция отличается от асинхронной и когда какую выбрать?

Заголовок раздела «Чем синхронная интеграция отличается от асинхронной и когда какую выбрать?»
Ответ

Что это

  • Синхронная интеграция — вызывающий отправляет запрос и ждёт ответа: REST, gRPC, SOAP.
  • Асинхронная интеграция — отправитель кладёт сообщение в очередь или файл в папку обмена и продолжает работу. Получатель обработает его позже.

Когда что выбирать

Ситуация Стиль
Ответ нужен сейчас: цена, остаток, авторизация платежа синхронно
Данные не должны потеряться, ответ не срочен: заказы, накладные асинхронно
Большие пакеты раз в день: справочники, выгрузки в учёт файлы или пакетная загрузка

Частая ошибка

Интегрироваться через общую базу: читать и писать прямо в таблицы чужой системы. Переименование колонки у соседа ломает ваш код, а его бизнес-правила обходятся.

Как устроено

Стиль Чем связаны системы Главный минус
Файловый обмен форматом файла и расписанием данные устаревают до следующей выгрузки
Общая база схемой таблиц схему нельзя менять без соседей
Удалённый вызов (REST, gRPC, SOAP) временем: оба должны работать отказ получателя — отказ вызывающего
Сообщения через брокер форматом сообщения задержка, дубли, порядок

Когда что выбирать

  • Пользователь ждёт ответа, операция короткая — синхронный вызов с таймаутом.
  • Операция долгая, но клиенту нужен результат — асинхронный запрос-ответ: сервер сразу отвечает 202 Accepted с адресом, по которому клиент узнаёт статус.
  • Событие интересно нескольким системам — сообщение в брокер, а не вызов каждой.

Ловушки

  • Таймаут — не отказ. Если ответ не пришёл, неизвестно, выполнилась ли операция. Повтор без защиты создаёт дубль.
  • Асинхронно — не значит быстрее. Сообщение может лежать в очереди минуты, поэтому за глубиной очереди следят.
  • Недописанный файл. Получатель забирает файл, пока его ещё пишут. Файл пишут под временным именем и переименовывают в конце.

Правило

По умолчанию между системами — сообщения или API, общая база — никогда. Синхронно — только там, где без ответа нельзя продолжить.

Под капотом

  • Синхронный вызов держит ресурсы вызывающего — поток, соединение, транзакцию — всё время ожидания. Медленный сосед исчерпывает их и превращает чужую задержку в ваш отказ.
  • Брокер хранит сообщение, пока получатель его не подтвердит. Отказ получателя становится задержкой, а не ошибкой у отправителя.
  • Общая база связывает системы сильнее всего: сосед зависит от схемы, блокировок, индексов и даже от вашего окна обслуживания.

Цена

  • Доступность синхронной цепочки — произведение доступностей звеньев: 0,999⁵ ≈ 0,995, то есть почти 44 часа простоя в год вместо 9.
  • Задержки цепочки складываются, а хвост задержки определяет самый медленный участник.
  • Асинхронность покупается согласованностью в конечном счёте (eventual consistency): какое-то время системы видят разные данные.

Компромиссы

  • Синхронный вызов проще писать, отлаживать и объяснять пользователю: ошибка видна сразу.
  • Асинхронный обмен требует корреляции запросов и ответов, обработки дублей, мониторинга очередей и «застрявших» сообщений. Брокеры и гарантии доставки — тема «Очереди и Kafka».
  • Файлы до сих пор нормальный выбор для больших пакетов и партнёров без API, но нужна договорённость о формате, именах и квитанциях.

Как проверить в проде

  • Для синхронных зависимостей — p99 и доля ошибок исходящих вызовов, метрика http.client.request.duration.
  • Для асинхронных — глубина очереди, возраст самого старого сообщения и число сообщений в очереди ошибок.

Итог: синхронная интеграция переносит чужие сбои к вам сразу, асинхронная — превращает их в задержку; общая база превращает чужую схему в ваш контракт.

Пример
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(Channel.CreateUnbounded<string>());
builder.Services.AddHostedService<ExportWorker>();
var app = builder.Build();
// Синхронно: ответ нужен сейчас, клиент ждёт
app.MapGet("/stock/{sku}", (string sku) => new { sku, quantity = 42 });
// Асинхронно: принять, поставить в очередь и сразу ответить 202 Accepted
app.MapPost("/exports/{docId}", async (string docId, Channel<string> queue) =>
{
await queue.Writer.WriteAsync(docId);
return Results.Accepted($"/exports/{docId}"); // по этому адресу клиент узнает статус
});
app.Run();
// Получатель разбирает очередь в своём темпе.
// Канал в памяти теряет сообщения при перезапуске — в проде здесь брокер.
public class ExportWorker(Channel<string> queue, ILogger<ExportWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var docId in queue.Reader.ReadAllAsync(stoppingToken))
logger.LogInformation("Exported {DocId}", docId);
}
}
Middle+

REST, gRPC или SOAP: чем отличаются и что когда выбрать?

Заголовок раздела «REST, gRPC или SOAP: чем отличаются и что когда выбрать?»
Ответ

Что это

  • REST — URL обозначает ресурс, HTTP-метод — действие, тело обычно JSON. Подробно — в теме Web API.
  • gRPC — клиент вызывает метод удалённого сервиса как обычный метод. Контракт описан в файле .proto, по нему генерируются классы сервера и клиента.
  • SOAP — запрос и ответ — XML-конверт (envelope), контракт описан в WSDL. Так работают сервисы WCF и многие корпоративные системы.

Как пользоваться

  • REST — контроллеры или Minimal API, клиент — HttpClient.
  • gRPC — пакет Grpc.AspNetCore, файл .proto, AddGrpc() и MapGrpcService<T>().
  • SOAP-клиент — dotnet-svcutil генерирует прокси по WSDL.

Частая ошибка

Выбрать gRPC для API, которое вызывает браузер. Браузер не умеет вызывать gRPC напрямую, понадобится gRPC-Web или прокси.

Как устроено

Стиль Контракт и формат Сильная сторона
REST OpenAPI (необязательно), JSON понятен всем, работает в браузере
gRPC .proto (обязательно), Protobuf, HTTP/2 скорость, генерация клиента, потоки
SOAP WSDL и XSD, XML строгая схема, стандарты WS-*

У gRPC четыре вида вызовов: unary (запрос-ответ), потоковый от сервера, потоковый от клиента и двунаправленный.

Когда что выбирать

Признак gRPC REST
Клиент — браузер только через gRPC-Web да
Внутренние вызовы с высокой нагрузкой да можно, но тяжелее
Потоки в реальном времени да частично: SSE, WebSocket
Публичный API для партнёров редко да

Ловушки

  • Дедлайн. У gRPC-вызова нет дедлайна по умолчанию: без deadline вызов может ждать бесконечно.
  • Номера полей. В .proto поле определяется номером. Сменить номер или переиспользовать удалённый — сломать совместимость.
  • WCF в .NET Core. Клиентские пакеты System.ServiceModel.Http, System.ServiceModel.NetTcp поддерживают только подмножество возможностей WCF из .NET Framework, часть WS-* недоступна.

Правило

Внешний и браузерный API — REST, внутренний поток вызовов — gRPC, SOAP — только на стыке с тем, кто его требует.

Под капотом

  • gRPC-вызов — отдельный поток (stream) HTTP/2. Сообщения идут с префиксом длины, а статус вызова — в trailers (grpc-status), поэтому прокси должен пропускать HTTP/2 целиком.
  • Одно HTTP/2-соединение мультиплексирует много вызовов. За L4-балансировщиком все вызовы клиента уйдут на один сервер — нужен L7-балансировщик или балансировка на клиенте.
  • Protobuf кодирует поле номером и типом. Неизвестные поля старый клиент пропускает, поэтому добавление поля совместимо, а смена номера — нет.

Цена

  • Бинарный Protobuf не прочитать в логе или Fiddler без схемы.
  • gRPC-Web в браузере поддерживает только unary и потоки от сервера.
  • SOAP — самый тяжёлый: XML-конверт, пространства имён, иногда WS-Security с подписью каждого сообщения.

Компромиссы

  • gRPC JSON transcoding (.NET 7+) публикует gRPC-сервис ещё и как REST по аннотациям в .proto: один код, два протокола.
  • CoreWCF — порт серверной части WCF от сообщества .NET Foundation с поддержкой Microsoft. Он закрывает перенос, но не всё: распределённые транзакции и часть WS-* не поддерживаются.
  • Клиенты к чужим SOAP-сервисам генерируют dotnet-svcutil; актуальная версия пакетов System.ServiceModel.* — 10.x, вышла вместе с .NET 10.

Как проверить в проде

  • dotnet-counters показывает счётчики Grpc.AspNetCore.Server и Grpc.Net.Client: число вызовов, ошибок и превышений дедлайна.
  • В трассировке вызовы gRPC помечены тегами grpc.method и grpc.status_code, а у SOAP разбирают fault-ответы: ошибка приходит как <Fault> в теле.

Итог: gRPC — производительность и строгий контракт ценой браузера и читаемости, REST — универсальность, SOAP — совместимость; CoreWCF — мост для переноса, а не выбор для нового сервиса.

gRPC: контракт и сервис
syntax = "proto3";
option csharp_namespace = "Stock";
service StockService {
rpc GetBalance (BalanceRequest) returns (BalanceReply); // unary
rpc WatchBalances (WatchRequest) returns (stream BalanceReply); // поток от сервера
}
message BalanceRequest { string sku = 1; int32 store_id = 2; }
message BalanceReply { string sku = 1; int32 quantity = 2; }
message WatchRequest { int32 store_id = 1; }
Grpc.AspNetCore
using Grpc.Core;
using Stock; // классы сгенерированы из .proto при сборке
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
app.MapGrpcService<StockGrpcService>();
app.Run();
public class StockGrpcService : StockService.StockServiceBase
{
public override Task<BalanceReply> GetBalance(BalanceRequest request, ServerCallContext context) =>
Task.FromResult(new BalanceReply { Sku = request.Sku, Quantity = 42 });
}

Клиент — фрагмент: всегда с дедлайном.

Grpc.Net.Client
using Grpc.Core;
using Grpc.Net.Client;
using Stock;
using var channel = GrpcChannel.ForAddress("https://stock.example.com");
var client = new StockService.StockServiceClient(channel);
try
{
var reply = await client.GetBalanceAsync(
new BalanceRequest { Sku = "SKU-1", StoreId = 17 },
deadline: DateTime.UtcNow.AddSeconds(2)); // без дедлайна вызов ждёт бесконечно
Console.WriteLine(reply.Quantity); // 42
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
Console.WriteLine("Stock service timeout");
}
SOAP-клиент без сгенерированного прокси
System.ServiceModel.Http
using System.ServiceModel;
var binding = new BasicHttpBinding(BasicHttpSecurityMode.Transport);
var address = new EndpointAddress("https://erp.example.com/InvoiceService.svc");
var factory = new ChannelFactory<IInvoiceService>(binding, address);
IInvoiceService client = factory.CreateChannel();
string status = await client.GetStatusAsync("INV-1001"); // XML-конверт уходит по HTTPS
Console.WriteLine(status);
((IClientChannel)client).Close();
factory.Close();
// Контракт повторяет WSDL; обычно его генерирует dotnet-svcutil
[ServiceContract]
public interface IInvoiceService
{
[OperationContract]
Task<string> GetStatusAsync(string invoiceNumber);
}
Middle+

Как сделать вызов внешнего сервиса устойчивым?

Заголовок раздела «Как сделать вызов внешнего сервиса устойчивым?»
Ответ

Что это

Временный сбой — ошибка, которая пройдёт сама: обрыв сети, перезапуск сервиса, ответ 503. Устойчивый вызов переживает такие сбои и не зависает, если сосед упал надолго.

Как пользоваться

  1. Регистрируйте клиента через IHttpClientFactory: builder.Services.AddHttpClient<ErpClient>(...).
  2. Добавьте пакет Microsoft.Extensions.Http.Resilience и вызовите .AddStandardResilienceHandler().
  3. Передавайте CancellationToken во все вызовы, чтобы отмена запроса останавливала и исходящий вызов.

Частая ошибка

Повторять запрос в цикле без паузы и без ограничения. Если сосед перегружен, такие повторы добивают его окончательно.

Как устроено

AddStandardResilienceHandler выстраивает пять стратегий, от внешней к внутренней:

Стратегия Что делает По умолчанию
Rate limiter ограничивает одновременные запросы 1000, без очереди
Total timeout лимит на вызов со всеми попытками 30 с
Retry повтор при 5xx, 408, 429 и сетевых ошибках 3 раза, экспонента от 2 с, jitter
Circuit breaker размыкается при доле ошибок 10% при 100+ запросах за 30 с, пауза 5 с
Attempt timeout лимит на одну попытку 10 с

Circuit breaker — автомат с тремя состояниями: закрыт (вызовы идут), разомкнут (вызовы сразу отклоняются), полуоткрыт (пробный вызов решает, закрыться ли).

Когда что выбирать

  • Любой HTTP-клиент — стандартный обработчик, параметры подстраивают под SLA соседа.
  • Нужен запасной ответ: кеш, значение по умолчанию — свой конвейер через AddResilienceHandler со стратегией fallback.
  • Вызов не через HttpClient, например WCF или SFTP, — Polly v8 напрямую: ResiliencePipelineBuilder.

Ловушки

  • Повтор POST. Стандартный обработчик повторяет все методы. Для неидемпотентных — options.Retry.DisableForUnsafeHttpMethods() или ключ идемпотентности.
  • Несколько обработчиков. Два resilience-обработчика на одном клиенте повторяют друг друга. Документация советует один.
  • HttpClient.Timeout. Он по умолчанию 100 с и действует поверх конвейера; его делают не меньше общего таймаута.

Правило

Таймаут — всегда, повтор — только идемпотентного и с jitter, circuit breaker — на каждую внешнюю зависимость.

Под капотом

  • Обработчик — DelegatingHandler в цепочке HttpClient, внутри — конвейер Polly v8. Стратегия, добавленная первой, — внешняя.
  • Таймаут в Polly v8 кооперативный: отменяет CancellationToken, и код должен его уважать. При срабатывании — TimeoutRejectedException.
  • Circuit breaker в Polly v8 считает долю ошибок за окно (FailureRatio, MinimumThroughput, SamplingDuration). Классического счётчика подряд идущих ошибок в v8 нет. Bulkhead из Polly v7 заменён ограничителем конкурентности (AddConcurrencyLimiter, пакет Polly.RateLimiting).

Цена

  • Каждый повтор — дополнительная нагрузка на соседа, который и так не справляется. Повторы на нескольких уровнях перемножаются.
  • Повторы удлиняют ответ: три попытки с паузами 2, 4 и 8 с вместе с таймаутами легко превышают терпение пользователя. Общий таймаут ограничивает это.
  • Разомкнутый circuit breaker — быстрый отказ, который надо обработать: ответ 503, fallback или сообщение в очередь на потом.

Компромиссы

  • Где повторять. На одном уровне — обычно ближе всего к вызову. Сервис посередине цепочки ошибку пробрасывает, а не повторяет.
  • Hedging. AddStandardHedgingHandler шлёт параллельную копию медленного запроса и берёт первый ответ. Это снижает хвост задержки ценой лишней нагрузки, и только для идемпотентных операций.
  • Retry-After. Стандартный повтор учитывает этот заголовок в ответах 429 и 503: сосед сам говорит, когда возвращаться.

Как проверить в проде

  • Polly публикует метрику resilience.polly.strategy.events и логи событий: повторы, срабатывания таймаутов, размыкания breaker.
  • Сравнивают число исходящих попыток с числом логических вызовов: их отношение показывает долю повторов. Рост этой доли — первый признак проблем у соседа.

Итог: устойчивость — это бюджет времени, ограниченные повторы на одном уровне и быстрый отказ при больном соседе; без идемпотентности повторы превращаются в дубли.

Пример
Microsoft.Extensions.Http.Resilience
using Microsoft.Extensions.Http.Resilience;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<ErpClient>(c => c.BaseAddress = new Uri("https://erp.example.com"))
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5); // одна попытка
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(20); // все попытки вместе
options.Retry.MaxRetryAttempts = 3; // экспонента и jitter включены по умолчанию
options.Retry.DisableForUnsafeHttpMethods(); // POST, PUT, PATCH, DELETE не повторяем
});
var app = builder.Build();
app.MapGet("/orders/{id}/erp-status", (string id, ErpClient erp, CancellationToken ct) =>
erp.GetOrderStatusAsync(id, ct));
app.Run();
public class ErpClient(HttpClient http)
{
public Task<string> GetOrderStatusAsync(string id, CancellationToken ct) =>
http.GetStringAsync($"/orders/{id}/status", ct);
}
Polly v8 для вызова не через HttpClient
Polly.Core
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
var pipeline = new ResiliencePipelineBuilder()
.AddTimeout(TimeSpan.FromSeconds(30)) // внешняя: бюджет на все попытки
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential, // 1 с, 2 с, 4 с
UseJitter = true, // разброс, чтобы клиенты не били хором
Delay = TimeSpan.FromSeconds(1)
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5, MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(15)
})
.AddTimeout(TimeSpan.FromSeconds(5)) // внутренняя: одна попытка
.Build();
var status = await pipeline.ExecuteAsync(async ct => await GetInvoiceStatusAsync("INV-1001", ct));
Console.WriteLine(status); // Accepted
static async Task<string> GetInvoiceStatusAsync(string number, CancellationToken ct)
{
await Task.Delay(100, ct); // здесь вызов SOAP-сервиса или SFTP
return "Accepted";
}
Middle+

Что такое идемпотентность и как обеспечить её в интеграциях?

Заголовок раздела «Что такое идемпотентность и как обеспечить её в интеграциях?»
Ответ

Что это

Идемпотентная операция — та, которую можно выполнить несколько раз с тем же результатом, что и один раз.

Операция Идемпотентна?
Установить статус «Отгружено» да
Удалить заказ 42 да
Прибавить 10 к остатку нет
Создать заказ без ключа нет: каждый повтор — новый заказ

Зачем

Сеть ненадёжна. Если ответ потерялся, отправитель не знает, выполнилась ли операция, и повторяет её. Брокер после сбоя тоже доставит сообщение ещё раз.

Как пользоваться

Получатель хранит id обработанных сообщений. Если id уже есть — сообщение подтверждают и пропускают.

Частая ошибка

Считать, что повторов «не бывает». Они случаются при каждом сбое, а дубль накладной или списания находят только при сверке.

Как устроено

Способ Как работает Пример
Естественная идемпотентность операция задаёт состояние, а не меняет его UPDATE ... SET status = 'Shipped', upsert по номеру документа
Дедупликация по id сообщения таблица обработанных id с уникальным ключом обработчик сообщений из брокера
Ключ идемпотентности клиент присылает ключ, сервер хранит результат первой обработки POST создания платежа

Как работает Idempotency-Key в HTTP API, разобрано в теме Web API.

Когда что выбирать

  • Можно переписать операцию как «установить» — это самый дешёвый путь, хранилище не нужно.
  • Обработчик сообщений — дедупликация по id сообщения или бизнес-ключу документа.
  • Создание сущности или платёж по API — ключ идемпотентности и сохранённый ответ.

Ловушки

  • Проверка и запись раздельно. «Проверил, что ключа нет, потом обработал» — два параллельных повтора пройдут оба. Нужна атомарная вставка ключа с уникальным индексом.
  • Метка до эффекта. Если сначала записать «обработано», а потом упасть, сообщение потеряется. Метка и эффект — в одной транзакции.
  • Ключ без содержимого. Тот же ключ с другим телом запроса — ошибка клиента, а не повтор. Сервер сравнивает хеш тела и отвечает ошибкой.

Правило

Каждый обработчик входящих данных проектируют так, будто каждое сообщение придёт дважды.

Под капотом

  • Надёжная дедупликация — уникальный индекс: INSERT ключа в той же транзакции, что и бизнес-изменение. Конфликт ключа — признак дубля, транзакцию откатывают, а сообщение подтверждают.
  • Для HTTP-ключа хранят состояние «в обработке» и готовый ответ. Повтор во время обработки получает 409, после — сохранённый ответ.
  • Таблица обработанных сообщений у получателя — часть паттерна inbox; вместе с outbox его разбирают в теме «Очереди и Kafka».

Цена

  • Хранилище ключей растёт с трафиком: нужен срок хранения (TTL) и регулярная очистка.
  • Срок хранения ключа должен быть больше окна повторов отправителя. Если партнёр может перезагрузить файл через неделю, ключ, удалённый через сутки, не спасёт.
  • Ключ в той же базе, что и данные, удобен для транзакции, но нагружает её при большом трафике.

Компромиссы

  • Ключ из id сообщения или из бизнеса. Id сообщения ловит технические повторы, но не повторную отправку того же документа с новым id. Бизнес-ключ — номер документа и отправитель — ловит оба.
  • Порядок. Дедупликация не спасает от старого сообщения, пришедшего после нового. Изменения состояния несут версию, и получатель применяет только более новую.
  • Внешние эффекты. Отправку SMS или платёж не откатить транзакцией. Ключ передают провайдеру, если он его поддерживает, или ведут журнал отправленного и сверяют его.

Как проверить в проде

  • Считают срабатывания дедупликации: рост доли дублей показывает проблемы у отправителя или брокера.
  • Периодический запрос ищет дубли по бизнес-ключу — номеру документа, внешнему id — в целевых таблицах.

Итог: идемпотентность держится на атомарном уникальном ключе, разумном сроке его хранения и версиях против неправильного порядка.

Пример
var handler = new PriceHandler();
var msg = new PriceChanged("m-1", "SKU-1", 49.90m, Version: 7);
Console.WriteLine(handler.Handle(msg)); // Applied
Console.WriteLine(handler.Handle(msg)); // Duplicate: то же сообщение пришло повторно
Console.WriteLine(handler.Handle(msg with { MessageId = "m-0", Price = 45m, Version = 6 })); // Stale
public record PriceChanged(string MessageId, string Sku, decimal Price, long Version);
public class PriceHandler
{
// В проде — таблицы с уникальным ключом, обновляемые в одной транзакции
private readonly HashSet<string> _processed = [];
private readonly Dictionary<string, (decimal Price, long Version)> _prices = [];
public string Handle(PriceChanged m)
{
if (!_processed.Add(m.MessageId)) return "Duplicate"; // дедупликация по id
if (_prices.TryGetValue(m.Sku, out var current) && current.Version >= m.Version)
return "Stale"; // старая версия пришла позже новой
_prices[m.Sku] = (m.Price, m.Version); // «установить», а не «прибавить»
return "Applied";
}
}
Senior+

Как интегрироваться с внешними и legacy-системами (ERP, учётные системы, EDI-провайдеры)?

Заголовок раздела «Как интегрироваться с внешними и legacy-системами (ERP, учётные системы, EDI-провайдеры)?»
Ответ

Что это

  • Legacy- и внешние системы — ERP, учётные системы, системы партнёров. Их нельзя поменять, у них свои форматы и ограничения.
  • EDI (electronic data interchange) — обмен стандартными электронными документами между компаниями: заказ, уведомление об отгрузке, счёт. Обычно через EDI-провайдера.

Как пользоваться

  1. Сделайте адаптер — отдельный класс или сервис, который единственный знает формат внешней системы.
  2. Адаптер переводит чужие данные в ваши классы и обратно.
  3. Каждый входящий документ сохраните как есть, до разбора.

Частая ошибка

Протащить чужую модель в свой код: поля DOC_NO, RCV_FLAG и коды ERP расползаются по всей системе, и замена ERP превращается в переписывание.

Как устроено

  • Anti-corruption layer (ACL). Слой между доменом и внешней системой: переводит модели, коды, единицы измерения и ошибки. Домен говорит на своём языке.
  • Транспорт. API или SOAP для онлайн-запросов; файлы через SFTP или общую папку для пакетов; AS2 — HTTP с подписью, шифрованием и квитанцией MDN, классика EDI.
  • Форматы EDI. UN/EDIFACT — сообщения ORDERS (заказ), DESADV (уведомление об отгрузке), RECADV (приёмка), INVOIC (счёт). Многие провайдеры работают и с XML-форматами.

Когда что выбирать

Ситуация Подход
Одна-две внешние системы адаптер точка-точка
Много партнёров с одинаковыми документами провайдер EDI и одна внутренняя модель документа
Справочники и большие объёмы раз в сутки пакетная загрузка файлов через staging

Ловушки

  • Нет квитанции — нет доставки. Файл, положенный на SFTP, ещё не обработан. Ждут подтверждения: MDN, CONTRL, ответный статус.
  • Повторная загрузка. Тот же файл пришёл дважды — загрузку делают идемпотентной по номеру документа и отправителю.
  • Тихие ошибки разбора. Документ, который не разобрался, должен попасть в очередь ошибок с алертом, а не в лог.

Правило

Сырое — сохранить, перевести — в адаптере, состояние — отслеживать, расхождения — ловить сверкой.

Под капотом

  • Каждый документ — конечный автомат: получен, разобран, загружен, подтверждён или ошибка. Статус и корреляционный id хранят в журнале обмена.
  • Входящий файл сохраняют в архив до разбора. После исправления маппинга документ переобрабатывают из архива, не прося партнёра прислать его снова.
  • Сверка (reconciliation) — периодическое сравнение двух сторон: количество, суммы и номера документов за период. Она ловит то, что пропустили квитанции.

Цена

  • Точка-точка: n систем, связанных каждая с каждой, — до n(n−1)/2 интеграций, и у каждой свой маппинг.
  • Шина сокращает число связей до одного адаптера на систему, но добавляет центральный компонент, его лицензию, отдельную команду и единую точку отказа.
  • Каноническая модель снижает число маппингов, но её изменение затрагивает всех участников.

Компромиссы

  • ESB или «умные концы, глупые трубы». BizTalk — хаб с адаптерами, маршрутизацией, оркестрациями и встроенной поддержкой EDI и AS2. Современный подход: брокер только доставляет, а логика перевода живёт в небольших сервисах-адаптерах.
  • Свой EDI или провайдер. Провайдер берёт на себя транспорт, форматы конкретных сетей и квитанции. Вы платите зависимостью и форматом провайдера.
  • Постепенная замена legacy — тема «Архитектурные стили».

Как проверить в проде

  • Алерт на документы без квитанции дольше SLA и на рост очереди ошибок разбора.
  • Ежедневная сверка с отчётом расхождений: отправлено и получено, суммы по счетам, документы без ответа.

Итог: надёжная интеграция с legacy держится на изоляции модели, журнале статусов, архиве для повторной обработки и сверке, а шина — компромисс между числом связей и централизацией.

Пример
using System.Globalization;
var row = new ErpShipmentRow("0000123456", "20261009", "Y", "4820000000017;12|4820000000024;6");
var delivery = ErpShipmentTranslator.ToDomain(row);
Console.WriteLine($"{delivery.Number}: {delivery.Lines.Count} lines, received={delivery.IsReceived}");
// 123456: 2 lines, received=True
// Модель внешней системы — как есть, со всеми её особенностями
public record ErpShipmentRow(string DocNo, string DocDate, string RcvFlag, string Lines);
// Своя модель
public record Delivery(long Number, DateOnly Date, bool IsReceived, IReadOnlyList<DeliveryLine> Lines);
public record DeliveryLine(string Gtin, int Quantity);
// Anti-corruption layer: перевод в одном месте, домен не знает о форматах ERP
public static class ErpShipmentTranslator
{
public static Delivery ToDomain(ErpShipmentRow row) => new(
long.Parse(row.DocNo, CultureInfo.InvariantCulture),
DateOnly.ParseExact(row.DocDate, "yyyyMMdd", CultureInfo.InvariantCulture),
row.RcvFlag == "Y",
row.Lines.Split('|')
.Select(line => line.Split(';'))
.Select(p => new DeliveryLine(p[0], int.Parse(p[1], CultureInfo.InvariantCulture)))
.ToList());
}
Сверка: документы без квитанции
-- Отправленные документы, на которые за 4 часа не пришла квитанция
SELECT d.doc_number, d.partner_id, d.sent_at
FROM edi_outbound AS d
LEFT JOIN edi_ack AS a ON a.doc_id = d.id
WHERE a.doc_id IS NULL
AND d.sent_at < now() - interval '4 hours'
ORDER BY d.sent_at;