How to Create and Provide a Custom Service in Cordis

To create and provide a custom service in Cordis, extend the abstract Service class from @cordis/core, implement the init lifecycle hook, and register the class using ctx.provide(name, ServiceClass), which attaches the service to the context hierarchy and exposes it via property access on all child contexts.

Cordis is a context-driven framework where reusable functionality is encapsulated as discrete units living on a shared Context. When you need to expose business logic that multiple plugins can share, you must create and provide a custom service that follows the framework's shadow-based injection system and lifecycle conventions.

Understanding the Service Architecture

The foundation of every Cordis service is the Service<out T = never> abstract class defined in packages/core/src/service.ts. This base class establishes the contract for service initialization and disposal, using a generic type parameter to represent the service's public interface. When you provide a service, Cordis creates a shadow object on every child context, enabling transparent access through the proxy system implemented in packages/core/src/reflect.ts.

The reflection system validates service names during registration to prevent duplicates, while the shadow architecture ensures that each child context can access the service without maintaining separate instances. This design supports hot-reloading and isolation, as demonstrated in packages/core/tests/shadow.spec.ts.

Defining a Custom Service

Create a TypeScript file that extends the Service base class and implements your specific functionality. The following example defines a counter service:

// src/services/my-counter.ts
import { Service } from '@cordis/core'

export class MyCounter extends Service<number> {
  private _value = 0

  // Called automatically when the service attaches to a context
  init() {
    console.log('MyCounter initialized')
  }

  // Public API exposed to consumers
  inc(delta = 1) {
    this._value += delta
  }

  get value() {
    return this._value
  }

  // Optional cleanup logic
  dispose() {
    console.log('MyCounter disposed')
  }
}

This implementation follows the pattern in packages/core/src/service.ts, where concrete services override lifecycle methods while inheriting shadow management infrastructure that handles context propagation.

Providing the Service to a Context

Use the provide method on a context instance to register your service under a unique name. This registration process, governed by the logic in packages/core/src/reflect.ts, validates the identifier and establishes the shadow chain:

import { createContext } from '@cordis/create'
import { MyCounter } from './services/my-counter'

async function main() {
  const rootCtx = await createContext()
  
  // Register under the name 'myCounter'
  rootCtx.provide('myCounter', MyCounter)
  
  // Access the service directly on the context
  rootCtx.myCounter.inc()
  console.log(rootCtx.myCounter.value) // → 1
}

main()

Calling ctx.provide() instantiates the service class and attaches it to the context hierarchy. The reflection proxy in packages/core/src/reflect.ts intercepts property access like ctx.myCounter and resolves it through the shadow system.

Consuming Services from Plugins

Once provided, the service becomes accessible as a property on any child context. Plugins can interact with the service without importing the class directly:

export default function myPlugin(ctx) {
  ctx.on('ready', () => {
    ctx.myCounter.inc(5)
    ctx.logger.info(`Counter value: ${ctx.myCounter.value}`)
  })
}

The shadow system ensures that ctx.myCounter resolves to the correct instance regardless of which child context the plugin occupies, maintaining isolation while sharing state through the parent context.

Hot Reloading and Service Shadows

Cordis supports hot module replacement (HMR) through the packages/hmr package. The shadow architecture allows service implementations to be swapped at runtime without destroying the context hierarchy. When you modify a service file, the HMR system updates the shadow reference, and existing code paths automatically receive the new implementation.

This behavior is verified in packages/hmr/tests/plugin-service.ts, where service updates propagate through the context tree without requiring manual re-registration via ctx.provide or context restarts.

Summary

  • Extend Service<T>: Create a class in packages/core/src/service.ts that implements the init() and optional dispose() lifecycle hooks.
  • Register with ctx.provide(): Supply a unique name and the service class to attach it to the context hierarchy; the system in packages/core/src/reflect.ts prevents duplicate registrations.
  • Access via property: Retrieve the service through ctx.serviceName on any child context, utilizing the shadow system for transparent resolution across the hierarchy.
  • Leverage shadows: Each child context maintains a shadow that forwards to the parent service, enabling isolation and hot-reloading as tested in packages/core/tests/shadow.spec.ts.
  • Support HMR: The packages/hmr package can replace service implementations dynamically while preserving active context states and existing references.

Frequently Asked Questions

What is the purpose of the generic type parameter in Service<T>?

The generic T in Service<out T = never> specifies the TypeScript type of the value your service exposes to consumers typing ctx.yourService. While the base implementation in packages/core/src/service.ts defaults to never, you should provide a concrete interface to enable type checking and autocompletion when accessing the service through the context proxy.

How does Cordis prevent service name collisions?

The registration logic in packages/core/src/reflect.ts validates the name parameter during ctx.provide(), throwing an error if the identifier is already registered on the current context. This ensures each service maintains a unique identity within the context hierarchy and prevents accidental overwrites that could break dependency chains.

Can a service access other services during its initialization phase?

Yes. The init() lifecycle hook receives the context as a parameter, allowing you to access previously provided services via ctx.otherService. This is safe when registration order respects dependencies; provide foundational services before those that depend on them to ensure proxies resolve correctly during initialization.

What happens to service data when a specific child context is disposed?

When a child context is disposed, Cordis removes the shadow references for that specific branch but preserves the parent service instance. The dispose() method is invoked only when the service's owning context is destroyed, ensuring that state persists for surviving contexts while allowing cleanup of context-specific resources.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →