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
    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 NameInterfaceProvider Example
http-serverIHttpServerplugin-hono-server, adapter-nextjs
dataIDataEngine@objectstack/objectql (drivers implement IDataDriver)
authIAuthServiceplugin-auth
api-registryApiRegistry@objectstack/core
cacheICacheServiceRedis, Memcached, or in-memory
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)
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.

On this page