cordis

Cordis

Cordis is a Crystal shard for contextual dependency injection, reactive service lifecycles, and event-driven plugin coordination.

It gives you:

  • a root Cordis::Context for application state
  • named services registered through reflection
  • declarative dependencies with inject
  • lifecycle nodes that activate, suspend, fail, and dispose reactively
  • scoped service resolution through isolation contexts
  • synchronous, background, parallel, serial, and typed events
  • interception contexts for configuration such as logger level and event timeouts

Requirements

  • Crystal >= 1.21.0
  • msgpack-crystal ~> 1.3.5

Installation

Add Cordis to shard.yml.

For a published shard, use its repository coordinates. For a local checkout:

dependencies:
  cordis:
    github: shpeckman/cordis

Then install dependencies:

shards install

Require the shard:

require "cordis"

Quick start

require "cordis"

class DatabaseService < Cordis::Service
  def initialize(ctx : Cordis::Context)
    super(ctx, "database")
  end

  def query(sql : String) : String
    "[]"
  end
end

Cordis.define_service("database", DatabaseService)

class UserRepository < Cordis::Service
  inject "database"

  def initialize(ctx : Cordis::Context)
    super(ctx, "user_repository")

    self.ctx.node.effect do
      users = self.ctx.database.query("SELECT * FROM users")
      self.ctx.logger.info("Loaded users: #{users}")

      -> {
        self.ctx.logger.info("UserRepository stopped")
        nil
      }
    end
  end
end

Cordis.define_service("user_repository", UserRepository)

app = Cordis::Context.new

repo = UserRepository.new(app)
sleep 20.milliseconds
puts repo.ctx.node.state

db = DatabaseService.new(app)
sleep 20.milliseconds
puts repo.ctx.node.state

app.node.dispose

Registering UserRepository before DatabaseService is safe. The repository stays PENDING until database is available and active.

Core concepts

Context

Cordis::Context is the runtime boundary for an application or plugin tree.

A context exposes:

  • ctx.root
  • ctx.parent
  • ctx.node
  • ctx.events
  • ctx.reflect
  • ctx.registry
  • ctx.logger
  • ctx.execution_context
  • ctx.isolate
  • ctx.intercept

Create the root context with:

app = Cordis::Context.new

Create a child context with:

child = app.extend_context

Child contexts inherit the root, execution context, event bus, registry, and reflection store. They receive their own logger facade so intercepted configuration can be honored per branch.

Services

A service inherits from Cordis::Service and registers itself by name:

class CacheService < Cordis::Service
  def initialize(ctx : Cordis::Context)
    super(ctx, "cache")
  end
end

Inside a service constructor, use self.ctx after super. The ctx parameter is the parent context; self.ctx is the service's own lifecycle context.

Dependencies

Declare dependencies with inject:

class Repository < Cordis::Service
  inject "database", "cache"
end

The service lifecycle node stays PENDING until every dependency resolves to an active service.

Add dependencies later with:

node.add_inject(["metrics"])

Retry a failed node with:

node.retry

Typed service getters

Generate strict getters with:

Cordis.define_service("database", DatabaseService)

This adds:

ctx.database  # DatabaseService
ctx.database? # DatabaseService?

The strict getter raises Cordis::Errors::ServiceNotInjected when the service is missing or inactive.

Dynamic access is also available:

ctx.service("database")
ctx.service?("database")
ctx.service_active?("database")
ctx.database

Lifecycle

Every service gets a Cordis::LifecycleNode.

States:

  • PENDING — dependencies are not met yet
  • LOADING — effects are being activated
  • ACTIVE — dependencies are met and effects are running
  • SUSPENDED — dependencies were lost and effects were torn down
  • FAILED — an effect or teardown raised
  • UNLOADING — disposal or suspension is in progress
  • DISPOSED — final state

Useful predicates:

