Skip to content

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.