Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,46 @@ injection and in-memory adapters.

Install extra packages only for the adapters your application imports.

The core package does not load optional adapter dependencies from the root
import. Install the peer packages listed below only when importing the matching
subpath.

## Pub/Sub Adapters

The in-memory pub/sub adapter has no extra runtime dependencies.

```ts
import { InMemoryPubSub } from '@haskou/ddd-kernel/adapters/pubsub/in-memory';
```

The AMQP adapter uses `amqplib`:

```bash
yarn add amqplib
```

```ts
import { AmqpMessageBusAdapter } from '@haskou/ddd-kernel/adapters/pubsub/amqp';
```

## DB Adapters

The in-memory repository adapter has no extra runtime dependencies.

```ts
import { InMemoryRepository } from '@haskou/ddd-kernel/adapters/db/in-memory';
```

The MongoDB repository adapter uses `mongodb`:

```bash
yarn add mongodb
```

```ts
import { MongoRepository } from '@haskou/ddd-kernel/adapters/db/mongo';
```

## UI Adapters

The Express adapter uses `express`, `routing-controllers` and decorator
Expand All @@ -40,6 +60,10 @@ metadata packages:
yarn add express routing-controllers reflect-metadata class-transformer class-validator
```

```ts
import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express';
```

Install `cors` only when enabling `routingControllersOptions.cors`:

```bash
Expand Down
14 changes: 7 additions & 7 deletions docs/getting-started/package-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ Bootstrap code chooses adapters.

## Core

| Area | Import | Purpose |
| ---------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Kernel runtime | `@haskou/ddd-kernel` | Registers consumers, routes, schedulers, runtimes and shutdown hooks. |
| Environment variables | `@haskou/ddd-kernel` | Loads `.env.<environment>` files and exposes typed `kernel.environment` when a schema is configured. |
| Dependency injection | `@haskou/ddd-kernel/dependency-injection` | Wraps `node-dependency-injection` and container YAML generation/loading. |
| Lifecycle | `@haskou/ddd-kernel/lifecycle` | Runtime and initializer contracts. |
| Kernel logger contract | `@haskou/ddd-kernel/contracts/kernel` | `KernelLogger` interface. |
| Area | Import | Purpose |
| --------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Kernel runtime | `@haskou/ddd-kernel` | Registers consumers, routes, schedulers, runtimes and shutdown hooks. |
| Environment variables | `@haskou/ddd-kernel` | Loads `.env.<environment>` files and exposes typed `kernel.environment` when a schema is configured. |
| Dependency injection | `@haskou/ddd-kernel/dependency-injection` | Wraps `node-dependency-injection` and container YAML generation/loading. |
| Lifecycle | `@haskou/ddd-kernel/lifecycle` | Runtime and initializer contracts. |
| Kernel contracts | `@haskou/ddd-kernel/contracts/kernel` | `KernelConsumer`, `KernelRoute`, `KernelLogger`, middleware and shutdown contracts. |

`dotenv`, `node-dependency-injection` and `fs-extra` are package dependencies
because the core environment and DI implementations use them directly.
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Consumers are registered by class:
kernel.registerConsumers(RegisterUserWhenCreated);
```

`Consumer` implements the core `KernelConsumer` contract. The kernel only
requires `queueName` and `init()`, so a custom transport can provide its own
consumer class without extending this adapter base class.

## Middleware

Register consumer middleware when you need idempotency, retries, tracing or
Expand Down
56 changes: 54 additions & 2 deletions docs/reference/kernel.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,14 @@ creating the kernel:

```ts
const environmentSchema = {
ENABLE_JOBS: { defaultValue: false, type: 'boolean' },
ENABLE_JOBS: {
defaultValue: false,
description: 'Enables background schedulers.',
type: 'boolean',
},
HTTP_PORT: { required: true, type: 'number' },
SERVICE_NAME: { type: 'string' },
NODE_ENV: { choices: ['local', 'test', 'production'], type: 'string' },
SERVICE_NAME: { sensitive: false, type: 'string' },
} as const;

const kernel = new Kernel({ environmentSchema });
Expand All @@ -95,12 +100,39 @@ kernel.loadEnvironmentVariables();

kernel.environment.HTTP_PORT; // number
kernel.environment.ENABLE_JOBS; // boolean
kernel.environment.NODE_ENV; // 'local' | 'test' | 'production' | undefined
kernel.environment.SERVICE_NAME; // string | undefined
```

Required variables throw `KernelEnvironmentValidationError` when they are
missing. `number` and `boolean` values are parsed after `.env` files are loaded.
Boolean values accept `true`, `false`, `1`, `0`, `yes`, `no`, `on` and `off`.
Blank numeric values are rejected instead of being coerced to `0`.

`choices` restricts the allowed runtime values and narrows the TypeScript type
when the schema is declared `as const`:

```ts
const environmentSchema = {
NODE_ENV: { choices: ['local', 'test'], type: 'string' },
} as const;

const kernel = new Kernel({ environmentSchema });