node.pending?
node.loading?
node.active?
node.suspended?
node.failed?
node.unloading?
node.disposed?

Useful introspection:

node.inject
node.dependencies_met?
node.effect_count
node.disposable_count
node.last_error
node.dispose_reason
node.last_transition

Effects

Effects run while a node is active and return a teardown proc:

node.effect do
  connection = connect

  -> {
    connection.close
    nil
  }
end

Named effects are supported for debugging:

node.effect("database connection") do
  -> { nil }
end

Adding an effect to a disposed or unloading node raises Cordis::Errors::Disposed.

If an effect raises, the node moves to FAILED, already-started teardowns are run, and node.last_error is set. Call node.retry after fixing the underlying condition.

Disposal

Dispose a node with:

node.dispose
node.dispose("shutdown requested")

Dispose asynchronously with:

node.dispose_async

Disposal runs effect teardowns and service-provided cleanup tasks, then broadcasts dependency updates so dependents can suspend.

Reflection and registry

ReflectService is the dependency graph. It tracks service implementations and the lifecycle node that provided them.

ctx.reflect.get("database")
ctx.reflect.has?("database")
ctx.reflect.active?("database")
ctx.reflect.impl("database")
ctx.reflect.registered_names
ctx.reflect.keys
ctx.reflect.each do |impl|
  puts impl.name
end

RegistryService is a lightweight plugin registry for named plugin instances.

ctx.registry.plugin("metrics", MetricsPlugin.new)
ctx.registry.get("metrics")
ctx.registry.has?("metrics")
ctx.registry.names
ctx.registry.count
ctx.registry.delete("metrics")

Use reflection for injectable services. Use the registry for plugin bookkeeping that should not participate in dependency activation.

Events

Register a hook:

dispose = ctx.events.on("user/created") do |args|
  ctx.logger.info(args[0].as(String))
  nil
end

dispose.call

Register a one-shot hook:

ctx.events.once("boot") do |_args|
  nil
end

Inspect listeners:

ctx.events.listener_count("boot")
ctx.events.event_names

Execution styles:

ctx.events.emit("sync")
ctx.events.background("async")
ctx.events.parallel("fan_out")
ctx.events.serial("pipeline")
ctx.events.bail("pipeline")
  • emit runs hooks synchronously.
  • background runs hooks without blocking.
  • parallel runs hooks concurrently and raises Cordis::Errors::AggregateHook if any hook fails or times out.
  • serial runs hooks in order and returns the first non-nil result.
  • bail delegates to serial.

Cancellable hooks

Use on_cancellable when a hook should observe timeouts or shutdown:

ctx.events.on_cancellable("work") do |args, token|
  token.on_cancel do
    cleanup
    nil
  end

  perform_work
  nil
end

A timed-out parallel or serial call cancels its token. Normal hooks keep their existing signature and behavior.

Shut down async event work with:

ctx.events.shutdown

After shutdown, async event methods raise Cordis::Errors::Disposed.

Typed events

Create a typed event channel:

user_created = ctx.events.typed("user/created", String)

user_created.on do |name|
  ctx.logger.info("created #{name}")
end

user_created.emit("alice")

The dynamic string-based event API remains available.

Isolation

Isolation lets different branches resolve the same logical service name differently.

api = app.isolate("api")
worker = app.isolate("worker")

Isolation keys resolve upward through the context parent chain.

Interception

Interception stores branch-local configuration:

debug = app.intercept("logger", "debug".as(Cordis::Any))
debug.logger.level

Supported built-in interception keys:

  • "logger" — logger level as a String, Hash(String, String), or MessagePack payload
  • "events" — default event timeout as milliseconds using Int32/Int64, seconds using Float64, or a hash with "timeout_ms" / "timeout_s"

Examples:

ctx.intercept("logger", "error".as(Cordis::Any))
ctx.intercept("events", 250.as(Cordis::Any))
ctx.intercept("events", {"timeout_ms" => "250"}.as(Cordis::Any))

Errors

Cordis provides specific exception types:

  • Cordis::Errors::ServiceNotFound
  • Cordis::Errors::ServiceNotInjected
  • Cordis::Errors::DuplicateService
  • Cordis::Errors::ServiceNotProvided
  • Cordis::Errors::DependencyTimeout
  • Cordis::Errors::AggregateHook
  • Cordis::Errors::Lifecycle
  • Cordis::Errors::Disposed

AggregateHook exposes the underlying failures through #errors.

Relation to deepseek-harness cordis

Cordis is a Crystal port of the cordis plugin architecture vendored in deepseek-ai/deepseek-harness (vendor/cordis, TypeScript; see its cordis primer). The module layout maps one-to-one:

Cordis shard deepseek-harness Role
src/cordis/context.cr context.ts root/child contexts, extend/isolate/intercept
src/cordis/service.cr service.ts base service class; registers itself on construction
src/cordis/lifecycle_node.cr fiber.ts per-plugin lifecycle state machine and effects
src/cordis/reflect.cr reflect.ts service store: provide/get/set, dependency notification
src/cordis/registry.cr registry.ts plugin registry
src/cordis/events.cr events.ts event bus with dispatch modes
src/cordis/logger.cr logger.ts logger service with intercepted level
src/cordis/utils.cr utils.ts DisposableList and shared vocabulary
src/cordis/errors.cr fiber.ts (CordisError, ValidationError) raiseable error types
src/cordis/macros.cr module augmentation + @Inject decorator typed service accessors

Shared core: contexts with isolation and per-service interception, services that register themselves on construction, dependency-driven lifecycle nodes (upstream Fiber uses PENDING/LOADING/ACTIVE/FAILED/DISPOSED/UNLOADING; the shard adds SUSPENDED), effects with reverse-order teardown, strict service reads gated on the provider being active, and internal/* events driving dependency refresh.

Where the shard differs:

  • Language idioms — upstream wraps every context in a JS Proxy so any property read resolves services, and augments TS interfaces (Context, Events) for typing. The shard uses method_missing plus the define_service macro for typed accessors.
  • Plugin model — upstream loads ctx.plugin(fn | class | { apply }, config) with standard-schema config validation, update()/restart, and HMR through an internal/update waterfall. The shard's RegistryService is a name→instance map; there is no config schema or reload.
  • Events — upstream adds waterfall and listener options (prepend, global, context filter); its bail is synchronous. The shard's bail aliases async serial, and it adds background (tracked fibers) and timeout-capped parallel via CancellationToken (upstream has no timeouts).
  • Reflection — upstream supports computed accessor properties, mixin (exposing service members on ctx), and a per-service check availability predicate. The shard has none of these and instead adds has?/active?/registered_names inspection.
  • Logger — upstream is a callable factory for named loggers with exporters, per-level thresholds, a buffer, and printf formatting. The shard is a level-gated wrapper over Crystal's std Log.
  • Cancellation — the shard's CancellationToken exists because Crystal fibers cannot be aborted; upstream relies on promise semantics.
  • Errors — upstream raises CordisError code strings and ValidationError; the shard has an Errors::* class hierarchy (ServiceNotFound, DuplicateService, AggregateHook, DependencyTimeout, …).

Candidate alignments (ideas, not implemented):

  • a waterfall dispatch mode
  • listener prepend / global options
  • a service check availability predicate
  • config schema validation
  • update(config) reload on LifecycleNode
  • named logger facades

Examples

Run the basic example:

crystal run example/app.cr

Run the reactive lifecycle example:

crystal run example/reactive_app.cr

Development

Install dependencies:

shards install

Run the specs:

crystal spec
Repository

cordis

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • 19 days ago
  • October 14, 2026
License

MIT License

Links
Synced at

Wed, 14 Oct 2026 12:59:46 GMT

Languages