A controller needs to register an order webhook. It constructs a vendor SDK connection, accesses a dynamic resource property, calls its creation method, names the remote event, chooses response fields, and catches the SDK’s base exception.
Its test needs to intercept construction of that concrete connection and mock a dynamic property chain. The two useful behavior tests are skipped with the same explanation: the code needs an interface and dependency injection.
The controller does not need a vendor client. It needs one capability:
register order-created notificationsThat difference is the useful boundary.
A client exposes the remote system’s grammar
A vendor client is often a faithful model of its API. It may expose resources, method names, request fields, authentication placement, error codes, and response arrays.
That is valuable inside an adapter. It is expensive in application code.
The retained controller knew all of these remote decisions:
- how to construct an authenticated connection;
- which remote resource managed event listeners;
- the exact remote event name;
- which result fields to request;
- the dynamic call shape used to create and delete a listener;
- which SDK exception meant invalid credentials; and
- which SDK exception meant the listener was already absent.
Replacing the SDK therefore required changing the controller even though its product behavior had not changed.
An application-facing interface should describe the operation the application owns:
registerOrderWebhook(account, callbackUrl) -> subscription
removeWebhook(account, subscriptionId) -> voidThe adapter can translate that request into the vendor’s resource names, protocol, fields, and authentication.
Put the translation map in one place
The retained repair introduced a narrow webhook manager between two controllers and the remote connector.
connect controller ──┐
├── webhook lifecycle capability
disconnect controller┘ |
v
remote connector
|
v
authentication + JSON-RPC + errorsThe connect controller now asks to create the one webhook the product needs. The manager supplies the vendor event name and requested result fields. The disconnect controller asks to remove a known subscription. Neither controller constructs a remote connection or navigates the SDK’s dynamic resources.
This is not abstraction for its own sake. It gives each decision one owner:
| Decision | Owner |
|---|---|
| when a user connects or disconnects | application controller |
| which product event needs a webhook | lifecycle capability |
| remote event name and response fields | adapter |
| authentication placement | connector |
| request framing and endpoint | connector and request object |
| remote error-code translation | connector |
| customer-facing failure response | application controller |
When the remote API changes its event-listener spelling, the application use case does not need to change. When the product decides to register a second event, the capability changes deliberately rather than leaking another remote method call into a controller.
Translate failures before they cross the boundary
Transport libraries and SDKs fail in their own vocabulary. Application code should not need to know numeric vendor error codes or catch a package-specific exception.
The retained connector maps remote errors into three local categories:
access denied
remote object not found
integration request failedThat lets the application make its own decisions. Registration failure becomes a clear invalid-credentials result and no local integration record is created. During disconnection, “already absent remotely” is accepted and local cleanup continues.
Those two policies are different even if both failures came from the same remote endpoint. The adapter identifies what happened; the application decides what that outcome means for its workflow.
Do not flatten every failure into IntegrationException. Callers need
distinctions they can act on. Do not reproduce every vendor error class either.
A useful local taxonomy is the smallest set which changes retry, compensation,
customer response, or local state.
Test the two sides of the seam differently
Before the repair, the controller tests tried to overload a concrete SDK class and configure a dynamic call chain. They were skipped because the dependency could not be substituted cleanly.
After the repair, the application tests supply a small in-memory capability:
register(...) -> returns synthetic subscription metadata
remove(...) -> records the removed subscription IDThose tests can assert product behavior:
- successful registration stores the returned subscription identity;
- registration failure stores no local integration;
- disconnection asks to remove the right subscription;
- local state is removed after remote absence is tolerated.
Connector tests own a different contract:
- base URL and authentication query are correct;
- JSON-RPC method and parameters are correct;
- requested response fields are correct; and
- remote error codes become the expected local exceptions.
The controller test no longer cares whether the adapter uses an SDK, a generic HTTP client, or a hand-written socket. The connector test no longer needs a database, route, session, or feature flag.
That separation made the two previously skipped behavior tests executable. It does not prove the real remote service accepted the requests; the retained connector tests use mocked HTTP responses.
The retained seam is useful, not finished
The new interface is narrower than the vendor client, but it still leaks two weak shapes.
First, registration returns an untyped array. The controller reaches into it for the remote subscription identity. A stronger result would name the local facts:
WebhookSubscription {
id: string
status: SubscriptionStatus
}Second, every method receives raw account credentials. That may be appropriate if the capability is deliberately stateless, but it spreads secret-bearing arguments across callers. An account reference resolved inside the adapter can reduce exposure when the application’s credential model supports it.
The interface also names one product event in the method itself. That is a good constraint while the product supports exactly one webhook. It becomes awkward if many event types share the same lifecycle and policy. At that point a typed event enum or subscription specification may be the better capability.
These are reasons to improve the seam, not reasons to expose the entire remote client again.
Keep broad clients at the edge
A broad client interface can be reasonable when the application truly provides a generic proxy or administration console for most of the remote API. In that case, mirroring remote resources may be the product.
Direct SDK use can also be reasonable in a short-lived migration script or a small process whose only responsibility is that integration. The cost appears when remote grammar crosses into code which owns unrelated product policy.
A generated SDK does not remove the boundary decision. It can provide excellent serialization, authentication, and endpoint coverage while remaining behind a capability adapter. The question is not whether the SDK is good. It is whether application callers should inherit its model and release cycle.
Review the leak map, not the interface count
Adding an interface does not guarantee containment. Review the actual dependencies on both sides:
| Concern | Contained? | Evidence to seek |
|---|---|---|
| remote package classes | yes when imports stop at the adapter | dependency scan |
| authentication format | yes when callers pass local account context | boundary test |
| endpoint and request grammar | yes when request objects own them | connector test |
| error codes and package exceptions | yes when mapped locally | failure tests |
| response terminology | only if results are typed locally | return type and consumers |
| webhook lifecycle policy | yes when capability names product operations | application tests |
| credentials | only if exposure is intentionally bounded | data-flow review |
The retained migration removed four direct vendor packages across several integrations, but that count is not the argument here. INT-04 already examines semantic translation in one payment path, and the later design retrospective uses the broader replacement as a change-pressure test. This article owns the narrower controller-to-webhook capability boundary.
The public reproduction runs a connect and disconnect workflow against a fake capability, then separately exercises a synthetic adapter’s request framing and error mapping. It does not reproduce the private SDK, HTTP library, provider, controller framework, or production integration.
An adapter earns its place when application code can state what it needs without speaking the remote system’s language. Ask for the capability, keep vendor grammar at the edge, and test product decisions separately from wire translation.