A server plugin is a function returning a plain object:
interface ServerPlugin {
name: string;
module: Type | DynamicModule;
onPluginInit?(): void | Promise<void>;
migrations?: { dir: () => string; table: string };
docs?: PluginApiDocs;
}docs is the one people miss. It is this plugin’s contribution to the host’s
OpenAPI document — the security schemes its guards accept, which of them to
advertise as document-level alternatives, and an optional final pass over the
generated document. The host merges every plugin’s contribution before it
generates the reference.
Leave it out and your routes still work; they simply arrive in the API reference with no credential attached, so nobody trying a request from that page can authenticate one. A plugin that introduces a new way of authenticating is the case that needs it.
docs: {
securitySchemes: {
reviewsKey: { type: 'apiKey', in: 'header', name: 'X-Reviews-Key' }
},
defaultSecurity: ['reviewsKey']
}defaultSecurity is read as OR: listing two scheme names means “either
one”, not both.
The factory
// packages/reviews/server/src/lib/utils/reviews-plugin.ts
import { join } from 'node:path';
import type { ServerPlugin } from '@apograph/bootstrap-server';
import { ReviewsModule } from '../reviews.module';
export function ReviewsPlugin(config: ReviewsPluginConfig): ServerPlugin {
return {
name: 'reviews',
module: ReviewsModule.forRoot(config),
migrations: {
dir: () => join(__dirname, '../../../migrations'),
table: '__drizzle_migrations_reviews'
}
};
}A factory rather than a bare module, because that is what lets configuration be
passed at the composition root instead of read from process.env deep inside a
service.
migrations.table is this plugin’s own tracking table. Every plugin has one,
which is what keeps their migration histories independent.
The dynamic module
@Module({})
export class ReviewsModule {
static forRoot(config: ReviewsPluginConfig): DynamicModule {
return {
module: ReviewsModule,
controllers: [ListReviewsController, CreateReviewController],
providers: [
{ provide: REVIEWS_CONFIG, useValue: config },
ReviewsService
],
exports: [ReviewsService]
};
}
}Export only what another plugin legitimately needs. A service exported for convenience becomes a coupling somebody depends on.
Mark the module global: true when it binds a port other plugins consume —
that is how a port implementation reaches the plugin that declared it.
Reaching the database
import { InjectDatabase, type Database } from '@apograph/database';
@Injectable()
export class ReviewsService {
constructor(@InjectDatabase() private readonly db: Database) {}
}Annotate with Database, not the dialect-specific type, so a dialect change is
a one-line edit in one package rather than a sweep across every plugin.
Owning schema
Put your Drizzle tables in src/lib/schema and add a drizzle.config.ts
pointing at them. Inside the Apograph monorepo the db:generate target then
appears on the project automatically:
npx nx run reviews-server:db:generate --name=create_reviewsIn an app scaffolded by create-apograph-app there is no Nx; run drizzle-kit generate against that config directly. Either way, commit the emitted SQL —
apograph migrate (the host’s db:migrate in the monorepo) applies it with every
other plugin’s, because your plugin’s migrations descriptor is what the host
reads.
No foreign keys across plugin boundaries
Your table may not reference another plugin’s. A workspace_id or a user_id
is a plain uuid column, and the relationship is enforced in your service layer.
That is what allows a plugin to be removed, and what stops the schema becoming one interlocked graph nobody can take a piece out of.
Guards and permissions
Use the existing ones. Identity exports the session guard and the permissions decorator; workspaces exports the membership guard and the current-workspace decorator.
@Controller('reviews')
@UseGuards(WorkspaceGuard)
export class ListReviewsController {
@Get()
@RequirePermissions(PERMISSIONS.REVIEWS_READ)
list(@CurrentWorkspace() workspaceId: string) { … }
}Two rules that the codebase’s own review checklist treats as non-negotiable:
- Permissions come from a constant, never a string literal. A typo in
@RequirePermissions('reviews:raed')is a route that checks a permission nobody holds — or, worse, one that nobody notices is unchecked. - Lock a contended invariant; do not count then write. Reading a count and then inserting is a race. Take the lock, or use a constraint and handle the violation.
Lifecycle
onPluginInit runs before the Nest application is created, in list order.
It is for setup that must complete before the app boots — opening a connection,
validating configuration.
It is not the place for anything needing dependency injection: nothing is instantiated yet.
Contributing to the API reference
A plugin can declare the security schemes its guards accept, and decorate the generated OpenAPI document. The passes run in registration order, last writer winning.
What to read next
- Database access — transactions and the unit of work.
- Domain events — the outbox, and how auditing actually happens.
- DI ports — extending another plugin.
- Testing — the server end-to-end harness.
The server-plugin skill in the repository is the full checklist, including the
folder layout and the review-critical invariants.