# Best Practices for Writing Custom Cordis Services: A Complete Guide

> Master custom Cordis services with this guide. Learn best practices for building type-safe, testable services using generic Service<T>, ctx.plugin(), and Cordis patterns.

- Repository: [Cordiverse/cordis](https://github.com/cordiverse/cordis)
- Tags: best-practices
- Published: 2026-08-23

---

**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.

```ts
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`](https://github.com/cordiverse/cordis/blob/main/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()`:

```ts
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`](https://github.com/cordiverse/cordis/blob/main/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`.

```ts
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`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/service.ts). This pattern is used by the HMR service in [`packages/hmr/src/index.ts`](https://github.com/cordiverse/cordis/blob/main/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`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/service.ts)) orchestrates this process.

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

```ts
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`](https://github.com/cordiverse/cordis/blob/main/packages/loader/tests/isolate.spec.ts) demonstrates this pattern:

```ts
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`](https://github.com/cordiverse/cordis/blob/main/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`](https://github.com/cordiverse/cordis/blob/main/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

```ts
/**
 * 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:

```ts
import { events } from '@cordis/core'

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

```

The events system in [`packages/core/src/events.ts`](https://github.com/cordiverse/cordis/blob/main/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`](https://github.com/cordiverse/cordis/blob/main/packages/core/src/service.ts) detects this symbol and automatically wraps your instance with `createCallable` from [`packages/core/src/utils.ts`](https://github.com/cordiverse/cordis/blob/main/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.