Skip to content

Integration Contract Design

This page explains the reasoning behind the integration communication contract — the design decisions that shape how platform integrations interact with Conflux: credential verification (CFX-53), outbound bulk exports (CFX-45), and inbound webhook processing (CFX-48).

For the exact field-by-field specification, see the Integration Contract reference.

What the Contract Is

When Conflux needs data from an external system (WooCommerce, PrestaShop), it calls through an integration. The integration is the only part of the system that knows the system's native API. Everything above the integration works with normalised Conflux DTOs.

The contract enforces that boundary. IntegrationContract's bulk-export methods (requestBulkExport(), streamBulkExport(), probeBulkExport(), fetchBulkExportPageRange()) are the entry points an integration exposes for the outbound direction; all of them ultimately deal in a typed collection of CustomerData, OrderData, or ProductData. Conflux never receives raw system objects.

System-Agnostic by Design

The data types (DTOs) carry a sourceSystem field of type SystemDriver. This means consumers (rapidmail, User Platform, or any other downstream service) receive a single, uniform object shape regardless of which system produced it. The system identity travels as data on the DTO, not as a variant of the type itself.

An alternative would be system-specific DTOs (e.g. WooCommerceCustomerData). This was avoided deliberately. Downstream consumers would then need to branch on type, which scatters system awareness throughout the codebase and makes adding a new system a cross-cutting change.

The sourceId / id Split

Every entity has two identity fields:

  • sourceId — the system-native ID, always a string. This is the dedup key: if Conflux receives the same record twice, it matches on sourceId + sourceSystem.
  • id — the Conflux-assigned UUID, nullable. It is null when the integration returns the record, because Conflux has not yet assigned or looked up the internal identity. It is populated by Conflux after the record is persisted or looked up.

This split keeps the contract honest. An integration cannot know whether a record already exists in Conflux, so requiring id would force either a database lookup inside the integration (wrong layer) or a fabricated UUID (wrong data). Nullable id makes the lifecycle explicit: the integration is a producer, not an authority on Conflux identity.

The same pattern applies to cross-entity references. OrderData.customerId is the Conflux UUID of the customer (null until resolved); OrderData.customerSourceId is the platform-native customer ID (usable immediately for linking during import).

Monetary Values as Minor Currency Units

All monetary amounts are stored as integers in the smallest unit of the currency — cents for EUR/USD, pence for GBP, and so on. 1299 means €12.99.

Floating-point arithmetic on money is lossy. A float field invites rounding errors that compound across sums. Using integers eliminates the ambiguity entirely: the value is exact, and the interpretation (divide by 100 for display) is left to the consumer. The currency field travels alongside so that a consumer knows which denomination applies.

customAttributes as an Escape Hatch

Every entity includes a customAttributes: Collection<int, CustomAttributeData> collection. Systems expose fields that do not fit the normalised schema — a WooCommerce custom meta field, a PrestaShop custom attribute. Rather than modelling every system-specific field in the DTO (which would make the DTO a union of all systems), integrations map them into customAttributes.

Each entry is a CustomAttributeData with a key (string) and a typed value (string|int|bool|null). The convention is to namespace keys to avoid collisions (e.g. woocommerce_meta_loyalty_points). Downstream consumers that care about a specific attribute can extract it by key; consumers that do not care ignore the collection.

Error Handling at the Contract Boundary

AbstractIntegration.requestBulkExport() (and its streaming/paged siblings streamBulkExport(), probeBulkExport(), fetchBulkExportPageRange()) are final. Integration authors implement fetchBulkExportBatches() instead (optionally performProbeBulkExport()/performFetchBulkExportPageRange() for parallel fan-out). The base class wraps the call:

  • If fetchBulkExportBatches() raises an IntegrationRequestException (or subclass), it propagates unchanged. This covers expected conditions: authentication failures, rate limiting, resource not found.
  • Any other Throwable is caught and re-thrown as InvalidIntegrationResponseException. This covers unexpected failures: null pointer exceptions, malformed API responses, deserialisation errors that the integration did not anticipate.

Callers of requestBulkExport() only need to handle IntegrationRequestException. The distinction between "the integration reported an error" and "the integration crashed unexpectedly" is preserved by the exception hierarchy (InvalidIntegrationResponseException extends IntegrationRequestException), but both cases are catchable at the same level.

