Skip to content

Outbound Writer Contract

Reference for stage 5b of outbound delivery: the write side of an integration, how Conflux puts a projected record into a system somebody else owns. This covers the five capability interfaces that say what a writer can do, the traits that supply those verbs, the DTOs and exceptions around them, what AbstractOutboundWriter takes care of, and the registry that resolves a writer for a driver.

The shape to hold on to: a writer's verbs live in its type, not in a flag. OutboundWriterContract itself carries only requestsPerMinute(); each verb comes from an interface the writer implements and a trait it composes. A caller asks with instanceof, not by reading a boolean.

Three concrete writers exist today. WooCommerceOutboundWriter and PrestaShopOutboundWriter each route a consent change back into their shop and declare update alone; UserPlatformOutboundWriter is the first that creates a record in a target and keeps the identifier it gets back, which is the shape the delivery ledger is built around. The chain of jobs that calls them arrived with CFX-216; what a rule may ask of a delivery is CFX-217 and the guards around a large pass are CFX-218 — see Outbound Delivery for where this stage sits. Everything here is tested against the two doubles described at the bottom of this page.

Interface: OutboundWriterContract

Namespace: App\Contracts\OutboundWriterContract

public function requestsPerMinute(): ?int;
public function accepts(Projection $projection): bool;

That is the whole contract. It carries no verbs. The two things it does ask for are the only facts about a target that are not tied to a particular verb. requestsPerMinute() is the rate it tolerates, null where it publishes none. It paces the runner and is not enforced inside a writer, because a writer that throttled itself inside a call would hold a worker rather than yield it. accepts() says whether the target can hold a record at all; see accepts() below.

accepts()

Whether the target can hold this record at all. AbstractOutboundWriter answers true for every record. A writer that can express only part of a domain overrides it.

The delivery layer asks before anything else happens to a dependent record: RecordDelivery::deliverDependent() projects the candidate and, where accepts() is false, records it suppressed at its version. It does so before the anchor lookup, before any request, and before a token is taken from the delivery budget. A refused record is not a failure and is not retried. It is offered again only once it changes.

Writer Refuses
WooCommerceOutboundWriter, PrestaShopOutboundWriter Anything but a customer_consents projection for a channel in supportedConsentChannels() (email) with a known subscribed
UserPlatformOutboundWriter A customer_consents projection for a channel outside [email, sms] (so general), or with subscribed: null. Every customers projection is accepted

The shared check is the App\Services\Concerns\AcceptsConsentChannels trait: a consent for a channel the target cannot express, or one whose subscription the source cannot know, is refused, because writing "unknown" as false would unsubscribe the person. Consent knowledge stays in the writers, and the pipeline only sees the generic hook. Each writer keeps its own guard as a second line inside performUpdate().

A separate contract from IntegrationContract rather than verbs added to it, because a system can be source-only, target-only, or both, and write verbs on the read contract would hand every integration methods it has to declare and must never use (ADR-0014).

Capability interfaces

Which verbs a writer offers is stated in its type, one interface per verb, all in App\Contracts:

Interface Verb Absent when
CanCreateRemoteRecords create(SystemConnection, Projection): RemoteReference The target's records are created elsewhere and Conflux only enriches them
CanUpdateRemoteRecords update(SystemConnection, Projection, RemoteReference): void The target is append-only — an event sink, an import endpoint, a queue
CanDeactivateRemoteRecords deactivate(SystemConnection, RemoteReference): void and reactivate(SystemConnection, RemoteReference): void The target has no inactive state of its own, which is the common case
CanDeleteRemoteRecords delete(SystemConnection, RemoteReference): void The target offers no delete endpoint, or the writer does not implement one
CanIngestRemoteRecords ingest(SystemConnection, SystemCapability, iterable<Projection>): IngestResult The target has no bulk path, or its bulk path returns no per-record identifier

None of the five is unconditional, which is why none of them lives on OutboundWriterContract. A caller narrows before using a verb:

if ($writer instanceof CanDeleteRemoteRecords) {
    $writer->delete($connection, $reference);
}

The gain over a supportsDelete flag is that the two cannot disagree (ADR-0021). A writer implementing CanDeleteRemoteRecords has a delete(), because PHP refuses the class otherwise, so there is no "declared but not implemented" state to detect, report or test. PHPStan narrows through the instanceof as well, so the call inside the branch is checked rather than hoped for.

