Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ MongoDB repositories require:
```bash
yarn add mongodb
```

## TypeScript Resolution

The package publishes ESM, CommonJS and declaration files for every public
subpath. Modern projects should prefer `moduleResolution: "NodeNext"` or
`"Bundler"`, but declaration mappings are also provided for projects still using
classic `moduleResolution: "node"`.
49 changes: 49 additions & 0 deletions docs/guides/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,52 @@ export default class MyPublisher implements DomainEventPublisher {

If an adapter needs a third-party dependency, expose it through a subpath and
mark that dependency as an optional peer dependency.

## Message Bus Hooks

Message bus adapters can expose publisher hooks so applications can attach
replicated publishers, websocket notifications, tracing or auditing without
wrapping the adapter in an application-local class.

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

const messageBus = new AmqpMessageBusAdapter({
publisherHookErrorPolicy: {
handleAfterPublishError(error, context) {
logger.error(
`Post-publish hook failed for ${context.topic}: ${String(error)}`,
);
},
shouldFailAfterPublish() {
return false;
},
},
publisherHooks: [
{
afterPublish: async ({ domainEvent, message }) => {
await websocketPublisher.publish(domainEvent ?? message);
},
},
],
});
```

Custom generic adapters should implement the `MessageBus` contract. Domain-event
adapters should implement `DomainMessageBus`. Both can delegate hook execution
through `PublisherHookPipeline`:

```ts
import {
PublisherHookPipeline,
type PublisherHook,
} from '@haskou/ddd-kernel/adapters/pubsub';

export default class CustomMessageBus {
private readonly hooks = new PublisherHookPipeline();

public registerPublisherHooks(...hooks: PublisherHook[]) {
this.hooks.register(...hooks);
}
}
```
5 changes: 5 additions & 0 deletions docs/guides/amqp-pubsub.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ new AmqpMessageBusAdapter({
dsn: 'amqp://localhost',
exchange: 'users-service',
maxRetries: 3,
publisherHooks: [replicatedPublisherHook],
retryDelayInMilliseconds: 1000,
serviceName: 'users-service',
});
Expand All @@ -30,3 +31,7 @@ Environment variables:

Failed messages are sent to `<queue>_dlx`. Use `consumeDlx` to retry failed
messages.

`publisherHooks` run around each domain event published by the adapter. Use them
for transport-adjacent fan-out such as websocket updates, replicated-state
publishers, tracing or audit logs.
39 changes: 36 additions & 3 deletions docs/reference/consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,44 @@ correlation IDs around handler execution:

```ts
kernel.registerConsumerMiddleware({
async handle(event, next) {
async handle(event, next, context) {
logger.info(`Handling ${context.eventName}`);
await next();
},
});
```

Middleware receives the event and a `next` callback. The kernel does not include
a full outbox or idempotency implementation.
Middleware receives the event, the next pipeline callback and a
`ConsumerExecutionContext` containing queue, exchange, event id, correlation id
and causation id. Transport adapters can also attach metadata, such as AMQP
headers or retry counts, to `context.metadata`.

## Built-in Middleware

The pub/sub adapter package includes small middleware implementations for common
consumer concerns. They are intentionally infrastructure-level primitives, not a
full outbox implementation.

```ts
import {
CorrelationConsumerMiddleware,
IdempotencyConsumerMiddleware,
InMemoryIdempotencyStore,
RetryConsumerMiddleware,
} from '@haskou/ddd-kernel/adapters/pubsub';

kernel.registerConsumerMiddleware(
new CorrelationConsumerMiddleware(),
new IdempotencyConsumerMiddleware({
store: new InMemoryIdempotencyStore(),
}),
new RetryConsumerMiddleware({
maxAttempts: 3,
}),
);
```

Use a custom `IdempotencyStore` for durable idempotency. Prefer stores that
implement atomic `claim`, `commit` and `release` methods so duplicate messages
cannot pass a non-atomic `has`/`mark` check concurrently. The in-memory store is
only useful for tests and single-process applications.
53 changes: 53 additions & 0 deletions docs/reference/express-kernel-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,56 @@ await server.run();
```

Routes are registered with `kernel.registerRoutes(RouteClass)`.

## External Controllers

Applications can add controllers at the server boundary without registering them
on the kernel:

```ts
const server = new ExpressKernelServer({
controllers: [HealthController],
kernel,
port: 3000,
});
```

`controllers` are merged with `kernel.getRoutes()` before
`routing-controllers` is configured.

## HTTP Middleware And Hooks

Use middleware arrays for normal Express middleware and hooks for integrations
that need direct app access, such as Swagger or static assets:

```ts
const server = new ExpressKernelServer({
kernel,
hooks: [
{ phase: 'beforeControllers', handle: setupTracing },
{ phase: 'beforeErrors', handle: setupSwagger },
{ phase: 'beforeErrors', handle: setupStaticAssets },
],
middlewares: [requestIdMiddleware],
preControllerMiddlewares: [authenticationMiddleware],
postControllerMiddlewares: [notFoundMiddleware],
});
```

Hook order is:

1. `middlewares`
2. `preControllerMiddlewares`
3. `beforeControllersHooks`
4. `hooks` with `phase: 'beforeControllers'`
5. `routing-controllers`
6. `postControllerMiddlewares`
7. `afterControllersHooks`
8. `hooks` with `phase: 'afterControllers'`
9. `swaggerHooks`
10. `staticHooks`
11. `hooks` with `phase: 'beforeErrors'`
12. `errorHandlers`

`swaggerHooks` and `staticHooks` remain available for compatibility. New
integrations should use `hooks` with an explicit phase.
21 changes: 20 additions & 1 deletion docs/reference/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ Register scheduler classes with `kernel.registerSchedulers(...)`.

## Error Policy

Schedulers accept a `SchedulerErrorPolicy`:
Schedulers accept a `SchedulerErrorPolicy` exported from
`@haskou/ddd-kernel/scheduler`:

```ts
import type { SchedulerErrorPolicy } from '@haskou/ddd-kernel/scheduler';

class ReplicationScheduler extends Scheduler {
constructor(errorPolicy: SchedulerErrorPolicy) {
super(errorPolicy);
Expand All @@ -35,3 +38,19 @@ interface SchedulerErrorPolicy {
handle(error: unknown, scheduler: Scheduler): Promise<void> | void;
}
```

Use `shouldSkip` for domain-specific transient states that should not be logged
as scheduler failures, for example replicated state that is not ready yet:

```ts
const policy: SchedulerErrorPolicy = {
shouldSkip(error) {
return error instanceof ReplicatedStateNotReadyError;
},
handle(error, scheduler) {
logger.error(`${scheduler.getProcessName()} failed: ${String(error)}`);
},
};
```

The default policy never skips and wraps failures in `ScheduledExecutionError`.
79 changes: 79 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,85 @@
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"adapters": [
"dist/adapters/index.d.ts"
],
"adapters/db": [
"dist/adapters/db/index.d.ts"
],
"adapters/db/in-memory": [
"dist/adapters/db/in-memory/index.d.ts"
],
"adapters/db/mongo": [
"dist/adapters/db/mongo/index.d.ts"
],
"adapters/kernel": [
"dist/adapters/kernel/index.d.ts"
],
"adapters/kernel/console": [
"dist/adapters/kernel/console/index.d.ts"
],
"adapters/pubsub": [
"dist/adapters/pubsub/index.d.ts"
],
"adapters/pubsub/amqp": [
"dist/adapters/pubsub/amqp/index.d.ts"
],
"adapters/pubsub/in-memory": [
"dist/adapters/pubsub/in-memory/index.d.ts"
],
"adapters/ui": [
"dist/adapters/ui/index.d.ts"
],
"adapters/ui/express": [
"dist/adapters/ui/express/index.d.ts"
],
"adapters/ui/routes": [
"dist/adapters/ui/routes/index.d.ts"
],
"contracts": [
"dist/contracts/index.d.ts"
],
"contracts/db": [
"dist/contracts/db/index.d.ts"
],
"contracts/kernel": [
"dist/contracts/kernel/index.d.ts"
],
"contracts/pubsub": [
"dist/contracts/pubsub/index.d.ts"
],
"contracts/ui": [
"dist/contracts/ui/index.d.ts"
],
"dependency-injection": [
"dist/infrastructure/dependency-injection/index.d.ts"
],
"domain": [
"dist/domain/index.d.ts"
],
"errors": [
"dist/errors/index.d.ts"
],
"express": [
"dist/adapters/ui/express/index.d.ts"
],
"lifecycle": [
"dist/infrastructure/lifecycle/index.d.ts"
],
"logs": [
"dist/infrastructure/logs/index.d.ts"
],
"scheduler": [
"dist/infrastructure/scheduler/index.d.ts"
],
"websocket": [
"dist/infrastructure/websocket/index.d.ts"
]
}
},
"publishConfig": {
"access": "public"
},
Expand Down
4 changes: 4 additions & 0 deletions src/Kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export class Kernel {
return Kernel.getActiveKernel().logger;
}

