The admin has slots. The server has ports:
- The plugin that wants extending declares a
Symboltoken and an interface. - It injects the token
@Optional(). - The plugin that provides the behaviour binds the token in its (global) module.
The extended plugin never imports the extender. Remove the extender and the
port is simply undefined.
Declare a port
// @acme/reviews-server — src/lib/review-moderation.port.ts
import type { CreateReviewDto } from './create-review.dto';
export const REVIEW_MODERATION = Symbol('REVIEW_MODERATION');
export interface ReviewModeration {
/** Return a reason to hold the review, or null to accept it. */
hold(workspaceId: string, review: CreateReviewDto): Promise<string | null>;
}// @acme/reviews-server — reviews.service.ts
import { Inject, Injectable, Optional, UnprocessableEntityException } from '@nestjs/common';
import type { CreateReviewDto } from './create-review.dto';
import { REVIEW_MODERATION, type ReviewModeration } from './review-moderation.port';
@Injectable()
export class ReviewsService {
constructor(
@Optional()
@Inject(REVIEW_MODERATION)
private readonly moderation?: ReviewModeration
) {}
async create(workspaceId: string, dto: CreateReviewDto) {
const reason = await this.moderation?.hold(workspaceId, dto);
if (reason) throw new UnprocessableEntityException(reason);
// …insert
}
}Export the token and the interface from the package index.
Bind a port
// @acme/moderation-server — moderation.module.ts
import { Module, type DynamicModule } from '@nestjs/common';
import { REVIEW_MODERATION } from '@acme/reviews-server';
import { BannedWordsModeration } from './banned-words.moderation';
@Module({})
export class ModerationModule {
static forRoot(): DynamicModule {
return {
module: ModerationModule,
global: true, // how the binding reaches ReviewsService
providers: [
BannedWordsModeration,
{ provide: REVIEW_MODERATION, useExisting: BannedWordsModeration }
],
exports: [REVIEW_MODERATION]
};
}
}This is exactly how media binds content’s MEDIA_ASSET_RESOLVER:
// packages/media/server/src/lib/media.module.ts
MediaAssetResolverQuery,
{ provide: MEDIA_ASSET_RESOLVER, useExisting: MediaAssetResolverQuery }Register into a shipped registry
A token binds once — Nest has no multi-provider, so a second binding silently replaces the first. Where many plugins must contribute, the declaring plugin ships a registry plus a registrar helper you list among your providers.
Example: refuse to publish an entry while it has open reviews, via content’s publish-guard registry.
// @acme/reviews-server — open-reviews.publish-guard.ts
import { Injectable } from '@nestjs/common';
import type {
ContentPublishGuard,
ContentPublishGuardContext,
PublishVerdict
} from '@orthacms/content-server';
import { InjectDatabase, type Database } from '@orthacms/database';
import { and, count, eq, isNull } from 'drizzle-orm';
import { reviews } from './schema';
@Injectable()
export class OpenReviewsPublishGuard implements ContentPublishGuard {
constructor(@InjectDatabase() private readonly db: Database) {}
async check({ entryId, workspaceId }: ContentPublishGuardContext): Promise<PublishVerdict> {
const [{ open }] = await this.db
.select({ open: count() })
.from(reviews)
.where(
and(
eq(reviews.workspaceId, workspaceId),
eq(reviews.entryId, entryId),
isNull(reviews.resolvedAt)
)
);
if (open === 0) return { allowed: true };
return {
allowed: false,
status: 409,
code: 'open_reviews',
message: `${open} review(s) on this entry are still open.`
};
}
}// reviews.module.ts — providers
import { contentPublishGuardRegistrar } from '@orthacms/content-server';
providers: [
OpenReviewsPublishGuard,
// Registers on bootstrap; injects the registry optionally, so the app
// still boots without ContentPlugin
contentPublishGuardRegistrar('reviews', OpenReviewsPublishGuard)
]A refused publish (POST /api/content/:type/:id/publish, and every other
publish path) answers with your verdict:
// 409 Conflict
{ "statusCode": 409, "code": "open_reviews", "message": "2 review(s) on this entry are still open." }Guards run in turn; the first refusal wins. status is 400, 403 or 409.
The shipped ports
Tokens, each bound once:
| Port | Declared by | Bound by | For |
|---|---|---|---|
CONTENT_ENTRY_EXTENSION | content | i18n | Row-per-locale scoping, create stamping, shared-field sync, locale filters |
MEDIA_ASSET_RESOLVER | content | media | Checking a media id exists and matches accept; expanding it on reads |
CONTENT_CATALOG | workspaces | content | Which content types exist, for a workspace’s grants |
CONTENT_ENTRY_COUNTER | workspaces | content | How many entries a workspace holds |
WORKSPACE_DIRECTORY | identity | workspaces | Checking that a new API token’s workspaces exist |
COPILOT_ATTACHMENT_RESOLVER | copilot | media | A chat attachment’s asset ids |
MAIL_DISPATCHER | Queuing an invitation or reset message; injected by users | ||
ACTIVITY_RECORDER | identity | activity | Deprecated — auditing moved to the outbox |
Registries, which take any number of contributions:
| Registry | Registrar | Declared by | Used by | For |
|---|---|---|---|---|
ContentPublishGuardRegistry | contentPublishGuardRegistrar | content | protection | May this caller publish this entry now? First refusal wins |
ContentReadScopeRegistry | contentReadScopeRegistrar | content | segments | Narrowing a public read; fragments are AND-ed, so a scope can only subtract rows |
EntryWriteExtensionRegistry | entryWriteExtensionRegistrar | content | segments | State written beside an entry’s values in the same transaction |
EntryFilterProviderRegistry | entryFilterProviderRegistrar | content | segments, protection | Virtual filterable fields on the records list |
ProposalApplierRegistry | copilotAppliersRegistrar | copilot | content, i18n, media, segments | Applying a copilot proposal of a given kind |
ToolRegistry | register() from onModuleInit | tools | content, i18n, media, activity, alarms, users, segments, protection | Agent tools |
All registrar helpers are exported from the declaring package
(@orthacms/content-server, @orthacms/copilot-server).
Two rules
- Call unconditionally; no-op inside. The consumer calls every method on every write. An implementation must no-op for cases it does not apply to — i18n’s extension does nothing for a non-localized type.
- A seam many plugins share is a registry, never a token. As a token, a second publish guard would replace protection’s — protection quietly off.
Port or event?
Port — the behaviour must run inside the operation: same transaction, changing what is written or returned. Localization scopes a list query and syncs siblings inside the write.
Domain event — the reaction happens after the commit and the operation does not depend on it. Auditing moved from a port to an outbox subscriber for exactly that reason.