0020. Register outbound writers in their own registry¶
Date: 2026-09-07 Status: Accepted
Context¶
ADR-0014 already put the write side
of an integration behind its own contract, separate from
IntegrationContract, 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. IntegrationContract has carried a registry of its own since the
read side was built: IntegrationRegistry maps a SystemDriver to the class that implements it, and
resolve() is the only way a SystemDriver is ever turned into a live integration. The write
contract needs the same lookup — something has to turn a SystemDriver into a live
OutboundWriterContract — and the two obvious ways to get it are to extend IntegrationRegistry or
to add a sibling next to it.
The two registries do not hold the same set. The User Platform is a target and never a source: it
will have a writer registered and no integration at all. Every driver today runs the other way —
WooCommerce and PrestaShop are sources with no writer — but that is what today's two integrations
happen to be, not a rule the registry can lean on. A registry keyed on SystemDriver for both
directions would therefore have to answer "does this driver have a writer" and "does this driver have
an integration" as two independent questions, because neither implies the other.
Decision¶
Outbound writers are registered in OutboundWriterRegistry, a sibling of IntegrationRegistry
rather than an extension of it. It offers the same two operations the read side already has,
register() and resolve(), plus one the read side never needed:
public function register(SystemDriver $driver, string $writer): void;
public function has(SystemDriver $driver): bool;
public function resolve(SystemDriver $driver): OutboundWriterContract;
Folding both directions into one registry means one of two shapes. Either that class holds two maps
behind one name — which is this same decision, just with a worse name on the class that makes it —
or its resolve() returns null for whichever direction a driver does not support, which turns
"can this system be written to" from a question the type answers into one every caller has to
remember to ask. A sibling class keeps each question answered by which registry a caller reaches
for, not by a null check inside the one they always reach for.
has() exists because a target that cannot be written to is an ordinary configuration, not a
fault. IntegrationRegistry has never needed a method like it: a SystemConnection that reaches a
sync always has an integration behind it, because the connection could not have been created
without one, so resolve() failing was always a bug. A SystemDriver with no registered writer is
not a bug — the direction matrix from
ADR-0004 already lets a system decline
push_out for every domain, and a rule cannot target a connection whose driver has no writer to
carry it. Something upstream of resolve() — sync rule validation, when a rule's target side is
checked — needs to ask that question and act on the answer, rather than catching the
RuntimeException resolve() throws for what would otherwise look like a registration bug.
Consequences¶
Both registries are bound as singletons in the same place, AppServiceProvider::register(), and
nothing about registering a writer changes how an integration is registered or vice versa — the two
boot() calls are independent lines against independent instances. A driver is free to appear in
both. WooCommerce is a source only today; CFX-49 is expected to give it a writer as well, and when
that lands it registers one in OutboundWriterRegistry without touching its IntegrationRegistry
entry at all.
OutboundWriterRegistry has no writer registered yet, because no concrete writer exists to point
it at. The first registration lands with the first real target.