The admin has slots. The server has ports, and the inversion runs the same way.
The plugin that wants to be extended declares a Symbol token and an interface,
and injects it @Optional(). The plugin that provides the behaviour binds it.
// content-server declares it, and knows nothing about locales
export const CONTENT_ENTRY_EXTENSION = Symbol('CONTENT_ENTRY_EXTENSION');
export interface ContentEntryExtension {
listScope(...): SQL | undefined;
createColumns(...): Promise<Record<string, unknown>>;
afterUpdate(...): Promise<Row[]>;
beforeWrite?(...): Promise<void>;
describeFanout?(...): Fanout | undefined;
}
// i18n-server binds it, in a global module
providers: [
{ provide: CONTENT_ENTRY_EXTENSION, useClass: EntryLocaleExtensionService }
];Why the direction is that way round
If content-server imported the localization plugin, the package graph would have a cycle the moment localization needed anything from content — which it does.
With the port, content depends on nothing, and localization depends on content.
Removing the localization plugin leaves content working: the port is injected
@Optional(), so it is simply absent.
Two rules
Call it unconditionally, and no-op inside. The consumer calls every method on every write. An implementation must no-op for the cases it does not apply to — the localization extension does nothing at all for a non-localized type.
Support exactly one binding. A second implementation would silently replace the first. If two plugins need to extend the same seam, that needs a composite, and one does not exist.
The shipped ports
| Port | Declared by | Bound by | For |
|---|---|---|---|
CONTENT_ENTRY_EXTENSION | content | i18n | Row-per-locale scoping, create stamping, shared-field sync |
MEDIA_ASSET_RESOLVER | content | media | Verifying a media id exists and matches accept |
CONTENT_CATALOG | identity | content | What content types exist, for grants |
TOOL_PROVIDER | tools | content, media, i18n | Contributing agent tools |
COPILOT_TOOL_PROVIDER | copilot | content and others | Contributing copilot tools |
CONTENT_PUBLISH_GUARD | content | protection | Whether this caller may publish this entry now |
ACTIVITY_RECORDER | identity | activity | Deprecated — auditing moved to the outbox |
CONTENT_PUBLISH_GUARD is the exception to the one-binding rule above, and it
is an exception by construction rather than by accident: content resolves it
through a registry, several guards AND together, and any refusal refuses.
The rule still holds for a plain token — a second binding would replace the
first, and for a publish guard that means protection quietly switched off in the
installation least likely to notice. Where a seam genuinely needs several
contributors, the plugin that declares it ships the registry that merges them —
content does, and protection registers into it.
What a port buys, concretely
MEDIA_ASSET_RESOLVER is the clearest example.
Content’s pure kernel can shape-check a media value — is this a uuid? Verifying
that the asset exists in this workspace and matches the field’s accept
needs the media table, which content cannot reach.
So content declares the port and injects it optionally. Media binds it.
With media absent, media fields still validate their shape and still store: they just skip the existence and restriction checks. The feature degrades rather than the server failing to boot.
Declaring your own
// in the plugin that wants extending
export const REVIEW_MODERATION = Symbol('REVIEW_MODERATION');
export interface ReviewModeration {
shouldHold(review: Review): Promise<boolean>;
}
@Injectable()
export class ReviewsService {
constructor(
@Optional()
@Inject(REVIEW_MODERATION)
private readonly moderation?: ReviewModeration
) {}
}Export the token and the interface from your package index, and document what must no-op.
A port is synchronous integration; an event is not
Use a port when the behaviour must run inside the operation — in the same transaction, changing what is written or what is returned. Localization scopes a list query and syncs siblings inside the write transaction; that cannot be an event.
Use a domain event when the reaction happens after the fact and the operation does not depend on it. Auditing is the example: it moved from a port to an outbox subscriber precisely because nothing about the write depends on the audit row existing yet.