There is no resolve verb and no upsert. Conflux creates its own record in the target for every source record and never searches the target for one it already holds — the delivery ledger answers "does a reference already exist for this source record" from a local row, exactly, without a network call. See ADR-0015.

Notes per verb

  • create() — the returned reference is the only pointer to what was just made, and it cannot be reconstructed. A writer that cannot parse an identifier out of the response must throw rather than return a fabricated one: a create nobody can address again is worse than a create that failed, because the next run makes a second one.
  • update() — sends the full canonical payload every time. No changed-field list is passed, because the record belongs to Conflux alone and there is no third-party edit to protect by omission. A target reporting the record is gone must surface as RemoteRecordGoneException and nothing else may. $projection and $reference have to name the same domain; the trait refuses a mismatched pair with InvalidArgumentException before the target is reached.
  • deactivate() — whatever the writer maps this onto has to mean "no longer present at the source" and nothing else. A consent flag is not available for it: reusing one would read a shop's deletion as a person's objection.
  • reactivate() undoes deactivate() for a record restored at the source, and nothing more. It writes no content: whether the fields follow is the rule's update_mode. It lives on the same interface as deactivate() so that no writer can offer one without the other, see ADR-0033.
  • delete() — implementing CanDeleteRemoteRecords says the target can delete, not that deleting is wise. What a deletion means in the target is the target's business, and whether a rule asks for one is its delivery policy.
  • ingest() — a first-load path, not a general write path; steady-state delivery stays per record. The domain is a parameter rather than something read off the first projection: a batch is homogeneous by construction, and a writer has to know which endpoint or file layout it is building before it sees a record — which for an empty batch is the only chance it gets. Reading it off $projections would also mean consuming an iterable that is allowed to be a generator, precisely so a first load need not be held in memory.
  • requestsPerMinute() — the pace the target tolerates. Takes no connection because it is a fact about the target system, constant across every connection to it. The runner reads it through DeliveryThrottle and spends one request of the target connection's budget per writer call. A value below 1 is refused as a writer fault.
  • bulkIngestSuppressesSideEffects() — declared on CanIngestRemoteRecords, not on the main contract, because a writer without a bulk path has no meaningful answer to give. Whether that path fires the notifications an ordinary write sets off is what decides whether a first load of a large dataset is survivable. There is no default: a writer offering the verb states what using it costs.

The unit create() and update() carry is a Projection, not a bare DTO. Its constructor already guarantees the record is the DTO its domain names, so a writer may switch on domain and trust it.

Abstract Base Class: AbstractOutboundWriter

Namespace: App\Services\AbstractOutboundWriter

public function __construct(
    private readonly CircuitBreaker $circuitBreaker,
    private readonly TracerInterface $tracer,
) {}

public function requestsPerMinute(): ?int; // defaults to null
public function accepts(Projection $projection): bool; // defaults to true

protected function write(SystemConnection $connection, string $verb, SystemCapability $domain, Closure $callback): mixed;
protected function requireMatchingDomain(Projection $projection, RemoteReference $reference): void;

It holds no verbs either. A base class that carried them would give every writer all five and the capability interfaces would describe nothing. What it supplies is write(), which every verb trait routes through, and which does three things:

  1. The circuit breaker (ADR-0009), so a target that is down stops a delivery chain rather than having a thousand records marked failed one at a time.
  2. A span (ADR-0011) named outbound.write <verb>, carrying conflux.outbound.verb, conflux.outbound.domain and conflux.connection.id, so one delivery is followable end to end.
  3. Exception mapping onto OutboundWriteException, the single failure type the ledger records — a writer may throw whatever its HTTP client throws.

Ahead of all three, write() calls requireDeliverableConnection($connection) — an empty hook a writer overrides to refuse a connection it may not deliver to at all: a status that no longer entitles it to writes, credentials the target cannot be addressed with, a prerequisite that has to exist first. It runs before the breaker and the span, and whatever it throws travels untouched. That placement is the whole point: checked inside the breaker's callback instead — where a writer asking its client for a request would naturally check it — the refusal becomes an OutboundWriteException and a breaker failure, so a rotated credential would put every record of a run on a retry schedule that cannot succeed and open the circuit over requests that never left. UserPlatformOutboundWriter is the worked example; see User Platform Outbound Writer — connections the writer refuses to deliver to.

