Integration styles
How systems exchange data: synchronously or through messages and files, over which protocol, how to survive a neighbor’s failures without creating duplicates, and how to connect ERPs, accounting systems and EDI providers. Interviewers here check not API knowledge but whether you can explain what each choice costs.
Before this topic: Web API.
How does synchronous integration differ from asynchronous, and when do you pick each?
Section titled “How does synchronous integration differ from asynchronous, and when do you pick each?”What it is
- Synchronous integration — the caller sends a request and waits for the response: REST, gRPC, SOAP.
- Asynchronous integration — the sender puts a message into a queue or a file into an exchange folder and keeps working. The receiver processes it later.
When to choose what
| Situation | Style |
|---|---|
| Need the answer now: price, stock, payment authorization | synchronous |
| Data must not be lost, answer is not urgent: orders, invoices | asynchronous |
| Large daily batches: reference data, exports to accounting | files or batch load |
Common mistake
Integrating through a shared database: reading and writing another system’s tables directly. Renaming a column on their side breaks your code, and their business rules are bypassed.
Takeaway: synchronous when you need the answer now, asynchronous when the work must not be lost and must not depend on the receiver being up.
How it works
| Style | What couples the systems | Main drawback |
|---|---|---|
| File transfer | file format and schedule | data is stale until the next export |
| Shared database | table schema | schema cannot change without the neighbors |
| Remote call (REST, gRPC, SOAP) | time: both must be up | receiver down means caller down |
| Messages through a broker | message format | latency, duplicates, ordering |
When to choose what
- The user waits for an answer and the operation is short — a synchronous call with a timeout.
- The operation is long but the client needs the result — asynchronous request-reply: the server immediately returns 202 Accepted with a URL where the client checks the status.
- Several systems care about an event — publish a message to a broker instead of calling each one.
Pitfalls
- A timeout is not a failure. If no response came back, you don’t know whether the operation ran. A blind retry creates a duplicate.
- Asynchronous does not mean faster. A message can sit in a queue for minutes, so queue depth is monitored.
- Half-written files. The receiver picks up a file while it is still being written. Write under a temporary name and rename at the end.
Rule of thumb
Between systems, default to messages or APIs, never a shared database. Go synchronous only where you cannot proceed without the answer.
Takeaway: an integration style is a choice of which coupling to accept: in time, in schema or in latency.
Under the hood
- A synchronous call holds the caller’s resources — thread, connection, transaction — for the whole wait. A slow neighbor exhausts them and turns their latency into your outage.
- A broker keeps the message until the receiver acknowledges it. A receiver outage becomes latency rather than an error on the sender’s side.
- A shared database couples systems the most: the neighbor depends on your schema, locks, indexes and even your maintenance window.
Cost
- The availability of a synchronous chain is the product of its links: 0.999⁵ ≈ 0.995, which is almost 44 hours of downtime per year instead of 9.
- Latencies along the chain add up, and the slowest participant defines the tail.
- Asynchrony is paid for with eventual consistency: for a while, the systems see different data.
Trade-offs
- A synchronous call is easier to write, debug and explain to the user: the error shows up immediately.
- Asynchronous exchange needs request-reply correlation, duplicate handling, and monitoring of queues and stuck messages. Brokers and delivery guarantees are covered in Queues and Kafka.
- Files are still a sane choice for large batches and partners without an API, but you need agreements on format, naming and acknowledgements.
How to check in production
- For synchronous dependencies — p99 and error rate of outgoing calls, the
http.client.request.durationmetric. - For asynchronous ones — queue depth, age of the oldest message and the number of messages in the error queue.
Takeaway: synchronous integration brings other systems’ failures to you immediately, asynchronous turns them into latency; a shared database turns someone else’s schema into your contract.
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddSingleton(Channel.CreateUnbounded<string>());builder.Services.AddHostedService<ExportWorker>();
var app = builder.Build();
// Synchronous: the answer is needed now, the client waitsapp.MapGet("/stock/{sku}", (string sku) => new { sku, quantity = 42 });
// Asynchronous: accept, enqueue and return 202 Accepted right awayapp.MapPost("/exports/{docId}", async (string docId, Channel<string> queue) =>{ await queue.Writer.WriteAsync(docId); return Results.Accepted($"/exports/{docId}"); // the client checks the status at this URL});
app.Run();
// The receiver drains the queue at its own pace.// An in-memory channel loses messages on restart — in production this is a broker.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); }}- Why is a shared database a bad integration? — The schema becomes a contract with the neighbor: you cannot change it, and the data owner’s business rules are bypassed.
- What if an operation takes minutes and the client calls it over HTTP? — Asynchronous request-reply: 202 Accepted, a status URL, and polling or a callback.
- How does availability depend on the length of a synchronous chain? — It multiplies: every link lowers overall availability.
- What can go wrong with file transfer? — Half-written files, loading the same file twice, and lost files without an acknowledgement.
- Is asynchronous integration always better? — No: it adds latency, duplicates and harder debugging; where the answer is needed immediately, a synchronous call is more honest.
REST, gRPC or SOAP: how do they differ and when do you choose each?
Section titled “REST, gRPC or SOAP: how do they differ and when do you choose each?”What it is
- REST — a URL identifies a resource, the HTTP method is the action, the body is usually JSON. Details are in Web API.
- gRPC — the client calls a method of a remote service as if it were a local method. The contract lives in a
.protofile, and server and client classes are generated from it. - SOAP — request and response are XML envelopes, the contract is described in WSDL. WCF services and many enterprise systems work this way.
How to use
- REST — controllers or Minimal APIs,
HttpClienton the client side. - gRPC — the
Grpc.AspNetCorepackage, a.protofile,AddGrpc()andMapGrpcService<T>(). - SOAP client — dotnet-svcutil generates a proxy from the WSDL.
Common mistake
Choosing gRPC for an API called by a browser. Browsers cannot call gRPC directly, so you will need gRPC-Web or a proxy.
Takeaway: REST by default and for external clients, gRPC between your own services, SOAP when the other side demands it.
How it works
| Style | Contract and format | Strength |
|---|---|---|
| REST | OpenAPI (optional), JSON | understood by everyone, works in browsers |
| gRPC | .proto (required), Protobuf, HTTP/2 |
speed, generated clients, streaming |
| SOAP | WSDL and XSD, XML | strict schema, WS-* standards |
gRPC has four call types: unary (request-response), server streaming, client streaming and bidirectional streaming.
When to choose what
| Criterion | gRPC | REST |
|---|---|---|
| Browser client | only via gRPC-Web | yes |
| High-load internal calls | yes | possible, but heavier |
| Real-time streams | yes | partly: SSE, WebSocket |
| Public API for partners | rarely | yes |
Pitfalls
- Deadline. A gRPC call has no deadline by default: without
deadlineit can wait forever. - Field numbers. In
.protoa field is identified by its number. Changing a number or reusing a removed one breaks compatibility. - WCF on .NET Core. The client packages
System.ServiceModel.HttpandSystem.ServiceModel.NetTcpsupport only a subset of .NET Framework WCF, and part of WS-* is missing.
Rule of thumb
External and browser APIs — REST, internal call traffic — gRPC, SOAP — only at the boundary with whoever requires it.
Takeaway: pick the protocol by the client: browsers and partners get REST, your services get gRPC, legacy gets SOAP.
Under the hood
- A gRPC call is a separate HTTP/2 stream. Messages are length-prefixed and the call status travels in trailers (
grpc-status), so a proxy must pass HTTP/2 through end to end. - One HTTP/2 connection multiplexes many calls. Behind an L4 load balancer all of a client’s calls land on one server — you need an L7 balancer or client-side load balancing.
- Protobuf encodes a field by number and type. Old clients skip unknown fields, so adding a field is compatible but changing a number is not.
Cost
- Binary Protobuf cannot be read in a log or Fiddler without the schema.
- gRPC-Web in the browser supports only unary calls and server streaming.
- SOAP is the heaviest: XML envelopes, namespaces, sometimes WS-Security signing every message.
Trade-offs
- gRPC JSON transcoding (.NET 7+) also exposes a gRPC service as REST based on annotations in
.proto: one codebase, two protocols. - CoreWCF is a community port of WCF’s server side under the .NET Foundation, with Microsoft support. It covers migration but not everything: distributed transactions and part of WS-* are not supported.
- Clients for third-party SOAP services are generated with dotnet-svcutil; the current
System.ServiceModel.*packages are 10.x, released alongside .NET 10.
How to check in production
- dotnet-counters shows the
Grpc.AspNetCore.ServerandGrpc.Net.Clientcounters: calls, failures and deadline exceeded. - In traces gRPC calls carry the
grpc.methodandgrpc.status_codetags, and for SOAP you inspect fault responses: the error arrives as<Fault>in the body.
Takeaway: gRPC buys performance and a strict contract at the cost of browsers and readability, REST buys universality, SOAP buys compatibility; CoreWCF is a migration bridge, not a choice for a new service.
syntax = "proto3";option csharp_namespace = "Stock";
service StockService { rpc GetBalance (BalanceRequest) returns (BalanceReply); // unary rpc WatchBalances (WatchRequest) returns (stream BalanceReply); // server streaming}
message BalanceRequest { string sku = 1; int32 store_id = 2; }message BalanceReply { string sku = 1; int32 quantity = 2; }message WatchRequest { int32 store_id = 1; }using Grpc.Core;using Stock; // classes are generated from .proto at build time
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 });}Client fragment — always with a deadline.
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)); // without a deadline the call waits forever Console.WriteLine(reply.Quantity); // 42}catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded){ Console.WriteLine("Stock service timeout");}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"); // an XML envelope goes over HTTPSConsole.WriteLine(status);
((IClientChannel)client).Close();factory.Close();
// The contract mirrors the WSDL; usually dotnet-svcutil generates it[ServiceContract]public interface IInvoiceService{ [OperationContract] Task<string> GetStatusAsync(string invoiceNumber);}- Why can’t a browser call gRPC directly? — Browsers don’t expose control over HTTP/2 or access to trailers; you need gRPC-Web or JSON transcoding.
- What call types does gRPC have? — Unary, server streaming, client streaming and bidirectional streaming.
- What happens if you change a field number in
.proto? — Old clients read the field into the wrong place or drop it: it is a breaking change. - How do you run an old WCF service on .NET 10? — CoreWCF on the server; clients use the
System.ServiceModel.*packages and dotnet-svcutil. - Why does gRPC behind an L4 load balancer hit a single server? — All calls are multiplexed over one long-lived HTTP/2 connection.
How do you make a call to an external service resilient?
Section titled “How do you make a call to an external service resilient?”What it is
A transient fault is an error that goes away on its own: a network blip, a service restart, a 503 response. A resilient call survives such faults and does not hang when the neighbor is down for long.
How to use
- Register the client through
IHttpClientFactory:builder.Services.AddHttpClient<ErpClient>(...). - Add the
Microsoft.Extensions.Http.Resiliencepackage and call.AddStandardResilienceHandler(). - Pass a
CancellationTokento every call so that cancelling the request also stops the outgoing call.
Common mistake
Retrying a request in a loop with no pause and no limit. If the neighbor is overloaded, such retries finish it off.
Takeaway: every external call gets a timeout and bounded retries with pauses, never “whatever happens”.
How it works
AddStandardResilienceHandler chains five strategies, from outermost to innermost:
| Strategy | What it does | Defaults |
|---|---|---|
| Rate limiter | caps concurrent requests | 1000, no queue |
| Total timeout | limit for the call including all attempts | 30 s |
| Retry | retries on 5xx, 408, 429 and network errors | 3 times, exponential from 2 s, jitter |
| Circuit breaker | opens on a failure ratio | 10% over 100+ requests in 30 s, break 5 s |
| Attempt timeout | limit for a single attempt | 10 s |
A circuit breaker is a state machine with three states: closed (calls flow), open (calls are rejected immediately), half-open (a probe call decides whether to close).
When to choose what
- Any HTTP client — the standard handler, with parameters tuned to the neighbor’s SLA.
- You need a fallback answer such as a cache or a default — a custom pipeline via
AddResilienceHandlerwith a fallback strategy. - The call does not go through
HttpClient, for example WCF or SFTP — Polly v8 directly:ResiliencePipelineBuilder.
Pitfalls
- Retrying POST. The standard handler retries all methods. For non-idempotent ones use
options.Retry.DisableForUnsafeHttpMethods()or an idempotency key. - Several handlers. Two resilience handlers on one client retry each other. The docs recommend just one.
HttpClient.Timeout. It defaults to 100 s and applies on top of the pipeline; keep it no smaller than the total timeout.
Rule of thumb
Always a timeout, retries only for idempotent calls and with jitter, a circuit breaker for every external dependency.
Takeaway: the standard handler is a sane baseline; your job is to fit the timeouts to the neighbor and not retry what must not be retried.
Under the hood
- The handler is a
DelegatingHandlerin theHttpClientchain with a Polly v8 pipeline inside. The strategy added first is the outermost. - Timeouts in Polly v8 are cooperative: they cancel the
CancellationToken, and the code must honor it. When one fires you getTimeoutRejectedException. - The Polly v8 circuit breaker tracks a failure ratio over a window (
FailureRatio,MinimumThroughput,SamplingDuration). The classic consecutive-failure counter is gone in v8. The Polly v7 bulkhead is replaced by a concurrency limiter (AddConcurrencyLimiter, packagePolly.RateLimiting).
Cost
- Every retry is extra load on a neighbor that is already struggling. Retries at several layers multiply.
- Retries stretch the response: three attempts with 2, 4 and 8 s pauses plus timeouts easily exceed the user’s patience. The total timeout caps this.
- An open circuit breaker is a fast failure you still have to handle: a 503, a fallback, or a message queued for later.
Trade-offs
- Where to retry. At one layer, usually closest to the call. A service in the middle of a chain propagates the error instead of retrying.
- Hedging.
AddStandardHedgingHandlersends a parallel copy of a slow request and takes the first answer. It cuts tail latency at the cost of extra load, and only for idempotent operations. Retry-After. The standard retry honors this header on 429 and 503 responses: the neighbor tells you when to come back.
How to check in production
- Polly emits the
resilience.polly.strategy.eventsmetric and event logs: retries, timeouts, breaker openings. - Compare outgoing attempts with logical calls: the ratio shows the retry share. A rising share is the first sign of trouble at the neighbor.
Takeaway: resilience is a time budget, bounded retries at one layer and fast failure when the neighbor is sick; without idempotency, retries turn into duplicates.
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); // a single attempt options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(20); // all attempts together options.Retry.MaxRetryAttempts = 3; // exponential backoff and jitter are on by default options.Retry.DisableForUnsafeHttpMethods(); // POST, PUT, PATCH, DELETE are not retried });
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);}using Polly;using Polly.CircuitBreaker;using Polly.Retry;
var pipeline = new ResiliencePipelineBuilder() .AddTimeout(TimeSpan.FromSeconds(30)) // outer: budget for all attempts .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential, // 1 s, 2 s, 4 s UseJitter = true, // spread so clients don't retry in unison Delay = TimeSpan.FromSeconds(1) }) .AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.5, MinimumThroughput = 10, SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(15) }) .AddTimeout(TimeSpan.FromSeconds(5)) // inner: a single attempt .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); // a SOAP or SFTP call goes here return "Accepted";}- Why is retrying without jitter dangerous? — All clients retry at the same moments and hit the recovering service in a synchronized wave.
- How does an attempt timeout differ from a total timeout? — The first limits one attempt, the second the whole call including retries and pauses.
- What does a circuit breaker do when half-open? — It lets a probe call through: success closes the circuit, failure opens it again.
- Can you retry POST? — Only if the operation is idempotent, for example with an idempotency key; otherwise use
DisableForUnsafeHttpMethods. - What replaced the bulkhead in Polly v8? — The
AddConcurrencyLimiterconcurrency limiter from thePolly.RateLimitingpackage.
What is idempotency and how do you ensure it in integrations?
Section titled “What is idempotency and how do you ensure it in integrations?”What it is
An idempotent operation can be run several times with the same result as running it once.
| Operation | Idempotent? |
|---|---|
| Set status to “Shipped” | yes |
| Delete order 42 | yes |
| Add 10 to stock | no |
| Create an order without a key | no: every retry is a new order |
Why
The network is unreliable. If a response is lost, the sender doesn’t know whether the operation ran, so it repeats it. After a failure a broker also redelivers the message.
How to use
The receiver stores the ids of processed messages. If the id is already there, the message is acknowledged and skipped.
Common mistake
Assuming repeats “don’t happen”. They happen on every failure, and a duplicated invoice or stock write-off is found only during reconciliation.
Takeaway: a repeat is inevitable, so the receiver must be able to process the same thing twice safely.
How it works
| Approach | How it works | Example |
|---|---|---|
| Natural idempotency | the operation sets state instead of changing it | UPDATE ... SET status = 'Shipped', upsert by document number |
| Deduplication by message id | a table of processed ids with a unique key | a message handler reading from a broker |
| Idempotency key | the client sends a key, the server stores the first result | POST that creates a payment |
How Idempotency-Key works in an HTTP API is covered in Web API.
When to choose what
- If the operation can be rewritten as “set”, that is the cheapest route — no storage needed.
- A message handler — deduplicate by message id or by the document’s business key.
- Creating an entity or a payment through an API — an idempotency key and a stored response.
Pitfalls
- Check and write separately. “Checked the key isn’t there, then processed” lets two parallel retries both through. You need an atomic key insert backed by a unique index.
- Mark before effect. If you write “processed” first and then crash, the message is lost. The mark and the effect go in one transaction.
- Key without content. The same key with a different request body is a client bug, not a retry. The server compares a hash of the body and returns an error.
Rule of thumb
Design every inbound handler as if every message will arrive twice.
Takeaway: idempotency is “set” instead of “change”, a unique key for what was processed, and atomicity of key and effect.
Under the hood
- Reliable deduplication is a unique index:
INSERTthe key in the same transaction as the business change. A key conflict means a duplicate: roll back the transaction and acknowledge the message. - For an HTTP key you store an “in progress” state and the finished response. A retry during processing gets 409, a retry afterwards gets the stored response.
- The receiver’s processed-messages table is part of the inbox pattern; together with the outbox it is covered in Queues and Kafka.
Cost
- Key storage grows with traffic: you need a retention period (TTL) and regular cleanup.
- Key retention must exceed the sender’s retry window. If a partner can reload a file a week later, a key deleted after a day won’t help.
- A key in the same database as the data is convenient for the transaction but adds load under heavy traffic.
Trade-offs
- Message id or business key. A message id catches technical retries but not a resend of the same document with a new id. A business key — document number plus sender — catches both.
- Ordering. Deduplication won’t save you from an old message arriving after a new one. State changes carry a version, and the receiver applies only a newer one.
- External effects. Sending an SMS or a payment cannot be rolled back with a transaction. Pass a key to the provider if it supports one, or keep a log of what was sent and reconcile it.
How to check in production
- Count deduplication hits: a growing share of duplicates points to problems at the sender or the broker.
- A periodic query looks for duplicates by business key — document number, external id — in the target tables.
Takeaway: idempotency rests on an atomic unique key, a sensible retention period for it, and versions to handle out-of-order delivery.
var handler = new PriceHandler();var msg = new PriceChanged("m-1", "SKU-1", 49.90m, Version: 7);
Console.WriteLine(handler.Handle(msg)); // AppliedConsole.WriteLine(handler.Handle(msg)); // Duplicate: the same message arrived againConsole.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{ // In production these are tables with unique keys, updated in one transaction 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"; // deduplication by id if (_prices.TryGetValue(m.Sku, out var current) && current.Version >= m.Version) return "Stale"; // an old version arrived after a newer one _prices[m.Sku] = (m.Price, m.Version); // "set", not "add" return "Applied"; }}- Which HTTP methods are idempotent? — GET, HEAD, OPTIONS, TRACE, PUT and DELETE; POST and PATCH are not.
- Why is the “processed” mark written in the same transaction as the change? — Otherwise a crash between them causes either a duplicate or a lost message.
- How long do you keep idempotency keys? — Longer than the sender’s maximum retry window, then delete by TTL.
- What if messages arrive out of order? — Carry a version or change timestamp and drop stale ones.
- How do you make sending an SMS idempotent? — A key on the provider side, or a sent log with a unique key checked before sending.
How do you integrate with external and legacy systems (ERP, accounting systems, EDI providers)?
Section titled “How do you integrate with external and legacy systems (ERP, accounting systems, EDI providers)?”What it is
- Legacy and external systems — ERPs, accounting systems, partners’ systems. You cannot change them, and they have their own formats and limits.
- EDI (electronic data interchange) — exchange of standard electronic documents between companies: orders, dispatch advices, invoices. Usually via an EDI provider.
How to use
- Build an adapter — a separate class or service that alone knows the external system’s format.
- The adapter translates foreign data into your classes and back.
- Store every inbound document as-is, before parsing.
Common mistake
Dragging the foreign model into your code: DOC_NO, RCV_FLAG fields and ERP codes spread across the whole system, and replacing the ERP turns into a rewrite.
Takeaway: an external system is connected through an adapter, and its formats never leave it.
How it works
- Anti-corruption layer (ACL). A layer between the domain and the external system that translates models, codes, units of measure and errors. The domain speaks its own language.
- Transport. API or SOAP for online requests; files over SFTP or a shared folder for batches; AS2 — HTTP with signing, encryption and an MDN receipt, the EDI classic.
- EDI formats. UN/EDIFACT messages
ORDERS(order),DESADV(dispatch advice),RECADV(receiving advice),INVOIC(invoice). Many providers also work with XML formats.
When to choose what
| Situation | Approach |
|---|---|
| One or two external systems | point-to-point adapter |
| Many partners with the same documents | an EDI provider and one internal document model |
| Reference data and large daily volumes | batch file loading via staging |
Pitfalls
- No receipt means no delivery. A file dropped on SFTP is not processed yet. Wait for confirmation: MDN,
CONTRL, a response status. - Reloads. The same file arrives twice — make loading idempotent by document number and sender.
- Silent parse errors. A document that failed to parse must go to an error queue with an alert, not just to a log.
Rule of thumb
Store the raw input, translate in the adapter, track state, catch discrepancies with reconciliation.
Takeaway: the adapter isolates the foreign model, while receipts, idempotent loading and an error queue make the exchange reliable.
Under the hood
- Every document is a state machine: received, parsed, loaded, acknowledged or failed. Status and correlation id live in an exchange journal.
- The inbound file is archived before parsing. After a mapping fix the document is reprocessed from the archive without asking the partner to resend it.
- Reconciliation is a periodic comparison of both sides: counts, totals and document numbers for a period. It catches what receipts missed.
Cost
- Point-to-point: n systems connected each to each means up to n(n−1)/2 integrations, each with its own mapping.
- A bus reduces the links to one adapter per system but adds a central component, its license, a dedicated team and a single point of failure.
- A canonical model reduces the number of mappings, but changing it affects every participant.
Trade-offs
- ESB vs “smart endpoints and dumb pipes”. BizTalk is a hub with adapters, routing, orchestrations and built-in EDI and AS2 support. The modern approach: the broker only delivers, and translation logic lives in small adapter services.
- Own EDI or a provider. A provider takes over transport, the formats of specific networks and receipts. You pay with a dependency and the provider’s format.
- Gradual replacement of legacy is covered in Architecture styles.
How to check in production
- Alert on documents without a receipt beyond the SLA and on a growing parse-error queue.
- Daily reconciliation with a discrepancy report: sent vs received, invoice totals, documents without a response.
Takeaway: reliable legacy integration rests on model isolation, a status journal, an archive for reprocessing and reconciliation, and a bus is a trade-off between the number of links and centralization.
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
// The external system's model — as is, with all its quirkspublic record ErpShipmentRow(string DocNo, string DocDate, string RcvFlag, string Lines);
// Our own modelpublic record Delivery(long Number, DateOnly Date, bool IsReceived, IReadOnlyList<DeliveryLine> Lines);public record DeliveryLine(string Gtin, int Quantity);
// Anti-corruption layer: translation in one place, the domain knows nothing about ERP formatspublic 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());}-- Sent documents with no receipt after 4 hoursSELECT d.doc_number, d.partner_id, d.sent_atFROM edi_outbound AS dLEFT JOIN edi_ack AS a ON a.doc_id = d.idWHERE a.doc_id IS NULL AND d.sent_at < now() - interval '4 hours'ORDER BY d.sent_at;- What is an anti-corruption layer and where are its boundaries? — A translation layer between the domain and the external system; foreign models, codes and errors never cross it.
- How does AS2 differ from SFTP? — AS2 is HTTP with signing, encryption and an MDN receipt; SFTP is file transfer over SSH with no confirmation of processing.
- How do you find a “lost” document? — By the status journal and correlation id; systematically, by alerting on documents without a receipt and by daily reconciliation.
- Why keep the raw inbound file? — To reprocess it after a mapping fix and to settle disputes with the partner.
- When do you need a bus and when is point-to-point enough? — A bus pays off with many systems and shared documents; for two or three links it is overkill.