Best Practices for Writing Custom Cordis Services: A Complete Guide

Extend the generic Service<T> class, register with ctx.plugin(), and follow Cordis's configuration, callable API, and isolation patterns to build type-safe, testable services.

Writing custom Cordis services requires understanding how the framework's dependency injection, lifecycle management, and configuration systems work together. This guide covers the architectural patterns used in cordiverse/cordis and shows you how to implement services that integrate cleanly with the core codebase.


Extend the Service Base Class Correctly

Every custom service in Cordis inherits from the Service class exported by @cordis/core. The generic parameter defines your configuration shape, while static properties control registration and merging behavior.

import { Service } from '@cordis/core'

export class MyService extends Service<{ foo?: string }> {
  // Optional: defines the registration key (defaults to class name)
  static provide = 'my-service'

  // Optional: custom configuration merging logic
  static Config = {
    merge(base = {}, head = {}) {
      return { ...base, ...head }
    },
  }

  constructor(ctx: Context, name = MyService.provide) {
    super(ctx, name)  // Let the base class handle context wiring
  }

  hello(name: string) {
    return `Hello, ${name}!`
  }
}

In packages/core/src/service.ts, the Service constructor (lines 28–31) automatically assigns this.ctx and this.name, ensuring each instance is bound to its specific context. The generic Service<TConfig> type parameter propagates through to configuration resolution, giving you full TypeScript inference.


Register Services with Context.plugin()

Services are never instantiated directly. Instead, pass your service class to ctx.plugin():

import { Context } from '@cordis/core'
import { MyService } from './my-service'

export function apply(ctx: Context) {
  ctx.plugin(MyService)  // Framework handles instantiation and registration
}

The Context implementation in packages/core/src/context.ts maintains a registry of active services. When you call ctx.plugin(), Cordis:

  1. Instantiates your service with the current context
  2. Registers it under the key from static provide
  3. Attaches lifecycle hooks for automatic cleanup

Implement Callable Services with symbols.invoke

Some services need to behave like functions—loggers, command engines, or factories. Cordis supports this through the symbols.invoke symbol. When present on your service prototype, the base constructor automatically wraps the instance using createCallable.

import { Service, symbols } from '@cordis/core'

export class LoggerService extends Service {
  static [symbols.invoke] = Symbol('invoke')

  log(message: string) {
    console.log(message)
  }
}

The callable wrapper creation happens at lines 27–33 of packages/core/src/service.ts. This pattern is used by the HMR service in packages/hmr/src/index.ts for advanced runtime behavior.


Handle Configuration Merging

Cordis merges configuration from multiple sources: base defaults, user config, and runtime overrides. The [symbols.resolveConfig] method in Service (lines 51–66 of packages/core/src/service.ts) orchestrates this process.

Define a custom Config.merge when you need deep merging, validation, or array handling:

static Config = {
  merge(base = {}, head = {}) {
    // Deep merge with explicit array replacement
    return deepmerge(base, head, { arrayMerge: 'replace' })
  },
}

Without a custom merge, Cordis performs a shallow object spread. Complex services should always implement explicit merging logic.


Test with the Loader Mock API

Isolate services during unit testing using @cordis/loader. The test suite in packages/loader/tests/isolate.spec.ts demonstrates this pattern:

import { loader } from '@cordis/loader'

loader.mock('my-dep', class Dep extends Service {
  get value() { return 42 }
})
  • loader.mock(name, class) registers a temporary service that your test context resolves instead of the real implementation
  • Mocks are automatically cleaned up between test runs
  • This keeps tests fast without requiring full context bootstrapping

Refer to line 22 of packages/loader/tests/isolate.spec.ts for complete mock usage examples.


Respect Context Isolation

Cordis supports isolates—independent contexts within the same process for hot-reloading and sandboxed plugins. Design services to avoid shared mutable state:

Anti-pattern Correct approach
Static properties holding state Instance properties on this
Global singletons Per-context instantiation via ctx.plugin()
Prototype mutations Constructor initialization

The Service base class guarantees isolation by binding each instance to exactly one context. Follow the TimerService implementation in packages/timer/src/index.ts for a production example of state management.


Follow Naming and Documentation Conventions

Adopt these patterns from the official codebase:

  • CamelCase class names: TimerService, HmrService
  • Kebab-case provide keys: my-service, timer-service
  • JSDoc on all public APIs: Enables IDE support and documentation generation
/**
 * Greets a user with a configurable prefix.
 * @param name - The name to include in the greeting
 * @returns The formatted greeting string
 */
public hello(name: string): string {
  return `${this.config.prefix ?? 'Hello'}, ${name}!`
}

Subscribe to Lifecycle Events

Decouple initialization logic from the constructor using the events module:

import { events } from '@cordis/core'

events.once('ready', () => this.initialize())
events.on('dispose', () => this.cleanup())

The events system in packages/core/src/events.ts provides the standard ready, dispose, and custom event channels. This is cleaner than overriding stop() or start() methods directly.


Summary

  • Extend Service<T> with a configuration generic for type-safe config access
  • Register via ctx.plugin() to leverage automatic lifecycle management
  • Use symbols.invoke sparingly for function-like service behavior
  • Implement Config.merge for predictable multi-source configuration
  • Mock with loader.mock() to isolate unit tests
  • Keep state instance-bound to support multiple isolates
  • Document and name consistently following official repository patterns

Frequently Asked Questions

How do I make a Cordis service callable like a function?

Add static [symbols.invoke] = Symbol('invoke') to your service class. The Service constructor in packages/core/src/service.ts detects this symbol and automatically wraps your instance with createCallable from packages/core/src/utils.ts, allowing invocations like ctx.myService(args).

What happens if I don't provide a static provide property?

Cordis defaults to using your class name as the registration key. Explicit provide values are recommended to prevent minification issues and to decouple your internal class name from the public API surface.

How does configuration merging work when multiple plugins provide defaults?

The [symbols.resolveConfig] method calls your Config.merge function with base configuration first, then head configuration. Your merge function receives two arguments and returns the combined result. Without a custom merge, Cordis performs a shallow { ...base, ...head } spread.

Can I register the same service class multiple times with different configurations?

Yes—each ctx.plugin(MyService, config) call creates a distinct instance bound to that specific context. Isolates prevent state leakage between registrations, making this safe for plugin systems that load user code dynamically.

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 →