Two exceptions stay outside that mapping:

  • CircuitBreakerOpenException passes through untouched. An open circuit is a statement about the target rather than about a record; mapping it onto a write failure would have the runner write one failed ledger row per record for a target that is simply down.
  • UndeliverableRecordException passes through untouched, and the breaker treats it as neither a failure nor a success: it carries ReportsARecordFault, which says the fault is in the record rather than in the target. A run over a dataset holding a few undeliverable records would otherwise open the circuit on a connection nothing is wrong with, and stop every good record behind them (ADR-0032).
  • RemoteRecordGoneException is caught inside the breaker's callback and rethrown outside it, so the breaker sees a successful call. A target answering "that record is not here" has answered successfully; counting it as a breaker failure would open the circuit on a run that hits a batch of records somebody deleted in the target — exactly the run that needs to finish so those records reach remote_gone.

Both collaborators are required rather than defaulted, unlike AbstractIntegration, which defaults its breaker to a new CircuitBreaker. A default like that bypasses the container, and the tracer could not be defaulted that way at all, since new TracerProvider yields a tracer that exports nothing and would lose spans instead of saying so. OutboundWriterRegistry resolves writers through the container, so a writer with no constructor of its own gets both injected; one with its own passes them up with parent::__construct().

Verb traits

Namespace: App\Services\Concerns

One trait per capability interface, each supplying the public verb and leaving one perform*() method to the writer:

Trait Supplies Leaves abstract
CreatesRemoteRecords final public function create() performCreate()
UpdatesRemoteRecords final public function update() performUpdate()
DeactivatesRemoteRecords final public function deactivate(), final public function reactivate() performDeactivate(), performReactivate()
DeletesRemoteRecords final public function delete() performDelete()
IngestsRemoteRecords final public function ingest() performIngest()

Both CreatesRemoteRecords and IngestsRemoteRecords refuse an empty identifier after the call, raising UnaddressableRemoteRecordException. The record is in the target by then, so this reports damage rather than preventing it — but the alternative is a run reporting success while some of what it made can never be addressed, and nothing downstream able to tell which.

For a batch it costs more: the whole result is discarded, including the identifiers that were fine, so a later run creates those records a second time as well. That is accepted deliberately, and a writer should not reach it — a record the target took without handing back an identifier was not usefully accepted, so it belongs in IngestResult::$rejections, where it is a reportable outcome rather than a fault and the rest of the batch survives. The exception message names the source ids, which is all anybody has to find those records in the target by hand.

The public verb is final, so a subclass cannot drop the wrapping, and the perform*() method is abstract, so a writer using the trait has to supply the target-specific half.

A writer never says which domain an identifier belongs to

performCreate() returns the identifier as a plain string, and IngestResult carries array<string, string> rather than references. The domain is added above — by the trait for a create, by the caller for an ingest — from the projection or the parameter that was just handed in. A writer therefore cannot pair an identifier with the wrong domain.

That is worth the slightly duller signature. The delivery ledger is keyed by (sync_rule_id, data_domain, source_id), so a reference carrying the wrong domain writes a row the next run cannot find, and the record is created in the target a second time — with nothing downstream able to detect it. For a bulk path it is the whole batch at once.

Checking the pairing after the call would not help: by then the record exists, and throwing loses the identifier for it, producing exactly the duplicate the check was meant to prevent. update() can be checked (requireMatchingDomain()) precisely because its check runs before the write. A writer therefore

reads:

final class AcmeCrmWriter extends AbstractOutboundWriter implements
    CanCreateRemoteRecords,
    CanUpdateRemoteRecords
{
    use CreatesRemoteRecords;
    use UpdatesRemoteRecords;

    public function requestsPerMinute(): ?int { /* ... */ }

    protected function performCreate(/* ... */): string { /* ... */ }
    protected function performUpdate(/* ... */): void { /* ... */ }
}

The interface and its trait belong together

write() is protected rather than private, because the traits are composed into the subclass and reach it from there. That is the one seam this design opens: a writer could implement CanDeleteRemoteRecords with its own delete() and reach the target without the breaker, the span or the error mapping. The architecture test a writer that declares a capability composes the trait that supplies it pins each interface to its trait, in app/ and in tests/Support/ alike. It is a rule rather than a guarantee, which is the price paid for capabilities living in the type.

Value object: RemoteReference

Namespace: App\Data\RemoteReference

A pointer to one record Conflux created in a target system.

