How Cordis Resolves Configuration for Services: A Deep Dive into the Prototype-Based Strategy
Cordis resolves service configuration by traversing the Context.intercept prototype chain, collecting configuration fragments, and merging them via the Service.resolveConfig method with optional service-specific Config.merge hooks.
The Cordis framework (maintained in the cordiverse/cordis repository) implements a deterministic, prototype-based strategy to build final configuration objects for every service. When a plugin requests a service like the logger or timer, Cordis aggregates configuration contributions from across the plugin tree. This process centers on the Service.resolveConfig static symbol defined in packages/core/src/service.ts and the intercept object stored on the Context.
The Configuration Resolution Pipeline
Cordis constructs service configuration through a six-step deterministic pipeline. Each step leverages specific source files in the core and loader packages.
Step 1: Creating the Intercept Map
When a context is instantiated, Cordis creates an intercept object that holds configuration fragments contributed by plugins. This object forms a prototype chain so that later plugins can shadow earlier ones without mutating the original fragments.
The intercept mechanism is defined in packages/core/src/utils.ts, where Context.intercept is initialized. This prototype chain is crucial for hierarchical configuration inheritance.
Step 2: Invoking Service.resolveConfig
Every service class inherits a static symbol Service.resolveConfig. The loader invokes this resolver via Service.prototype[Service.resolveConfig].call(this) during service initialization.
This call occurs in packages/loader/src/config/entry.ts, where the loader prepares to hydrate a service instance with its final configuration.
Step 3: Gathering Configuration Fragments
Inside Service.resolveConfig (implemented in packages/core/src/service.ts), the code walks up the prototype chain of the intercept object. It collects any own property named after the service (intercept[serviceName]). The collected entries are unshifted so that more-generic (higher-level) entries are placed before more-specific ones in the final array.
Step 4: Integrating Base and Head Configs
Callers can supply two optional configuration injections:
- Base config: Default values prepended to the fragment array
- Head config: Runtime-only overrides appended to the fragment array
This allows the loader to inject temporary overrides (such as command-line flags) while preserving plugin defaults and service hardcoded defaults.
Step 5: Merging Strategy
Cordis applies one of two merging strategies based on service definition:
- If the service defines a static
Configclass with amerge(...configs)method, that method is invoked to perform deep merging, validation, or transformation. - Otherwise, Cordis falls back to a shallow merge using
Object.assign({}, ...configs).
This architecture gives services full control over how their configuration fragments combine.
Step 6: Storing the Final Configuration
The merged object is returned and stored under the service’s internal config symbol (symbols.config). All subsequent calls to the service read from this merged configuration, ensuring consistent behavior throughout the service lifecycle.
Key Implementation Details
Prototype-Based Inheritance in Context.intercept
The prototype-based inheritance model lets later plugins override configuration without mutating earlier fragments. Because each plugin context extends the previous intercept object, configuration lookups naturally resolve to the most specific available value while maintaining access to parent values up the chain.
The Service.resolveConfig Method Signature
The resolution logic lives in packages/core/src/service.ts. The method signature effectively accepts:
- The service instance (
this) - Optional base configuration
- Optional head configuration
It returns the fully merged configuration object that has been validated and transformed according to the service's Config.merge implementation.
Practical Implementation Examples
Defining a Service with Custom Merge Logic
Services can implement deep merging by defining a static Config class:
// src/services/example.ts
import { Service } from 'cordis'
export class ExampleService extends Service<ExampleConfig> {
static Config = class {
static merge(...configs: Partial<ExampleConfig>[]) {
// Deep-merge example (you could use lodash.merge, etc.)
const result: ExampleConfig = { enabled: true, options: {} }
for (const cfg of configs) Object.assign(result, cfg)
return result
}
}
// Service logic …
}
This implementation in packages/core/src/service.ts checks for the presence of Config.merge before falling back to shallow assignment.
Contributing Configuration via Plugins
Plugins inject configuration fragments through the intercept object:
// plugins/my-plugin.ts
export default {
name: 'my-plugin',
apply(ctx) {
// Append config fragment for ExampleService
ctx.intercept.ExampleService = { enabled: false, options: { foo: 42 } }
},
}
According to packages/core/src/utils.ts, this assignment extends the prototype chain, allowing the resolution algorithm to discover the fragment during the walk-up phase.
Runtime Resolution in the Loader
The loader invokes the resolver during plugin registration, as seen in packages/loader/src/config/entry.ts:
// loader logic (excerpt)
const service = ctx.registry.service('ExampleService')
const finalConfig = Service.prototype[Service.resolveConfig].call(service, undefined, { options: { bar: 7 } })
// finalConfig => { enabled: false, options: { foo: 42, bar: 7 } }
Result: The final configuration respects the default (enabled: true), the plugin’s fragment (enabled: false, foo: 42), and the runtime head (bar: 7).
Accessing Merged Configuration
Services access their resolved configuration via the internal symbols export:
// Inside ExampleService methods
const cfg = this[symbols.config] // <-- the merged config object
if (!cfg.enabled) return
// use cfg.options …
This pattern ensures that services always work with the fully resolved, merged configuration regardless of how many plugins contributed fragments.
Summary
- Cordis resolves configuration by walking the
Context.interceptprototype chain defined inpackages/core/src/utils.ts. - The six-step pipeline involves creating the intercept map, invoking
Service.resolveConfig, gathering fragments, integrating base/head configs, merging viaConfig.mergeorObject.assign, and storing tosymbols.config. - Prototype-based inheritance allows later plugins to shadow earlier configuration without mutation.
- Services control their merging behavior through the optional static
Config.mergemethod. - The loader in
packages/loader/src/config/entry.tsorchestrates resolution during plugin registration.
Frequently Asked Questions
How does Cordis handle configuration conflicts between plugins?
Cordis uses the intercept object's prototype chain to resolve conflicts. When multiple plugins define configuration for the same service, the resolution algorithm in packages/core/src/service.ts walks up the prototype chain and unshifts entries into an array. This places parent (more generic) configurations before child (more specific) ones. The final merge respects this order, with later values typically overwriting earlier ones unless the service implements a custom Config.merge method that handles conflicts differently.
What is the difference between base config and head config in Cordis?
Base config provides default values that are prepended to the configuration array, serving as fallback values when plugins don't specify a setting. Head config represents runtime-only overrides that are appended to the array, ensuring they take precedence over plugin-defined values. According to the implementation in packages/core/src/service.ts, base configs are added to the front of the fragments array while head configs are added to the end, creating a precedence hierarchy: base < plugin fragments < head.
Can a service prevent configuration merging and use only the latest fragment?
Yes. A service can implement a static Config class with a custom merge method that ignores intermediate values. For example, the merge method could return only the last element of the configs array: static merge(...configs) { return configs[configs.length - 1]; }. Since Service.resolveConfig in packages/core/src/service.ts invokes this method when present, the service has complete control over the merging strategy, including the ability to reject or filter specific configuration sources.
Where does the loader trigger configuration resolution for services?
The loader triggers resolution in packages/loader/src/config/entry.ts. Specifically, it calls Service.prototype[Service.resolveConfig].call(service, baseConfig, headConfig) when preparing to instantiate or update a service. This occurs during the plugin registration phase defined in packages/loader/src/index.ts, where the loader creates the Context, wires the intercept map, and hydrates services with their final merged configurations before the application starts accepting requests.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →