Skip to content

0023. Model integration capabilities as opt-in interfaces with traits

Date: 2026-08-28 Status: Accepted

Context

IntegrationContract declared seven methods — verify(), requestBulkExport(), streamBulkExport(), probeBulkExport(), fetchBulkExportPageRange(), handleWebhook() and transformerClass() — and every registered integration had to satisfy all of them. That held while the only integrations were WooCommerce and PrestaShop, which are both full-featured shops doing roughly the same things.

AbstractIntegration absorbed the mismatch. It implemented all seven as final wrappers around perform*() hooks, and where a capability was genuinely optional it supplied a default body that threw: processWebhook() raised BadMethodCallException('Webhook handling is not implemented for this integration.'). "This integration does not do webhooks" was therefore a runtime failure inside a queued job, not a fact a caller could establish beforehand. ProcessWebhookJob resolved an integration and called handleWebhook() unconditionally; the only way to find out it was unsupported was to call it and catch.

The consent work (CFX-179) forced the issue. Positive User is an integration that pulls consent and does nothing else — no shop, no bulk export, no webhooks, no credential verification in the shop sense. Under the fat contract it would have inherited five methods it has no business having, four of which would need throwing overrides, and provideTransformerClass() — declared abstract — would have had to return a transformer that never runs. On-demand consent pull would meanwhile have become an eighth method on the contract that neither shop integration implements.

The codebase already had the answer in one place: HasConnectionConfiguration is an optional interface that callers check with instanceof before using, rather than a method on the base contract with a null-ish default.

Decision

IntegrationContract becomes a marker interface with no methods. It answers "is this a registered Conflux integration" — the type IntegrationRegistry::resolve() returns, what the arch tests and the container bind against — and nothing else. No capability is implied by being an integration.

Each capability is one Can* interface plus one paired trait. The interface is the declaration callers discover with instanceof; the trait is the shared machinery behind it. The naming follows the one the outbound writers of ADR-0021 already established for the same shape: the interface names the verb (Can<Verb>), the trait conjugates it. Four pairs exist today: CanVerifyConnections/VerifiesConnections, CanExportInBulk/ExportsInBulk, CanHandleWebhooks/HandlesWebhooks, and CanPullConsent/PullsConsent. A concrete integration opts in by declaring the interface and use-ing the trait, then implementing only the perform*()/process*()/provide*() hook the trait requires.

HasDeletionLog/FetchesDeletionLog is the fifth pair and keeps its Has* interface name. That prefix carries a distinction worth keeping rather than an inconsistency to iron out: a Can* interface names an operation the integration performs, while Has* states a fact about the platform behind it — this shop keeps a deletion log, this one exposes configuration fields (HasConnectionConfiguration), this one represents these consent channels (HasConsentChannels). HasConsentChannels is the case that shows why the split earns its keep: it declares facts and has no trait at all, because there is no outbound call to wrap.

Splitting behaviour into traits rather than a base class per capability is what makes capability combination free: an integration picks any subset, and PHP has no multiple inheritance to work around. The traits live in App\Services\Concerns and each is constrained with @phpstan-require-extends AbstractIntegration, because they read $this->circuitBreaker — the constraint is checked by PHPStan at level 10, so a trait cannot be pulled into an unrelated class.

The traits keep the sealing and error-handling AbstractIntegration used to provide. Each declares its public method final, so an integration can never bypass the wrapper, and each routes the hook through the circuit breaker and converts any non-IntegrationRequestException Throwable into InvalidIntegrationResponseException. Splitting the contract changed where that guarantee lives, not whether it holds. VerifiesConnections keeps its own strategy of converting a throw into a failed VerificationResult.

AbstractIntegration keeps only the shared toolkit — the CircuitBreaker and ConnectionTokenIssuerService constructor injection every integration needs regardless of capability. It declares no capability method at all, so nothing throws BadMethodCallException for being unimplemented any more.

Every caller checks instanceof before using a capability, and decides for itself what "unsupported" means. There is no shared fallback, because the four call sites do not want the same thing: SystemConnectionService::verify() returns VerificationResult::failure('This integration does not support verification.'), ProcessWebhookJob logs a warning and discards the call, BulkSyncJob logs a warning and returns without starting a sync, and FetchBulkPageRangeJob sets the connection to Error and cancels its batch.

Consequences

The two halves of a capability can drift apart in one direction. implements CanExportInBulk without use ExportsInBulk is a fatal error — the interface methods are missing — so that direction is caught at compile time. The reverse is not: a class that uses the trait but forgets the implements clause has every method, passes PHPStan, and passes its own unit tests, while every caller's instanceof check silently skips it. The failure mode is a capability that quietly does nothing rather than one that errors. Nothing enforces the pairing today; an arch test asserting that each capability trait's users also declare the matching interface would close it.

"Unsupported" is now a decision each caller has to make, and the four current answers are not consistent with each other. BulkSyncJob returning silently leaves its sync batch in whatever state it was created in, while FetchBulkPageRangeJob treats the same condition as a connection error. Both are defensible — the first is reached before work starts, the second mid-batch — but the asymmetry is a consequence of having no shared policy, and each new call site has to answer the question again.

The capability of an integration is now discoverable statically. An admin UI, a diagnostics endpoint or a sync-rule validator can enumerate what an integration supports from its type alone, without calling anything or catching anything. Adding a fifth capability touches no existing integration: a new interface/trait pair plus instanceof checks at the new call sites.

An integration is now allowed to support nothing. UserPlatformIntegration implements CanPullConsent and no other capability, which the fat contract could not express. The type system no longer claims every integration is a shop.