Property Type Description
domain SystemCapability Which domain the referenced record belongs to
remoteId string The identifier the target handed back for it

The domain travels with the identifier because deactivate() and delete() are handed a reference and no projection — without it a writer would not know which of the target's endpoints the identifier belongs to, since target identifiers are not globally unique (a contact 42 and a product 42 are ordinary). The identifier is always the target's own reference, never a field a person can change: addressing a record by something mutable, an email address for instance, means a change at the source produces a second record and orphans the first.

This is the only copy of that pointer — nothing can ask a target which of its records are Conflux's, so a lost reference leaves a record standing in the target that nobody can address again (ADR-0015).

Where a target's own properties live

There is no shared limits object. The two facts a target has to state about itself sit on whatever they qualify:

Fact Declared on Why there
requestsPerMinute(): ?int OutboundWriterContract, defaulted to null on the base class True of a target whatever verbs it has
bulkIngestSuppressesSideEffects(): bool CanIngestRemoteRecords A question about a bulk path; a writer without one is never asked it

A WriterLimits DTO carried both at first, alongside a supports* flag per verb. The flags went into the type (ADR-0021), and what remained was two unrelated fields plus one that only meant something for a subset of writers — the same problem the flags had, one size down. Only one of the two has a default, and the split is deliberate. requestsPerMinute() returns null from the base class, so the common case — a target that publishes no rate — writes nothing. bulkIngestSuppressesSideEffects() has none, so a writer offering a bulk path has to answer it. What differs is the cost of silence: an unstated rate gets the writer rejected by the target and retried, which is loud and self-correcting, while an unstated side-effect answer would be guessed on the writer's behalf, and guessing wrong means a first load quietly notifying fifty thousand people.

Response DTOs: bulk ingestion

IngestResult

Why this verb alone answers with an object

Every other verb writes one record and either manages it or does not, which an exception states exactly — so create() returns a RemoteReference and the rest return void. A bulk run is the only one that can end partly: nine hundred records taken and a hundred refused is neither a success nor a failure, and fits neither a return value nor an exception.

Unifying the two would make it worse in both directions. An outcome object for a single create is ceremony carrying nothing, and rejections travelling inside an exception is a payload in the wrong vehicle.

Namespace: App\Data\IngestResult

What a bulk ingestion run reports back.

Property Type Description
status IngestStatus Whether the run finished
accepted int How many records the target took
rejected int How many records the target refused
remoteIds array<string, string> The identifier the target assigned each accepted record, keyed by its sourceId. Default []
acceptedCount() int How many were accepted — a method, counting remoteIds, not a field
rejections list<IngestRejection> The refusals, as a list something can log. Default []

How many records were accepted is not a constructor argument: acceptedCount() counts the identifiers. An outcome therefore cannot claim more acceptances than it can address, where before a constructor guard checked that the two agreed. rejected stays a field, because a target may report a hundred refusals and detail only the first page of them — the count and the list are genuinely different facts.

A bulk path that reports accepted records but hands back no identifier for them leaves every one of those records in the target with no ledger reference, so the next ordinary run creates all of them a second time — nothing downstream can detect or repair that, because the very reference that would have told the two apart was never returned. Refusing the outcome here is cheaper than discovering a duplicated dataset later (ADR-0015). The check throws rather than asserts, since assert() is compiled out in production, which is where the duplication would happen.

There is no per-record success list beyond remoteIds, and no ordering guarantee.

Identifiers rather than RemoteReference objects, for the same reason performCreate() returns a bare string — see the callout below.

IngestRejection

Namespace: App\Data\IngestRejection

One record a bulk ingestion run refused.

Property Type Description
sourceId string Which source record was refused
reason string What the target said about it

reason is free text on purpose — logged and shown, never branched on. A target's error vocabulary is its own, and mapping it into a Conflux enum would be the writer inventing a taxonomy nobody agreed on.

IngestStatus enum

Namespace: App\Enums\IngestStatus

Case Value Description
Finished finished The run completed
Failed failed The run did not complete

Two cases and no third. There is no running case, because the promise a bulk path makes to the core is deliberately weak: make these records exist, say when the work is finished, promise nothing per record. Everything a target needs to keep that promise — file formats, uploads, job identifiers, polling, paginated error listings — stays inside the writer; a running case would mean the core has to poll, which is the writer's business. ingest() returns only once there is an answer. Per-record fate travels separately, as IngestResult::$remoteIds and $rejections — a run can finish having rejected records and still be finished.