kernel.environment.NODE_ENV; // 'local' | 'test' | undefined
```

Schema entries also accept metadata for generated documentation and operational
tools:

| Field | Purpose |
| -------------- | ----------------------------------------------------------------------- |
| `type` | Runtime parser and TypeScript primitive: `string`, `number`, `boolean`. |
| `required` | Throws when the variable is missing. |
| `defaultValue` | Used when the variable is absent. |
| `choices` | Restricts accepted values and narrows the inferred TypeScript type. |
| `description` | Human-readable explanation for generated docs or audits. |
| `sensitive` | Marks values that should not be logged or displayed by tooling. |

## Dependency Injection

Expand Down Expand Up @@ -145,6 +177,26 @@ kernel.registerConsumerInstances(consumer);
kernel.registerSchedulerInstances(scheduler);
```

`registerConsumers` accepts classes that implement the `KernelConsumer`
contract. The provided pub/sub `Consumer` base class already implements it, but
custom consumers can implement the contract directly when they do not need that
adapter base class:

```ts
import type { KernelConsumer } from '@haskou/ddd-kernel/contracts/kernel';

export default class CustomConsumer implements KernelConsumer {
public readonly queueName = 'custom.consumer';

public async init() {
// Subscribe to the transport and bind handlers here.
}
}
```

`registerRoutes` accepts classes assignable to `KernelRoute`. The provided HTTP
`Route` base class extends that contract.

