Features Apograph CMS on GitHub

DI ports

The server-side inversion that lets one plugin extend another with no dependency between them.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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

PortDeclared byBound byFor
CONTENT_ENTRY_EXTENSIONcontenti18nRow-per-locale scoping, create stamping, shared-field sync
MEDIA_ASSET_RESOLVERcontentmediaVerifying a media id exists and matches accept
CONTENT_CATALOGidentitycontentWhat content types exist, for grants
TOOL_PROVIDERtoolscontent, media, i18nContributing agent tools
COPILOT_TOOL_PROVIDERcopilotcontent and othersContributing copilot tools
CONTENT_PUBLISH_GUARDcontentprotectionWhether this caller may publish this entry now
ACTIVITY_RECORDERidentityactivityDeprecated — 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.