Sealing requestBulkExport() as final ensures this guarantee holds across all integration implementations — it cannot be accidentally bypassed by a subclass.

Circuit Breaker for Outbound Requests

The same four outbound template methods (verify(), streamBulkExport(), probeBulkExport(), fetchBulkExportPageRange()) are also wrapped in a per-SystemConnection circuit breaker, so that a failing or degraded external system stops receiving repeated requests instead of being hammered by every retry and scheduled job. When the breaker actually opens during a bulk sync, the affected SystemConnection is moved to SystemConnectionStatus::Error, taking it out of scheduled and manual sync until the owner reconnects. See ADR-0009 for the design rationale, the build-vs-buy decision, and the connection-status side effect.

Why verify() Returns a Result Instead of Throwing

verify() (CFX-53) does not follow the throw/catch pattern above. AbstractIntegration::verify() catches any Throwable from performVerification() and converts it directly into VerificationResult::failure() — it never lets an exception escape to the caller.

This is deliberate: verify() sits on the synchronous request path, called directly from SystemConnectionService while handling /register and /connect. The controller needs a uniform, non-throwing outcome it can turn into either a 201/200 response or a 422 — introducing a second exception type to catch on this path would duplicate the branching that VerificationResult::successful already provides. Bulk export and webhook handling, by contrast, run inside queued jobs where a thrown, typed exception is the natural way to fail a job and land it in the failed_jobs table.

See Connection Flow for how verify() fits into the two-sided registration handshake.

Why OrderData.status Is a String

Customer status and product status are modelled as enums (Gender, ProductStatus). Order status is a plain string. This is intentional.

Order statuses are highly system-specific and have no stable cross-system vocabulary. WooCommerce uses processing, completed, on-hold; PrestaShop represents status as a shop-configurable numeric current_state, resolved to its human-readable name. Defining a shared enum would require either a lossy mapping (collapsing distinct states) or a very large enum that grows with every new system. The raw system status is preserved so that downstream consumers can apply their own interpretation.

Inbound Webhooks: handleWebhook() / processWebhook()

CFX-48 added the inbound direction to the contract. When an external platform sends a webhook to POST /api/webhooks/{connection}, the payload eventually reaches the integration's processWebhook() method.

The design mirrors the outbound pattern: handleWebhook() is final on AbstractIntegration and wraps processWebhook() with the same error-handling guarantee. Any unexpected Throwable is caught and re-thrown as InvalidIntegrationResponseException. Integration authors override processWebhook(), never handleWebhook().

Unlike fetchBulkExportBatches(), processWebhook() is not abstract — it has a default implementation that throws BadMethodCallException. This makes webhook support opt-in: an integration that only handles bulk exports does not need to stub out an empty webhook method. If a webhook arrives for an integration that has not implemented processWebhook(), the error surfaces clearly as a BadMethodCallException wrapped in InvalidIntegrationResponseException.

Why signature validation lives outside the integration

HMAC-SHA256 signature validation is handled by WebhookController, before the payload reaches the queue or the integration. Integrations never see unsigned or mis-signed requests. This separation keeps platform-specific logic out of the security boundary — every webhook is validated identically, regardless of which platform sent it. An integration cannot accidentally skip or weaken signature checks.

Why processing is asynchronous

The controller dispatches ProcessWebhookJob and returns 200 immediately. This keeps the webhook response time predictable for the sending platform (which may have tight timeout requirements) and decouples ingestion from processing. If the integration's processWebhook() fails, the job enters the failed-job table and can be retried — the external platform does not need to resend.

What the Contract Does Not Cover

  • PaginationrequestBulkExport() returns a Collection. How the integration fetches pages internally is an implementation detail. The contract does not model cursors or page tokens; if a platform requires pagination, the integration resolves it before returning the complete collection.

Delta Sync: modifiedAfter

ExportRequest carries an optional modifiedAfter string (ISO 8601). When present, it signals to the integration that only records modified after this timestamp need to be returned. When absent, the integration fetches all records.

The cursor value is derived and injected by BulkSyncJob — integration authors do not compute it. An integration that targets a platform with server-side date filtering (such as WooCommerce) passes the value straight through as a query parameter. An integration targeting a platform with no filtering support can choose to filter the response in memory, or ignore the field and always perform a full fetch.

For the full design rationale, see Delta Sync.