Integrating a third-party API is one of the most common tasks in enterprise software, and one of the most commonly done badly. The quick version - raw HttpClient calls, string URLs, JSON parsed inline, tokens fetched on demand - works in a demo and rots in production. We package every vendor integration as a small, self-contained client library instead, and the shape of that library is the same every time.
Start from the contract
The vendor's Swagger or OpenAPI spec is the contract, so we start there. Generating models and a base client from the spec gives you typed requests and responses for free, and it means a vendor change shows up as a compile error rather than a runtime surprise. The generated types are the foundation; everything else wraps them in production concerns.
A typed client behind an interface
The public surface of the library is a narrow interface that speaks your domain, not the vendor's. Consumers depend on IBillingProvider, not on a generated BillComApiClient with forty methods. This keeps the vendor's shape contained in one place and makes the client mockable in tests:
public interface IBillingProvider
{
Task<Invoice> GetInvoiceAsync(string invoiceId, CancellationToken ct);
Task<PaymentResult> SubmitPaymentAsync(PaymentRequest request, CancellationToken ct);
}
Register it through HttpClientFactory so the framework manages the handler lifetime and you avoid socket exhaustion:
builder.Services.AddHttpClient<IBillingProvider, BillingProvider>(c =>
{
c.BaseAddress = new Uri(options.BaseUrl);
});
Authentication that refreshes itself
Most vendor APIs use OAuth2 client credentials, and the part teams get wrong is token lifecycle. Fetching a token per request is wasteful; caching one and never refreshing it fails the moment it expires. A delegating handler that acquires, caches, and refreshes the token before it lapses solves this once for every call:
builder.Services.AddHttpClient<IBillingProvider, BillingProvider>()
.AddHttpMessageHandler(() => new OAuth2TokenHandler(tokenOptions));
The handler attaches the bearer token to every outgoing request, so the client code never thinks about authentication at all.
Resilience as a default, not an afterthought
A vendor you do not control will be slow or down sometimes, so resilience belongs in the client, not in every caller. Layer retry, circuit breaker, and timeout onto the same registration so transient faults are absorbed before they ever reach your domain:
.AddResilienceHandler("billing", p =>
{
p.AddRetry(new() { MaxRetryAttempts = 3, UseJitter = true });
p.AddCircuitBreaker(new() { FailureRatio = 0.5 });
p.AddTimeout(TimeSpan.FromSeconds(10));
});
Warning: Only retry idempotent calls. A retried payment submission can charge twice - guard non-idempotent operations with an idempotency key the vendor honors, or do not retry them.
Map errors to your domain
The last job of the library is to translate vendor failures into exceptions your application understands. A 404 becomes a typed InvoiceNotFound, a 429 becomes a RateLimited your caller can back off on. Leaking raw HTTP status codes into business logic couples your domain to the vendor's wire protocol; mapping them at the boundary keeps that coupling in one file.
Wrapping up
A good API client library is boring on the outside and careful on the inside: a typed domain interface, generated models from the spec, self-refreshing OAuth2, built-in resilience, and errors mapped to your domain. Package those concerns once per vendor and every consumer in your system gets a client that is safe to call without thinking. The deadline that started it all gets met, and the integration does not become next quarter's incident.
Need a tricky integration done right? Let's build it.