Runtimes and initializers are passed to their run methods as classes. Runtimes
are resolved through DI, executed and automatically added to shutdown hooks.
When an object is already built outside the kernel, register the shutdown action
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/route.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ injection:
kernel.registerRoutes(GetUserRoute);
```

`Route` extends the core `KernelRoute` contract. HTTP adapters can use that
contract without making the kernel depend on a concrete UI adapter.

`Route` still exposes `get<T>()` for compatibility with older code, but
constructor injection is the recommended path because it keeps routes easier to
test.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@
"prepack": "yarn build",
"test": "yarn build && c8 node --test \"tests/**/*.test.mjs\"",
"build:coverage": "rm -rf dist && tsc -p tsconfig.coverage.json",
"test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/**/*.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test \"tests/**/*.test.mjs\"",
"test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/KernelDefaultEnvironment.ts\" --exclude \"src/kernel/KernelEnvironment.ts\" --exclude \"src/kernel/KernelEnvironmentForSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchemaInput.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableResolvedValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariableType.ts\" --exclude \"src/kernel/ShutdownCandidate.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test \"tests/**/*.test.mjs\"",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [
Expand Down
62 changes: 45 additions & 17 deletions src/Kernel.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import dotenv, { type DotenvConfigOutput } from 'dotenv';
import path from 'node:path';

import type { Consumer } from './adapters/pubsub/index.js';
import type { Route } from './adapters/ui/routes/index.js';
import type {
ConsumerMiddleware,
KernelConsumer,
KernelLogger,
KernelRoute,
ShutdownHook,
} from './contracts/index.js';
import type { ServiceClass } from './infrastructure/dependency-injection/index.js';
Expand All @@ -28,9 +28,11 @@ export type { KernelDefaultEnvironment } from './kernel/KernelDefaultEnvironment
export type { KernelEnvironment } from './kernel/KernelEnvironment.js';
export type { KernelEnvironmentForSchema } from './kernel/KernelEnvironmentForSchema.js';
export type { KernelEnvironmentSchema } from './kernel/KernelEnvironmentSchema.js';
export type { KernelEnvironmentSchemaInput } from './kernel/KernelEnvironmentSchemaInput.js';
export type { KernelEnvironmentValue } from './kernel/KernelEnvironmentValue.js';
export type { KernelEnvironmentVariableDefinition } from './kernel/KernelEnvironmentVariableDefinition.js';
export type { KernelEnvironmentVariablePrimitive } from './kernel/KernelEnvironmentVariablePrimitive.js';
export type { KernelEnvironmentVariableResolvedValue } from './kernel/KernelEnvironmentVariableResolvedValue.js';
export type { KernelEnvironmentVariableType } from './kernel/KernelEnvironmentVariableType.js';
export type { KernelEnvironmentVariablesOptions } from './kernel/KernelEnvironmentVariablesOptions.js';
export type { KernelOptions } from './kernel/KernelOptions.js';
Expand All @@ -44,9 +46,9 @@ export class Kernel<
);

private readonly consumerMiddlewares: ConsumerMiddleware[] = [];
private readonly consumersList: Consumer[] = [];
private readonly consumersList: KernelConsumer[] = [];
private readonly loggerInstance: KernelLogger;
private readonly routesList: ServiceClass<Route>[] = [];
private readonly routesList: ServiceClass<KernelRoute>[] = [];
private readonly schedulersList: Scheduler[] = [];
private readonly shutdownHooks: ShutdownHook[] = [];
private dependencyInjectionInstance: DependencyInjection | undefined;
Expand All @@ -73,7 +75,7 @@ export class Kernel<
return path.resolve(Kernel.rootDirectory, 'config');
}

public static get consumers(): Consumer[] {
public static get consumers(): KernelConsumer[] {
return Kernel.getActiveKernel().consumers;
}

Expand Down Expand Up @@ -101,7 +103,7 @@ export class Kernel<
return process.cwd();
}

public static get routes(): ServiceClass<Route>[] {
public static get routes(): ServiceClass<KernelRoute>[] {
return Kernel.getActiveKernel().routes;
}

Expand Down Expand Up @@ -135,6 +137,20 @@ export class Kernel<
}
}

private static assertEnvironmentVariableChoice(
name: string,
value: KernelEnvironmentValue,
schema: KernelEnvironmentSchema,
): void {
const choices = schema[name]?.choices;

if (choices && !choices.includes(value)) {
throw new KernelEnvironmentValidationError(
`Environment variable "${name}" must be one of: ${choices.join(', ')}.`,
);
}
}

private static getEnvironmentVariablesPath(
environment: string,
options: KernelEnvironmentVariablesOptions<
Expand Down Expand Up @@ -193,13 +209,23 @@ export class Kernel<
const definition = schema[name];

if (definition.type === 'boolean') {
return Kernel.parseBooleanEnvironmentVariable(name, value);
const parsedValue = Kernel.parseBooleanEnvironmentVariable(name, value);

Kernel.assertEnvironmentVariableChoice(name, parsedValue, schema);

return parsedValue;
}

if (definition.type === 'number') {
return Kernel.parseNumberEnvironmentVariable(name, value);
const parsedValue = Kernel.parseNumberEnvironmentVariable(name, value);

Kernel.assertEnvironmentVariableChoice(name, parsedValue, schema);

return parsedValue;
}

Kernel.assertEnvironmentVariableChoice(name, value, schema);

return value;
}

Expand Down Expand Up @@ -281,9 +307,9 @@ export class Kernel<
}

private getConsumerFromClass(
ClassDefinition: ServiceClass<Consumer>,
): Consumer {
return this.di.getService<Consumer>(ClassDefinition);
ClassDefinition: ServiceClass<KernelConsumer>,
): KernelConsumer {
return this.di.getService<KernelConsumer>(ClassDefinition);
}

private getInitializerFromClass(
Expand All @@ -302,7 +328,7 @@ export class Kernel<
return this.di.getService<Scheduler>(ClassDefinition);
}

public get consumers(): Consumer[] {
public get consumers(): KernelConsumer[] {
return this.consumersList;
}

Expand All @@ -326,7 +352,7 @@ export class Kernel<
return this.loggerInstance;
}

public get routes(): ServiceClass<Route>[] {
public get routes(): ServiceClass<KernelRoute>[] {
return this.routesList;
}

Expand Down Expand Up @@ -384,7 +410,7 @@ export class Kernel<
return result;
}

public getRoutes(): ServiceClass<Route>[] {
public getRoutes(): ServiceClass<KernelRoute>[] {
return this.routes;
}

Expand All @@ -395,18 +421,20 @@ export class Kernel<
}

public registerConsumers(
...ClassDefinitions: ServiceClass<Consumer>[]
...ClassDefinitions: ServiceClass<KernelConsumer>[]
): void {
for (const ClassDefinition of ClassDefinitions) {
this.consumersList.push(this.getConsumerFromClass(ClassDefinition));
}
}

public registerConsumerInstances(...consumers: Consumer[]): void {
public registerConsumerInstances(...consumers: KernelConsumer[]): void {
this.consumersList.push(...consumers);
}

public registerRoutes(...ClassDefinitions: ServiceClass<Route>[]): void {
public registerRoutes(
...ClassDefinitions: ServiceClass<KernelRoute>[]
): void {
this.routesList.push(...ClassDefinitions);
}

Expand Down
3 changes: 2 additions & 1 deletion src/adapters/pubsub/Consumer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { KernelConsumer } from '../../contracts/kernel/index.js';
import type { DomainEventConsumer } from '../../domain/DomainEventConsumer.js';
import type {
DomainEvent,
Expand All @@ -7,7 +8,7 @@ import type {
import { Kernel } from '../../Kernel.js';
import { ConsumerMiddlewarePipeline } from './ConsumerMiddlewarePipeline.js';

export abstract class Consumer {
export abstract class Consumer implements KernelConsumer {
constructor(private readonly consumer: DomainEventConsumer) {}

private async runMiddleware(
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/ui/routes/Route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { KernelRoute } from '../../../contracts/kernel/index.js';
import { Kernel } from '../../../Kernel.js';

export abstract class Route {
export abstract class Route extends KernelRoute {
public get<T>(service: unknown): T {
return Kernel.di.getService<T>(service);
}
Expand Down
5 changes: 5 additions & 0 deletions src/contracts/kernel/KernelConsumer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface KernelConsumer {
readonly queueName: string;

init(): Promise<void>;
}
3 changes: 3 additions & 0 deletions src/contracts/kernel/KernelRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export abstract class KernelRoute {
declare protected readonly kernelRouteContract: never;
}
Loading