Implement an Outbound Writer¶
Use this guide when a system has to be able to receive data from Conflux. A writer is stage 5b of outbound delivery: it takes a projected record and puts it into a system Conflux does not own. It is the only part of the delivery pipeline that knows a target's API.
The examples below build a writer for a fictional AcmeCrm — substitute your own target. For a real
one, see UserPlatformOutboundWriter, which
creates and updates Positive User contacts.
Prerequisites¶
- The target system exists as a
Systemwith its ownSystemDrivercase - It declares
push_outfor the domains it should receive, in its direction matrix (ADR-0004) — without it, routing resolves no route and the runner delivers nothing - Its update path is addressed by an identifier the target itself hands back, and it answers distinguishably when that identifier no longer exists
The last one is not a formality. It is the only channel through which Conflux ever learns that a record it created was deleted in the target. A target whose update silently creates instead, or that answers the same way for "not found" and "bad request", cannot be written to safely: Conflux would recreate records somebody deleted on purpose and never know.
1. Declare the verbs in the type¶
Extend App\Services\AbstractOutboundWriter, implement one capability interface per verb the target
offers, and compose the matching trait for each:
final class AcmeCrmWriter extends AbstractOutboundWriter implements
CanCreateRemoteRecords,
CanUpdateRemoteRecords,
CanDeleteRemoteRecords
{
use CreatesRemoteRecords;
use DeletesRemoteRecords;
use UpdatesRemoteRecords;
public function requestsPerMinute(): ?int
{
return 120;
}
}
Implement an interface only when the target offers the path and you write the method below. This
example omits CanDeactivateRemoteRecords because Acme CRM has no inactive state — so it also writes
no performDeactivate(), and callers can see that from the type without asking.
The interface and its trait belong together, always. The interface declares the public verb; the
trait supplies it, wrapped in the circuit breaker, the span and the error mapping, and leaves you the
perform*() half. Implement the interface without the trait and you would have to write the public
verb yourself — reaching the target with none of that. The architecture test
a writer that declares a capability composes the trait that supplies it fails the build if you do.
requestsPerMinute() is the only fact about a target that is not tied to a verb. It defaults to
null on the base class, so leave it out entirely unless your target publishes a rate — this example
overrides it because Acme CRM does. Keep an override free of I/O; it is read when a sync rule is
saved, before any delivery happens.
Everything else a target has to say about itself belongs to the verb it qualifies. A writer
implementing CanIngestRemoteRecords also answers bulkIngestSuppressesSideEffects(), because
whether a bulk path fires the notifications an ordinary write sets off is the fact that decides
whether a first load of 50,000 records is survivable — and it is a question a writer without a bulk
path is never asked.
2. Implement the verbs you declared¶
One perform*() per trait. The trait declares each abstract, so a missing one fails at compile
time rather than at delivery time.
protected function performCreate(SystemConnection $connection, Projection $projection): string
{
$response = $this->request($connection)
->post($this->endpoint($connection, $projection->domain), $this->payload($projection))
->throw();
$remoteId = $response->json('id');
if (! is_string($remoteId) || $remoteId === '') {
throw new UnaddressableRemoteRecordException('Acme CRM created a record but returned no id for it.');
}
return $remoteId;
}
Note the return type: the identifier alone, not a RemoteReference. The trait pairs it with the
projection's domain, so you cannot label a record with the wrong one — which matters because the
ledger is keyed by domain, and a wrong one makes the next run create the record a second time.
Throw rather than invent an identifier — and throw the right thing. The identifier you return is
the only pointer to what you just made, and it cannot be reconstructed: nothing can ask the target
which of its records are ours. UnaddressableRemoteRecordException is the type for it, not
OutboundWriteException, because the latter promises the write is safe to retry and this one is the
opposite — the record already exists, so a retry makes a second one just as unreachable.
If you return an empty string instead of throwing, the trait raises the same exception for you. That backstop exists because the fault is silent otherwise, but your own throw carries the better message: you know what the response looked like.
The bulk path has the same backstop, and there it is worth avoiding rather than relying on: an empty
identifier anywhere in the batch discards the whole result, good identifiers included. A record the
target took without handing back an identifier was not usefully accepted — report it as an
IngestRejection instead, and the rest of the batch survives.
protected function performUpdate(SystemConnection $connection, Projection $projection, RemoteReference $reference): void
{
$response = $this->request($connection)->put(
$this->recordUrl($connection, $reference),
$this->payload($projection),
);
if ($response->status() === 404) {
throw new RemoteRecordGoneException(sprintf('Acme CRM no longer holds record [%s].', $reference->remoteId));
}
$response->throw();
}
Map "not found" to RemoteRecordGoneException and nothing else to it. Mapping a 404 onto an
ordinary failure costs the one signal that tells Conflux a record was deleted on purpose; mapping an
ordinary failure onto it stops a record being delivered for good. Note the ->throw() comes after
the 404 check — the other way round, the status is never reached.
performDelete() follows the same shape.
Refuse a connection in requireDeliverableConnection(), not inside a verb. If your target has a
precondition that is about the connection rather than the record — a status that no longer entitles
it to deliveries, credentials it cannot be addressed with — override that hook:
protected function requireDeliverableConnection(SystemConnection $connection): void
{
$this->client->deliveryRequest($connection);
}
write() calls it before the breaker and the error mapping, so the exception reaches the caller with
its own type. Checked inside a verb instead, it is thrown within the breaker's callback and comes out
as a retryable OutboundWriteException with a breaker failure counted against the target — so one
rotated token puts every record of the run on a retry schedule that cannot succeed.
Everything else the target needs stays inside the class:
/**
* @return array<string, mixed>
*/
private function payload(Projection $projection): array
{
/** @var array<string, mixed> $payload */
$payload = $projection->record->toArray();
return $payload;
}
private function request(SystemConnection $connection): PendingRequest
{
/** @var array<string, mixed> $credentials */
$credentials = $connection->credentials ?? [];
$token = is_string($credentials['api_token'] ?? null) ? $credentials['api_token'] : '';
return Http::withToken($token)->acceptJson();
}
private function endpoint(SystemConnection $connection, SystemCapability $domain): string
{
return sprintf('%s/v1/%s', rtrim((string) $connection->base_url, '/'), $domain->value);
}
The payload() indirection is not decoration: toArray() is annotated array<mixed>, which PHPStan
level 10 refuses to pass to the HTTP client's $data. The narrowing docblock is the same convention
the existing integrations use for $credentials.
Once the mapping is more than a pass-through, it belongs in its own class, one per domain. A
writer knows how a target is addressed, authenticated and how its answers read; what a customer,
an order or a product is in that target is a different question, and it grows field by field.
UserPlatformContactMapper is the worked example: one invokable call, no state, no I/O, named so
that an …OrderMapper sits beside it rather than inside it. The writer then names each domain in a
table — which mapper a record goes through, and which collection it is written to — so a new domain
costs those two arms and its own mapper, and inherits nothing from the first domain about which
verbs its API offers. Tests follow the same line: the mapper's own file asks it for a payload
directly, and the writer's file proves only that it sends what came back.
3. Register the writer¶
Add one line to AppServiceProvider::registerOutboundWriters(), which boot() calls with the
registry and which holds every writer registration in one place:
public function registerOutboundWriters(OutboundWriterRegistry $writers): void
{
$writers->register(SystemDriver::WooCommerce, WooCommerceOutboundWriter::class);
$writers->register(SystemDriver::PrestaShop, PrestaShopOutboundWriter::class);
$writers->register(SystemDriver::UserPlatform, UserPlatformOutboundWriter::class);
$writers->register(SystemDriver::AcmeCrm, AcmeCrmWriter::class);
}
The registry holds one writer per driver. A target that carries several domains, as Positive User
carries customers and customer_consents, gets one writer that handles them all, and a writer that
can express only part of a domain says so in accepts().
OutboundWriterRegistry is a sibling of IntegrationRegistry, not an extension of it (ADR-0020). A driver can appear in one, the other, or both. The registry resolves through the container, so a writer with no constructor of its own gets CircuitBreaker and TracerInterface injected; one with its own dependencies passes them up with parent::__construct().
4. What a writer must not do¶
- Do not catch transport errors.
AbstractOutboundWritermaps anything unexpected toOutboundWriteException, the one type the ledger records. Atry/catchthat swallows a failure turns it into a silent success, and the runner writesdeliveredfor a call that never landed. - Do not retry, sleep, or throttle inside a call.
requestsPerMinutepaces the runner; a writer that throttles itself holds a worker rather than yielding it. Retries belong to the ledger, which counts attempts and schedules the next one. - Do not search the target for a record. There is no
resolveverb. The ledger answers "is there a record for this source id" from a local row, exactly, without a network call (ADR-0015). - Do not decide create against update. The runner has already decided from the ledger before it calls you.
- Do not interpret the delivery policy. Whether a tombstone means deactivate, delete or nothing is the rule's
delete_policy, not the writer's call. - Do not add a verb you cannot perform. There is no flag to set to
falseand no method to leave throwing: if the target has no update path, leaveCanUpdateRemoteRecordsoff, and a caller sees it in the type. - Do not add fields the DTO does not carry. The payload shape lives in the DTO layer; a field only one target wants is either defined in the canonical DTO or not sent at all.
5. Prove it with tests¶
Cover each declared verb against Http::fake(), never a live target. The two cases worth writing first are the ones that are load-bearing rather than obvious:
test('surfaces a missing remote record as gone rather than as a failure', function (): void {
Http::fake(['*' => Http::response(status: 404)]);
$writer = app(AcmeCrmWriter::class);
expect(fn () => $writer->update($connection, $projection, $reference))
->toThrow(RemoteRecordGoneException::class);
});
test('refuses a create whose response carries no identifier', function (): void {
Http::fake(['*' => Http::response(['name' => 'Ada'])]);
$writer = app(AcmeCrmWriter::class);
expect(fn (): RemoteReference => $writer->create($connection, $projection))
->toThrow(OutboundWriteException::class);
});
There is no test to write for "refuses a verb it does not support", and no exception for it either:
that verb does not exist on the class, so nothing can call it. The check a caller performs is
instanceof, and PHPStan verifies it statically.
The second one above is the test people skip. A create whose response is not parsed correctly leaves a record in the target that nothing can address, and the next run creates a second one — with no way to detect or repair it afterwards.
See Also¶
- Outbound Writer Contract reference: the six verbs, the DTOs, the failure types and what the base class takes care of
- Outbound Delivery: where stage 5b sits and what feeds it
- ADR-0015: why a writer creates rather than resolves, and why
remote_goneis load-bearing - ADR-0021: why you declare verbs by implementing an interface rather than setting a flag
- Implement an Integration: the inbound counterpart, for a system Conflux reads from