Service Registry
Dependency Injection mechanism for loose coupling between plugins
Service Registry
ObjectStack uses a lightweight Service Locator pattern for Dependency Injection. Services are the primary way plugins expose and consume functionality.
Concepts
- Service Name: A unique string identifier (e.g.,
http-server,data,auth). - Service Implementation: Any JavaScript object, class instance, or function.
- Service Contract: A TypeScript interface that defines the expected API surface.
Registering Services
Services should be registered during the init phase of your plugin:
import type { Plugin } from '@objectstack/core';
export const myPlugin: Plugin = {
name: 'my-cache-plugin',
async init(ctx) {
// Register a service with a concrete implementation
ctx.registerService('cache', new RedisCacheProvider({
url: 'redis://localhost:6379',
}));
},
};Take init-time configuration from the plugin's own options (as
CacheServicePlugin does) rather than from the
settings service: that service is an async, namespaced resolver —
await settings.get(namespace, key) returns a { value, source, locked, … }
envelope, not a synchronous config bag keyed by dotted paths — and it is only
guaranteed to be registered during your init if you declare
requiresServices: ['settings']. Settings-driven config usually belongs in
start instead, the way EmailServicePlugin binds the mail namespace there
and re-applies it on every change.
Factory Registration (Lazy)
Use registerServiceFactory when the service requires async initialization.
registerService stores a concrete instance as-is, so passing it a function
would just register the function object. The factory receives the plugin
context (and an optional scope id) and is wrapped in lifecycle management:
import { ServiceLifecycle } from '@objectstack/core';
ctx.registerServiceFactory('data', async (ctx) => {
const pool = await createPool({ url: 'postgres://localhost:5432/app' });
ctx.logger.info('Postgres pool ready');
return new PostgresDriver(pool);
}, ServiceLifecycle.SINGLETON);The lifecycle defaults to ServiceLifecycle.SINGLETON, so the factory runs once
on first access and the instance is cached. Use ServiceLifecycle.SCOPED for a
per-scope (e.g. per-project) instance, or ServiceLifecycle.TRANSIENT to create
a fresh instance on every access.
Consuming Services
// Synchronous retrieval (if already registered)
const http = ctx.getService<IHttpServer>('http-server');
http.get('/hello', (req, res) => res.send('Hello'));
// Async retrieval (for factory- or scope-registered services)
const db = await ctx.getServiceScoped<IDataEngine>('data', scopeId);
const users = await db.find('user', { where: { active: true } });Optional Services
ctx.getService throws when a service is not registered. To probe optionally,
check the registry map with ctx.getServices():
if (ctx.getServices().has('search')) {
const search = ctx.getService<ISearchService>('search');
await search.index('account', record.id, record);
}Presence is not capability. A registered service may be a degraded fallback
or an outright stub — the kernel auto-injects in-memory fallbacks into the
metadata, cache, queue, job, and i18n slots when no plugin provides
them, and those occupy the real slot. They say so with the __serviceInfo
descriptor (ADR-0076 D12), and consumers are expected to read it rather than
trust mere registration:
import { readServiceSelfInfo } from '@objectstack/spec/api';
const cache = ctx.getServices().get('cache');
const self = readServiceSelfInfo(cache); // undefined ⇒ a real implementation
if (cache && self?.status !== 'stub') {
// Safe: a real implementation, or a `degraded` one that genuinely works.
} else if (self) {
ctx.logger.info(`cache is a stub — ${self.message}`);
}status: 'degraded' means real work with reduced capability (all five kernel
fallbacks above declare it); status: 'stub' means the answer is fabricated and
must not be used for real work. handlerReady: false additionally means no HTTP
handler serves it.
Dev mode adds no fakes here: @objectstack/plugin-dev registers no
implementations of its own — it composes the same real plugins (ObjectQL, the
in-memory driver, auth, security, the HTTP server, REST) you would compose in
production — so a slot no plugin fills is empty in dev exactly as in production
(ADR-0115).
Standard Services
The core ecosystem defines several standard service contracts:
| Service Name | Interface | Provider Example |
|---|---|---|
http-server | IHttpServer | plugin-hono-server |
data | IDataEngine | @objectstack/objectql (drivers implement IDataDriver) |
auth | IAuthService | plugin-auth |
cache | ICacheService | @objectstack/service-cache (memory adapter; its Redis adapter is still a skeleton that throws) — otherwise the kernel's in-memory fallback |
lifecycle | LifecycleService (@objectstack/objectql) | Registered by ObjectQLPlugin — enforces object lifecycle declarations (ADR-0057 retention/rotation/archival); call sweep() for an on-demand pass |
The logger is not a registered service — it is exposed directly as ctx.logger
(the Logger contract). Inter-plugin events also do not go through a service:
use ctx.hook(name, handler) and ctx.trigger(name, ...args) instead.
Replacing Core Services
Swap any core component by providing an alternative plugin:
// Replace the default HTTP server with a custom one
export const customHttpPlugin: Plugin = {
name: 'custom-http',
async init(ctx) {
ctx.replaceService('http-server', new FastifyServer());
},
};registerService always throws if the name is already registered — there is no
strict-mode toggle and no last-registered-wins behavior. To swap an existing
core service, use ctx.replaceService(name, implementation), which replaces the
current instance and throws if the service does not yet exist.
Service Lifecycle
Services follow the plugin lifecycle:
init— Register servicesstart— Services are now available to all pluginsdestroy— Clean up resources (close connections, flush buffers)
let pool: Pool | undefined;
export const dbPlugin: Plugin = {
name: 'database',
async init(ctx) {
pool = await createPool({ url: 'postgres://localhost:5432/app' });
ctx.registerService('data', new PostgresDriver(pool));
},
async destroy() {
await pool?.close(); // Clean shutdown
},
};destroy() receives no context, so keep any handle you need to close in the
plugin's own scope — a class field, or a module-level binding as above. Plugin
declares no state slot, so stashing it on this does not typecheck.
The Plugin interface defines init, start?, and destroy? — there is no
stop hook, and destroy() takes no arguments.