Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

DI ports

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

Documents 0.13.1 Updated Edit this page Report a problem

On this page

The admin has slots. The server has ports:

  1. The plugin that wants extending declares a Symbol token and an interface.
  2. It injects the token @Optional().
  3. 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:

PortDeclared byBound byFor
CONTENT_ENTRY_EXTENSIONcontenti18nRow-per-locale scoping, create stamping, shared-field sync, locale filters
MEDIA_ASSET_RESOLVERcontentmediaChecking a media id exists and matches accept; expanding it on reads
CONTENT_CATALOGworkspacescontentWhich content types exist, for a workspace’s grants
CONTENT_ENTRY_COUNTERworkspacescontentHow many entries a workspace holds
WORKSPACE_DIRECTORYidentityworkspacesChecking that a new API token’s workspaces exist
COPILOT_ATTACHMENT_RESOLVERcopilotmediaA chat attachment’s asset ids
MAIL_DISPATCHERmailmailQueuing an invitation or reset message; injected by users
ACTIVITY_RECORDERidentityactivityDeprecated — auditing moved to the outbox

Registries, which take any number of contributions:

RegistryRegistrarDeclared byUsed byFor
ContentPublishGuardRegistrycontentPublishGuardRegistrarcontentprotectionMay this caller publish this entry now? First refusal wins
ContentReadScopeRegistrycontentReadScopeRegistrarcontentsegmentsNarrowing a public read; fragments are AND-ed, so a scope can only subtract rows
EntryWriteExtensionRegistryentryWriteExtensionRegistrarcontentsegmentsState written beside an entry’s values in the same transaction
EntryFilterProviderRegistryentryFilterProviderRegistrarcontentsegments, protectionVirtual filterable fields on the records list
ProposalApplierRegistrycopilotAppliersRegistrarcopilotcontent, i18n, media, segmentsApplying a copilot proposal of a given kind
ToolRegistryregister() from onModuleInittoolscontent, i18n, media, activity, alarms, users, segments, protectionAgent 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.