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::Contextfor 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.rootctx.parentctx.nodectx.eventsctx.reflectctx.registryctx.loggerctx.execution_contextctx.isolatectx.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 yetLOADING— effects are being activatedACTIVE— dependencies are met and effects are runningSUSPENDED— dependencies were lost and effects were torn downFAILED— an effect or teardown raisedUNLOADING— disposal or suspension is in progressDISPOSED— 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")
emitruns hooks synchronously.backgroundruns hooks without blocking.parallelruns hooks concurrently and raisesCordis::Errors::AggregateHookif any hook fails or times out.serialruns hooks in order and returns the first non-nilresult.baildelegates toserial.
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 aString,Hash(String, String), or MessagePack payload"events"— default event timeout as milliseconds usingInt32/Int64, seconds usingFloat64, 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::ServiceNotFoundCordis::Errors::ServiceNotInjectedCordis::Errors::DuplicateServiceCordis::Errors::ServiceNotProvidedCordis::Errors::DependencyTimeoutCordis::Errors::AggregateHookCordis::Errors::LifecycleCordis::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
Proxyso any property read resolves services, and augments TS interfaces (Context,Events) for typing. The shard usesmethod_missingplus thedefine_servicemacro for typed accessors. - Plugin model — upstream loads
ctx.plugin(fn | class | { apply }, config)with standard-schema config validation,update()/restart, and HMR through aninternal/updatewaterfall. The shard'sRegistryServiceis a name→instance map; there is no config schema or reload. - Events — upstream adds
waterfalland listener options (prepend,global, contextfilter); itsbailis synchronous. The shard'sbailaliases asyncserial, and it addsbackground(tracked fibers) and timeout-cappedparallelviaCancellationToken(upstream has no timeouts). - Reflection — upstream supports computed
accessorproperties,mixin(exposing service members onctx), and a per-servicecheckavailability predicate. The shard has none of these and instead addshas?/active?/registered_namesinspection. - 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
CancellationTokenexists because Crystal fibers cannot be aborted; upstream relies on promise semantics. - Errors — upstream raises
CordisErrorcode strings andValidationError; the shard has anErrors::*class hierarchy (ServiceNotFound,DuplicateService,AggregateHook,DependencyTimeout, …).
Candidate alignments (ideas, not implemented):
- a
waterfalldispatch mode - listener
prepend/globaloptions - a service
checkavailability predicate - config schema validation
update(config)reload onLifecycleNode- 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
cordis
- 0
- 0
- 0
- 0
- 1
- 19 days ago
- October 14, 2026
MIT License
Wed, 14 Oct 2026 12:59:46 GMT