public static get active(): Kernel {
return Kernel.getActiveKernel();
}

public static get rootDirectory(): string {
return process.cwd();
}
Expand Down
39 changes: 24 additions & 15 deletions src/adapters/pubsub/Consumer.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import type { ConsumerMiddleware } from '../../contracts/index.js';
import type { DomainEventConsumer } from '../../domain/DomainEventConsumer.js';
import type { DomainEvent } from '../../domain/index.js';
import type {
DomainEvent,
DomainEventConsumerContext,
} from '../../domain/index.js';

import { Kernel } from '../../Kernel.js';
import { ConsumerMiddlewarePipeline } from './ConsumerMiddlewarePipeline.js';

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

private async runMiddleware(
event: DomainEvent,
middlewares: readonly ConsumerMiddleware[],
index: number,
consumerContext?: DomainEventConsumerContext,
): Promise<void> {
const middleware = middlewares[index];

if (!middleware) {
await this.handler(event);

return;
}

await middleware.handle(event, () =>
this.runMiddleware(event, middlewares, index + 1),
const pipeline = new ConsumerMiddlewarePipeline(Kernel.consumerMiddleware);
const metadata = consumerContext?.metadata ?? {};

await pipeline.execute(
event,
{
causationId: event.getCausationId(),
correlationId: event.getCorrelationId(),
eventId: event.eventId,
eventName: this.eventName,
exchange: this.exchange,
kernel: Kernel.active,
metadata,
queueName: this.queueName,
rawMessage: metadata.rawMessage,
},
() => this.handler(event),
);
}

Expand All @@ -41,7 +50,7 @@ export abstract class Consumer {
this.eventName,
this.domainEvent,
this.exchange,
(event) => this.runMiddleware(event, Kernel.consumerMiddleware, 0),
(event, context) => this.runMiddleware(event, context),
);
}

Expand Down
Loading
Loading