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
const settings = ctx.getService('settings');
ctx.registerService('cache', new RedisCacheProvider({
url: settings.get('redis.url'),
}));
},
};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 settings = ctx.getService('settings');
const pool = await createPool(settings.get('database'));
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('analytics')) {
const analytics = ctx.getService<IAnalytics>('analytics');
analytics.track('page_view', { url: '/dashboard' });
}Standard Services
The core ecosystem defines several standard service contracts:
| Service Name | Interface | Provider Example |
|---|---|---|
http-server | IHttpServer | plugin-hono-server, adapter-nextjs |
data | IDataEngine | @objectstack/objectql (drivers implement IDataDriver) |
auth | IAuthService | plugin-auth |
api-registry | ApiRegistry | @objectstack/core |
cache | ICacheService | Redis, Memcached, or in-memory |
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)
export const dbPlugin: Plugin = {
name: 'database',
async init(ctx) {
const settings = ctx.getService('settings');
const pool = await createPool(settings.get('database'));
ctx.registerService('data', new PostgresDriver(pool));
this.db = pool;
},
async destroy() {
await this.db.close(); // Clean shutdown
},
};The Plugin interface defines init, start?, and destroy? — there is no
stop hook, and destroy() takes no arguments.