| title | Service registry — resolve and override |
|---|---|
| navTitle | Service Registry |
| description | How plugins expose and consume services in ObjectStack — register a service, resolve it by name, see the standard services, and replace a core one. |
ObjectStack uses a lightweight Service Locator pattern for Dependency Injection. Services are the primary way plugins expose and consume functionality.
- 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.
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.
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.
// 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 } });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).
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.
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.
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.