ObjectStackObjectStack

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 NameInterfaceProvider Example
http-serverIHttpServerplugin-hono-server
dataIDataEngine@objectstack/objectql (drivers implement IDataDriver)
authIAuthServiceplugin-auth
cacheICacheService@objectstack/service-cache (memory adapter; its Redis adapter is still a skeleton that throws) — otherwise the kernel's in-memory fallback
lifecycleLifecycleService (@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:

  1. init — Register services
  2. start — Services are now available to all plugins
  3. destroy — 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.

On this page