https://github.com/tada5hi/orkos
A lightweight modular application orchestrator for TypeScript with dependency-ordered startup, shutdown, and topological module resolution.
https://github.com/tada5hi/orkos
dependency-resolution lifecycle-management modular-architecture module-loader module-orchestrator plugin-system startup-shutdown
Last synced: about 1 month ago
JSON representation
A lightweight modular application orchestrator for TypeScript with dependency-ordered startup, shutdown, and topological module resolution.
- Host: GitHub
- URL: https://github.com/tada5hi/orkos
- Owner: tada5hi
- License: mit
- Created: 2026-03-26T18:04:50.000Z (about 2 months ago)
- Default Branch: master
- Last Pushed: 2026-03-28T15:33:27.000Z (about 2 months ago)
- Last Synced: 2026-03-28T17:48:52.390Z (about 2 months ago)
- Topics: dependency-resolution, lifecycle-management, modular-architecture, module-loader, module-orchestrator, plugin-system, startup-shutdown
- Language: TypeScript
- Homepage:
- Size: 257 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 2
-
Metadata Files:
- Readme: README.MD
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# orkos 🎻
[![npm version][npm-version-src]][npm-version-href]
[![codecov][codecov-src]][codecov-href]
[![Master Workflow][workflow-src]][workflow-href]
[![Known Vulnerabilities][snyk-src]][snyk-href]
[![Conventional Commits][conventional-src]][conventional-href]
A lightweight modular application orchestrator for TypeScript.
Define modules with dependencies, and orkos sets them up in the right order and tears them down in reverse. Built on top of [eldin](https://github.com/tada5hi/eldin) for dependency injection.
## Core Philosophy
Application startup is deceptively complex: services depend on each other, initialization order matters, and teardown must reverse that order reliably. orkos handles this with a single, explicit pattern — modules declare their dependencies, and a topological sort determines the rest. No implicit wiring, no decorator magic, no runtime surprises. The shared [eldin](https://github.com/tada5hi/eldin) container gives modules a clean way to exchange services without tight coupling.
**Table of Contents**
- [Core Philosophy](#core-philosophy)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Modules](#modules)
- [defineModule](#definemodule)
- [External Modules](#external-modules)
- [Dependency Ordering](#dependency-ordering)
- [Module Lifecycle](#module-lifecycle)
- [Application API](#application-api)
- [Error Handling](#error-handling)
- [Usage with eldin](#usage-with-eldin)
- [Contributing](#contributing)
- [License](#license)
## Installation
```bash
npm install orkos eldin --save
```
## Quick Start
```typescript
import { Application } from 'orkos';
import type { IModule } from 'orkos';
import type { IContainer } from 'eldin';
class ConfigModule implements IModule {
readonly name = 'config';
async setup(container: IContainer): Promise {
container.register(ConfigToken, { useValue: { port: 3000 } });
}
}
class DatabaseModule implements IModule {
readonly name = 'database';
readonly dependencies = ['config'];
async setup(container: IContainer): Promise {
const config = container.resolve(ConfigToken);
const db = new Database(config);
await db.connect();
container.register(DatabaseToken, { useValue: db });
}
async teardown(container: IContainer): Promise {
const db = container.resolve(DatabaseToken);
await db.disconnect();
}
}
class HttpModule implements IModule {
readonly name = 'http';
readonly dependencies = ['config', 'database'];
async setup(container: IContainer): Promise {
const config = container.resolve(ConfigToken);
const server = createServer(config.port);
await server.listen();
}
}
const app = new Application({
modules: [
new ConfigModule(),
new DatabaseModule(),
new HttpModule(),
],
});
// Sets up in dependency order: config -> database -> http
await app.setup();
// Tears down in reverse: http -> database -> config
await app.teardown();
```
## Modules
A module is any object implementing the `IModule` interface:
```typescript
interface IModule {
readonly name: string;
readonly version?: string;
readonly dependencies?: (string | ModuleDependency)[];
setup(container: IContainer): Promise;
teardown?(container: IContainer): Promise;
onReady?(container: IContainer): Promise;
onError?(error: Error, container: IContainer): Promise;
}
```
| Property | Description |
|----------|-------------|
| `name` | Unique identifier for the module |
| `version` | Optional semver version string |
| `dependencies` | Array of module names or `ModuleDependency` objects |
| `setup()` | Called during startup with the shared DI container |
| `teardown()` | Optional cleanup, called during shutdown |
| `onReady()` | Called after all modules have been set up successfully |
| `onError()` | Called when this module's `setup()` throws an error |
Modules receive the application's shared [eldin](https://github.com/tada5hi/eldin) container. Use it to register and resolve dependencies across modules.
### ModuleDependency
Dependencies can be plain strings (module names) or objects with additional constraints:
```typescript
interface ModuleDependency {
name: string; // module name to depend on
version?: string; // semver range (e.g. '>=2.0.0', '^1.3.0')
optional?: boolean; // skip silently if not registered or resolvable
package?: string; // npm package name, if different from the module name
}
```
Examples:
```typescript
const dependencies = [
'config', // simple: depend on module named 'config'
{ name: 'database', version: '>=2.0.0' }, // require database v2+
{ name: 'cache', optional: true }, // skip if cache isn't available
{ name: 'redis', package: '@myorg/orkos-redis' }, // resolve from a different package name
];
```
The `package` field is used during [external module resolution](#external-modules) — it tells orkos which npm package to `import()` when the module isn't already registered. Without it, orkos uses the `name` as both the module name and the package name.
## defineModule
The `defineModule` helper provides a convenient way to create configurable modules with typed options and defaults.
### Inline Definition
```typescript
import { defineModule } from 'orkos';
const CacheModule = defineModule<{ driver: 'memory' | 'redis'; ttl: number }>({
name: 'cache',
dependencies: ['config'],
defaults: { driver: 'memory', ttl: 3600 },
async setup(options, container) {
// options is { driver, ttl } with defaults merged
container.register(CacheToken, {
useFactory: () => createCache(options),
});
},
async teardown(options, container) {
const cache = container.resolve(CacheToken);
await cache.close();
},
});
const app = new Application({
modules: [
CacheModule(), // use defaults
CacheModule({ driver: 'redis' }), // override driver, keep ttl default
CacheModule(false), // disable (no-op)
],
});
```
### Factory Definition
Wrap a class-based `IModule` implementation with options:
```typescript
import { defineModule } from 'orkos';
class CacheModule implements IModule {
readonly name = 'cache';
constructor(private options: { driver: string; ttl: number }) {}
async setup(container: IContainer) {
// use this.options
}
}
const createCacheModule = defineModule<{ driver: string; ttl: number }>({
defaults: { driver: 'memory', ttl: 3600 },
factory: (options) => new CacheModule(options),
});
const app = new Application({
modules: [
createCacheModule(),
createCacheModule({ driver: 'redis' }),
],
});
```
## External Modules
Modules can be referenced by npm package name and resolved automatically via dynamic `import()`. This is useful for sharing modules across projects as npm packages.
### Referencing External Modules
Pass a string (package name) or a `[string, options]` tuple anywhere you'd pass an `IModule`:
```typescript
const app = new Application({
modules: [
new ConfigModule(), // internal module (IModule)
'orkos-redis', // external: resolve from node_modules
['orkos-redis', { host: '10.0.0.1' }], // external with options
],
});
// Or via addModule:
app.addModule('orkos-redis');
app.addModule(['orkos-redis', { host: '10.0.0.1' }]);
```
External modules are resolved lazily — the `import()` call happens during `setup()`, not at registration time. This keeps `addModule()` synchronous.
### Package Convention
An orkos-compatible npm package must export a **default export** that is either a `ModuleFactory` (function) or an `IModule` (object).
**Factory export** (recommended — supports options):
```typescript
// orkos-redis/src/index.ts
import { defineModule } from 'orkos';
export default defineModule<{ host: string; port: number }>({
name: 'orkos-redis',
defaults: { host: 'localhost', port: 6379 },
async setup(options, container) {
const client = createRedisClient(options);
container.register(RedisToken, { useValue: client });
},
async teardown(options, container) {
const client = container.resolve(RedisToken);
await client.disconnect();
},
});
```
**Plain IModule export** (no options support):
```typescript
// orkos-metrics/src/index.ts
import type { IModule } from 'orkos';
const metricsModule: IModule = {
name: 'orkos-metrics',
async setup(container) {
// initialize metrics collection
},
};
export default metricsModule;
```
When orkos imports a package:
- **Function** → calls it as a `ModuleFactory` with provided options (or no args)
- **Object with `name` and `setup`** → uses it directly as an `IModule`
- **Anything else** → throws `INVALID_MODULE_EXPORT`
> **Name matching:** The module's `name` must match the package name used to reference it. If you add `'orkos-redis'`, the exported module must have `name: 'orkos-redis'`. A mismatch throws `INVALID_MODULE_EXPORT`. To use a different module name, declare the mapping via the [`package` field](#moduledependency) in a `ModuleDependency`.
### End-to-End Example
**Package author** publishes `orkos-redis`:
```typescript
// orkos-redis/src/index.ts
import { defineModule } from 'orkos';
export default defineModule<{ url: string }>({
name: 'orkos-redis',
defaults: { url: 'redis://localhost:6379' },
async setup(options, container) {
const client = new RedisClient(options.url);
await client.connect();
container.register(RedisToken, { useValue: client });
},
async teardown(options, container) {
const client = container.resolve(RedisToken);
await client.disconnect();
},
});
```
**Consumer** uses it in their application:
```typescript
import { Application } from 'orkos';
const app = new Application({
modules: [
new ConfigModule(),
['orkos-redis', { url: 'redis://prod:6379' }], // resolved via import('orkos-redis')
new AuthModule(), // can depend on 'orkos-redis'
],
});
await app.setup();
```
### Dependency Resolution Flow
When `setup()` is called, orkos resolves modules in this order:
1. **Resolve explicit externals** — strings and tuples passed to `addModule` or the constructor
2. **Scan dependencies** — check each resolved module's `dependencies` for unregistered names
3. **Auto-resolve missing deps** — attempt `import(name)` (or `import(package)` if the `package` field is set)
4. **Repeat recursively** — newly resolved modules may have their own unresolved dependencies
5. **Topological sort** — once all modules are resolved, determine setup order
6. **Throw on failure** — if a non-optional dependency can't be found after auto-resolution, throw `MODULE_NOT_FOUND`
Optional dependencies (`optional: true`) are silently skipped if they can't be resolved.
The recursive resolution has a configurable depth limit (`maxResolveDepth`, default 10) to prevent runaway chains.
### Auto-Install
By default, missing packages throw an error with a helpful install command:
```
ApplicationError: Module "orkos-redis" could not be resolved. Run: npm install orkos-redis
```
Enable `autoInstall` to install them automatically via [@antfu/install-pkg](https://github.com/antfu-collective/install-pkg):
```typescript
const app = new Application({
autoInstall: true, // attempts npm install before throwing
modules: ['orkos-redis'],
});
```
### Re-Resolution
External modules are cached after first resolution. Subsequent `setup()` calls reuse the cached modules. To force a fresh `import()` of all previously resolved externals (e.g. during development):
```typescript
await app.setup({ resolveCache: false });
```
This removes all previously resolved external modules and re-imports them.
## Dependency Ordering
orkos resolves module setup order using topological sort (Kahn's algorithm):
```typescript
const app = new Application({
modules: [
new HttpModule(), // dependencies: ['config', 'database']
new ConfigModule(), // no dependencies
new DatabaseModule(), // dependencies: ['config']
],
});
// Registration order doesn't matter — orkos resolves:
// 1. config (no deps)
// 2. database (depends on config)
// 3. http (depends on config + database)
await app.setup();
```
### Circular Dependencies
Circular dependencies are detected and throw an `ApplicationError` with code `CIRCULAR_DEPENDENCY`:
```typescript
// Module A depends on B, B depends on A
// → ApplicationError: Circular module dependency detected involving: A, B
```
### Missing Dependencies
When a module declares a dependency that isn't registered, orkos first attempts [auto-resolution](#dependency-resolution-flow) from `node_modules`. If the dependency still can't be found:
- **Non-optional** — `setup()` throws an `ApplicationError` with code `MODULE_NOT_FOUND`
- **Optional** (`optional: true`) — the dependency is silently skipped
## Module Lifecycle
### Versioning
Modules can declare a `version` and dependents can enforce semver constraints via [`ModuleDependency`](#moduledependency):
```typescript
class DatabaseModule implements IModule {
readonly name = 'database';
readonly version = '2.3.0';
// ...
}
class AuthModule implements IModule {
readonly name = 'auth';
readonly dependencies = [
{ name: 'database', version: '>=2.0.0' }, // requires database v2+
];
// ...
}
```
Supported ranges: `>=`, `>`, `<=`, `<`, `~`, `^`, and exact match. If a constraint is not satisfied, `setup()` throws an `ApplicationError` with code `VERSION_MISMATCH`.
### Hooks
**`onReady(container)`** — Called after **all** modules have been set up successfully, in dependency order. Use it for tasks that require the full application to be initialized (e.g. starting background jobs).
**`onError(error, container)`** — Called when this module's `setup()` throws. The original error is re-thrown after the hook runs. On failure, orkos automatically tears down any modules that were already set up successfully, in reverse order.
### Status Tracking
Each module moves through a lifecycle: `Pending → SettingUp → Ready` (or `Failed`), and `TearingDown → TornDown`. Query state via `getModuleStatus(name)` or `getStatus()` for the full map.
## Application API
```typescript
import { Application } from 'orkos';
import type { IApplication, ApplicationContext, SetupOptions } from 'orkos';
const app: IApplication = new Application({
modules: [...],
container: myContainer, // optional: pre-configured eldin container
autoInstall: false, // optional: auto-install missing packages
maxResolveDepth: 10, // optional: recursive resolution depth limit
});
```
| Method | Description |
|--------|-------------|
| `addModule(module)` | Register a module (`IModule`, string, or `[string, options]` tuple) |
| `addModules(modules)` | Register multiple modules at once |
| `setup(options?)` | Resolve externals, sort dependencies, and set up all modules |
| `teardown()` | Tear down all modules in reverse setup order |
| `getModuleStatus(name)` | Get the current status of a module |
| `getStatus()` | Get a map of all module statuses |
| `container` | The shared [eldin](https://github.com/tada5hi/eldin) `IContainer` instance |
`setup()` accepts an optional `SetupOptions` object:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `resolveCache` | `boolean` | `true` | When `false`, re-imports all previously resolved external modules |
### Dynamic Module Registration
```typescript
const app = new Application();
app.addModule(new ConfigModule());
app.addModule('orkos-redis');
app.addModule(['orkos-cache', { ttl: 60 }]);
app.addModules([new HttpModule(), new CacheModule()]);
await app.setup();
```
## Error Handling
orkos uses structured error codes via [ebec](https://github.com/tada5hi/ebec). All errors are instances of `ApplicationError` (extends `BaseError`) with a `code` property for programmatic handling:
```typescript
import { ApplicationError, ApplicationErrorCode } from 'orkos';
import { isBaseError } from 'ebec';
try {
await app.setup();
} catch (error) {
if (isBaseError(error) && error.code === ApplicationErrorCode.VERSION_MISMATCH) {
// handle version mismatch
}
}
```
| Code | When |
|------|------|
| `CIRCULAR_DEPENDENCY` | Circular module dependency detected |
| `MODULE_NOT_FOUND` | Required module/package not registered or resolvable |
| `INVALID_MODULE_EXPORT` | Package default export is not a valid `ModuleFactory` or `IModule` |
| `MODULE_INSTALL_FAILED` | Auto-install of a package failed |
| `OPTIONS_NOT_SUPPORTED` | Options passed to a package that exports `IModule` (not a factory) |
| `RESOLUTION_DEPTH_EXCEEDED` | Recursive resolution exceeded `maxResolveDepth` |
| `VERSION_MISMATCH` | Dependency version constraint not satisfied |
| `MODULE_NOT_REGISTERED` | `getModuleStatus()` called with unknown module name |
## Usage with eldin
orkos uses [eldin](https://github.com/tada5hi/eldin) for dependency injection. Define typed tokens with eldin and use them across modules:
```typescript
import { TypedToken } from 'eldin';
import type { IContainer } from 'eldin';
import { Application } from 'orkos';
import type { IModule } from 'orkos';
const ConfigToken = new TypedToken('Config');
const DatabaseToken = new TypedToken('Database');
class ConfigModule implements IModule {
readonly name = 'config';
async setup(container: IContainer) {
container.register(ConfigToken, {
useValue: { host: 'localhost', port: 5432 },
});
}
}
class DatabaseModule implements IModule {
readonly name = 'database';
readonly dependencies = ['config'];
async setup(container: IContainer) {
// Type-safe resolution — no manual generics needed
const config = container.resolve(ConfigToken); // Config
container.register(DatabaseToken, {
useFactory: () => new Database(config),
});
}
}
const app = new Application({
modules: [
new ConfigModule(),
new DatabaseModule(),
],
});
await app.setup();
// Access the container directly
const db = app.container.resolve(DatabaseToken); // Database
```
## Contributing
Before starting to work on a pull request, it is important to review the guidelines for
[contributing](./CONTRIBUTING.md) and the [code of conduct](./CODE_OF_CONDUCT.md).
These guidelines will help to ensure that contributions are made effectively and are accepted.
## License
Made with 💚
Published under [MIT License](./LICENSE).
[npm-version-src]: https://badge.fury.io/js/orkos.svg
[npm-version-href]: https://npmjs.com/package/orkos
[codecov-src]: https://codecov.io/gh/tada5hi/orkos/branch/master/graph/badge.svg?token=4KNSG8L13V
[codecov-href]: https://codecov.io/gh/tada5hi/orkos
[workflow-src]: https://github.com/tada5hi/orkos/workflows/CI/badge.svg
[workflow-href]: https://github.com/tada5hi/orkos
[snyk-src]: https://snyk.io/test/github/tada5hi/orkos/badge.svg?targetFile=package.json
[snyk-href]: https://snyk.io/test/github/tada5hi/orkos?targetFile=package.json
[conventional-src]: https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white
[conventional-href]: https://conventionalcommits.org