Exceptions

Class Extends When thrown
App\Exceptions\OutboundWriteException RuntimeException A write failed and may be worth trying again — that is the whole membership rule. The single failure type the ledger records: AbstractOutboundWriter::write() maps every transport-level problem a writer can hit — a refused connection, a timeout, a malformed response, an unexpected status — into this class, deliberately undifferentiated, and the runner treats all of them the same way, by counting an attempt and scheduling a retry. The one exception is its subclass TargetRateLimitedException, for which the runner counts no attempt and blocks the target connection instead
App\Exceptions\UnaddressableRemoteRecordException RuntimeException, marked App\Contracts\ProvesIntegrationReachable A record was created in the target and no identifier came back for it, so nothing points at it and nothing ever can. The one outcome where Conflux knows it has just produced a record nobody can reach. A writer throws it where it can describe the response; CreatesRemoteRecords and IngestsRemoteRecords throw it as a backstop when a writer reports an empty identifier instead. Never retry it — the record exists, so a second attempt makes a second unreachable record. The write having succeeded is also what makes it evidence of health: the circuit breaker treats it as a successful interaction (ADR-0025), because a target that changed its response shape would otherwise open the circuit within the first handful of records
App\Exceptions\RemoteRecordGoneException RuntimeException The target reported that a record Conflux created there no longer exists. The only channel through which Conflux ever learns a record was deleted in the target. The runner answers it by setting the ledger to remote_gone, reporting it, and never delivering that record again — it does not retry and does not recreate, because nobody else uses that record, so its deletion was unambiguously about it
App\Exceptions\UndeliverableRecordException RuntimeException, marked App\Contracts\ReportsARecordFault The record cannot be written, and no retry will change that: it has no addressee in any channel the target offers, it carries a field that would overwrite something the target owns, or it is not the kind of record this writer carries. The target is not implicated and usually was not contacted, so the circuit breaker leaves its state untouched (ADR-0032)
App\Exceptions\TargetRateLimitedException OutboundWriteException, marked App\Contracts\ProvesIntegrationReachable The target answered that it is receiving too many requests. Carries ?int $retryAfterSeconds, the wait the target asked for; parsing the hint (header casing, format) is the writer's job, and it passes null where the hint cannot be read. The delivery layer books nothing against the record for it and blocks the target connection for the wait (at most one day), or for 60 seconds where none was named (ADR-0036)

Only two of the five are retryable

None of RemoteRecordGoneException, UnaddressableRemoteRecordException and UndeliverableRecordException extends OutboundWriteException; they share no ancestor with it beyond RuntimeException. They are not a general and a special case of one another — they call for opposite reactions: deliver again later against never deliver this record again. Under a shared type, a caller writing the obvious catch (OutboundWriteException) { retry(); } would silently retry a record somebody deleted on purpose, which is the one outcome the remote_gone signal exists to prevent.

RemoteRecordGoneException was a subclass at first, and AbstractOutboundWriter::write() needed a catch block purely to keep it out of the other's arm — a hierarchy whose own defining class has to guard against it is one that asks every later caller to remember the same thing. Two arch rules now pin the separation — three, since UndeliverableRecordException joined them. Anything caught as an OutboundWriteException is safe to retry; that is the promise the type makes, and it is the reason the other three stand outside it: retrying a gone record recreates something somebody deleted on purpose, retrying an unaddressable one adds a second record nobody can reach to the first, and retrying an undeliverable one asks the same question of the same unchanged row on every pass, forever.

There is no exception for "this writer does not offer that verb". Which verbs exist is answered by the type before the call, with instanceof, so a caller that skipped the check gets a PHP Error rather than a delivery into the wrong shape — and one that did the check never asks. Should a caller ever need to report a missing verb rather than route around it, the exception for that belongs to whichever ticket has the caller; there is none on this branch, because nothing throws one.

A writer whose target cannot answer "not found" distinguishably from an ordinary failure — one whose update silently creates instead, or that answers the same way for "not found" and "bad request" — is not a valid push target, because Conflux would recreate records somebody deleted on purpose and never know.

CircuitBreakerOpenException (App\Exceptions\CircuitBreakerOpenException, extending IntegrationRequestException) can also surface from any verb — see the exclusion described under AbstractOutboundWriter above.

OutboundWriterRegistry

Namespace: App\Services\OutboundWriterRegistry

A sibling of IntegrationRegistry, resolving the writer registered for a driver the way that registry resolves the integration.

public function register(SystemDriver $driver, string $writer): void
public function has(SystemDriver $driver): bool
public function resolve(SystemDriver $driver): OutboundWriterContract
  • register() — associates a SystemDriver case with a class-string<OutboundWriterContract>.
  • has() — whether $driver has a writer at all, answered without resolving one. The read side never needed this, because a connection that reached a sync always had an integration; a target that cannot be written to is an ordinary configuration, not a fault.
  • resolve() — returns an instance of the registered writer, resolved via the service container. Throws RuntimeException if no writer has been registered for the given driver.

A sibling registry rather than an extension of IntegrationRegistry, because the two hold different sets: a driver can have a writer and no integration (the User Platform is a target and never a source), and today every registered driver runs the other way. See ADR-0020 for the full reasoning.

The registry is bound as a singleton in AppServiceProvider::register():

$this->app->singleton(OutboundWriterRegistry::class);

Writers are registered in AppServiceProvider::registerOutboundWriters(), one per driver:

Driver Writer Domains
woocommerce WooCommerceOutboundWriter customer_consents (update only)
prestashop PrestaShopOutboundWriter customer_consents (update only)
user-platform UserPlatformOutboundWriter customers, customer_consents

Test doubles

Two, because one that offers everything cannot stand in for a target that offers less.

FakeOutboundWriter

Namespace: Tests\Support\FakeOutboundWriter

Implements all five capability interfaces and composes all five traits. The whole delivery pipeline is built and tested against it.

public function __construct(
    private readonly ?int $requestsPerMinute = null,
    private readonly bool $bulkIngestSuppressesSideEffects = true,
    private array $remoteIds = [],
    private readonly array $failures = [],
    private readonly ?IngestResult $ingestOutcome = null,
    ?TracerInterface $tracer = null,
);

public static function remoteIdFor(string $sourceId): string;
Parameter Purpose
$requestsPerMinute The rate the double claims the target tolerates
$bulkIngestSuppressesSideEffects What the double claims its bulk path costs
$remoteIds The identifiers the target pretends to already hold. A reference to anything else is answered with a real RemoteRecordGoneException rather than a mocked one. create() adds to this set, delete() removes from it
$failures A Throwable per verb, keyed create, update, deactivate, reactivate, delete, ingest. Thrown after the call is recorded, so each defined failure is reachable without a stub HTTP layer
$ingestOutcome A fixed IngestResult for the bulk path. Omitted, the double accepts every projection and derives a reference for each
$tracer Overrides the tracer, so a test can assert on the spans a write emits
$connectionRefusal A Throwable the double refuses the connection with from requireDeliverableConnection(), before any verb runs — what a real writer raises for a connection no longer entitled to deliveries
$accepts A Closure(Projection): bool answering accepts(). Omitted, the double accepts every record

Every call is recorded — including one that then fails — via $created, $updated, $deactivated, $reactivated, $deleted and $ingested.

ingest() refuses a batch carrying a projection of a domain other than the one it was called with, throwing InvalidArgumentException. A real writer has no cheap way to notice — it picks its endpoint and payload layout from $domain before it sees a record — but this double iterates the batch anyway.

Unlike a real writer, it resolves the base class's two required collaborators itself, so new FakeOutboundWriter(...) keeps working with named arguments.

CreateOnlyOutboundWriter

Namespace: Tests\Support\CreateOnlyOutboundWriter

A writer for an append-only target: CanCreateRemoteRecords and nothing else. It stands in for the shape that put capabilities into the type in the first place, and it is what a test uses to check that a caller notices a missing verb — a check impossible to write against FakeOutboundWriter.

Note what it does not need: no flag saying update is unavailable, and no method refusing one at runtime. update() simply is not there. It has no constructor of its own, so build it with app(CreateOnlyOutboundWriter::class) — which is how a real writer is built too.

See also

  • Outbound Delivery — the five stages and the reasoning behind stage 5b
  • ADR-0014 — why the write contract stays separate from IntegrationContract
  • ADR-0015 — why there is no resolve verb
  • ADR-0020 — why writers get their own registry
  • ADR-0021 — why capabilities are interfaces rather than flags, and what that traded away
  • Implement an Outbound Writer — a worked example of a concrete writer, with the mistakes worth avoiding
  • Projection Contract — the envelope create() and update() carry