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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"description": "Claude Code plugin for building NestJS bounded contexts with Hexagonal Architecture, DDD, CQRS, and event-driven patterns. 10 skills, 8 agents (Opus 5 + Sonnet 5), TDD workflow, GSD compatible.",
"source": "./",
"category": "development",
"version": "1.1.0",
"version": "1.2.0-dev.0",
"homepage": "https://github.com/Softtor/nestjs-hexagonal"
}
]
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nestjs-hexagonal",
"description": "Skills for building NestJS bounded contexts with Hexagonal Architecture, DDD, and CQRS patterns. Covers domain modeling, application layer, infrastructure wiring, presentation, full TDD workflow, and architecture review.",
"version": "1.1.0",
"version": "1.2.0-dev.0",
"author": {
"name": "Softtor",
"url": "https://github.com/softtor"
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
unit:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Version parity between package.json and plugin.json
run: |
pkg=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
plugin=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('.claude-plugin/plugin.json','utf8')).version)")
marketplace=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('.claude-plugin/marketplace.json','utf8')).plugins[0].version)")
echo "package.json=$pkg plugin.json=$plugin marketplace.json=$marketplace"
if [ "$pkg" != "$plugin" ] || [ "$pkg" != "$marketplace" ]; then
echo "version mismatch between package.json, .claude-plugin/plugin.json and .claude-plugin/marketplace.json" >&2
exit 1
fi

- name: Type-check scripts
run: bunx --package typescript@5.9.3 tsc -p tsconfig.json

- name: Unit tests (no network)
run: bun test ./scripts

- name: Examples pass the hexagonal rulebook
run: bun scripts/check.ts --rulebook hexagonal --files 'examples/**/*.ts' --classes static --format text --strict
30 changes: 30 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ Compatible with GSD workflow.
7. **Write operations return void or `{ id: string }`** — CQRS strict
8. **No over-engineering** — no use case for simple `findById`, no abstraction for single use, no generic relay patterns

## Rulebook (machine-readable rules)

`rulebooks/hexagonal.rulebook.yaml` encodes the rules above; `scripts/check.ts` (entry `scripts/run.sh`, bin `nestjs-hexagonal-check`) runs the static ones. Semantic and runtime rules are declared but inert in this version; nothing is sent over the network. Projects opt in with `.claude/rulebook.yaml` (`extends` with sha256 stamps, own rules, overrides by id); `NESTJS_HEXAGONAL_DISABLE=1` turns everything off.

| Rule id | Class | Severity | Source |
|---|---|---|---|
| `hex/domain-no-nest-decorators` | static | FAIL | review D1, D3, D6, M3 |
| `hex/entity-unique-id` | static | FAIL | review D5 |
| `hex/vo-immutable` | static | FAIL | harness value-object-immutable |
| `hex/repo-interface-in-domain` | static | FAIL | review D6 |
| `hex/module-exports-ports-only` | static | FAIL | review I1 |
| `hex/vo-no-class-validator` | static | FAIL | review D2, P1 |
| `hex/no-circular-import` | static | FAIL | review M3, reviewer circular dependency |
| `hex/event-payload-sufficient` | static | FAIL | reviewer insufficient event payload |
| `hex/handler-max-lines` | static | WARN | reviewer god handler |
| `hex/tests-use-builders` | static | WARN | review D7, T5 |
| `hex/pattern-consistent` | static (external) | WARN | reviewer inconsistent pattern |
| `hex/no-overengineering-static` | static (external) | WARN | reviewer over-engineering audit |
| `hex/handler-no-business-rules` | semantic | FAIL | reviewer god handler |
| `hex/port-no-infra-leak` | semantic | FAIL | review A6 |
| `hex/entity-not-anemic` | semantic | WARN | reviewer anemic model |
| `hex/controller-thin` | semantic | WARN | review P5 |
| `hex/no-overengineering` | semantic (choice) | WARN | reviewer over-engineering audit |
| `hex/tests-coverage` | runtime | WARN | review T1-T5 |
| `softtor/tenant-scoped-query` | static | FAIL | review I7 (`softtor-conventions`) |
| `softtor/no-emoji` | static | FAIL | Softtor style (`softtor-conventions`) |
| `softtor/identifiers-english` | static | WARN | Softtor style (`softtor-conventions`) |

Adding a static rule requires `calibration/golden/<rule-id>/{good,bad}/` fixtures (at least 2 each); `bun test ./scripts` enforces it. Keep `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` on the same version.

## Skills

| Skill | When |
Expand Down
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,73 @@ One pattern only: `@EventsHandler` -> enrich if needed -> `WsGatewayPort.emit()`

No generic relay, no event maps, no custom broadcast events. Each event that needs to reach the frontend has its own explicit handler.

## Rulebook & CLI

The architecture rules above also exist as a machine-readable **rulebook** (`rulebooks/hexagonal.rulebook.yaml`) and a checker CLI, `nestjs-hexagonal-check`, that runs the static rules over a set of files. Semantic rules (answered by a typed-judgment model) and runtime rules (package tests) are declared in the rulebook but are not executed by this version: the CLI reports them as skipped and never opens a network connection.

### Running the checker

```bash
# inside this repository
bun scripts/check.ts --rulebook hexagonal --files 'src/**/*.ts' --classes static --format text

# from a project that installed the plugin as a dev dependency
bun add -d github:Softtor/nestjs-hexagonal#v1.2.0
bunx nestjs-hexagonal-check --files 'apps/api/src/**/*.ts' --strict
bunx nestjs-hexagonal-check --diff origin/main --format json
```

| Flag | Meaning |
|---|---|
| `--rulebook <path\|id>` | rulebook to run; an id resolves to `rulebooks/<id>.rulebook.yaml` in the plugin (`hexagonal`, `softtor-conventions`) |
| `--project-rulebook <path>` | project rulebook; defaults to `$NESTJS_HEXAGONAL_RULEBOOK`, then `$CLAUDE_PROJECT_DIR/.claude/rulebook.yaml` |
| `--files <glob...>` / `--diff <base>` | files to check (globs relative to the current directory) or `git diff --name-only <base>` |
| `--classes static[,semantic,runtime]` | rule classes to run (`static` only in this version) |
| `--format json\|text` | output format |
| `--strict` | exit 1 when any FAIL finding exists |
| `--explain` | list the rules applied to each file |

Each finding carries the rule id, severity (`FAIL`/`WARN`), path, line, evidence and the rule's `fix` text.

### Project rulebook

A project opts in by creating `.claude/rulebook.yaml` (or pointing `NESTJS_HEXAGONAL_RULEBOOK` at a file). It extends one or more plugin rulebooks, adds rules under its own namespace and overrides inherited rules by id (`disabled`, `severity`, `scope`, `thresholds`). `rulebooks/project.example.rulebook.yaml` is a complete example.

```yaml
$schema: nestjs-hexagonal/rulebook@1
id: acme-crm
version: 0.1.0
extends:
- { id: hexagonal, version: 1.2.0, sha256: <sha256sum rulebooks/hexagonal.rulebook.yaml> }
model: { provider: typesafe, pin: jev-1.13.0 }
rules: []
overrides:
- { id: softtor/identifiers-english, scope: { exclude: ['src/legacy/**'] } }
```

The `sha256` stamp pins the content of the base rulebook the project was calibrated against. When the installed copy differs, the CLI still runs but reports `rulebook-mismatch` and marks the run `uncalibrated`; a stale stamp never blocks.

### Opt-in gate and kill switch

`scripts/run.sh` is the single entry point for the CLI and for the plugin hooks (hooks ship in a later version). In hook mode (`--hook`) it decides in pure shell, before starting any runtime:

1. no `.claude/rulebook.yaml` in `$CLAUDE_PROJECT_DIR` and no `NESTJS_HEXAGONAL_RULEBOOK` pointing at an existing file: exit 0 with no output (the plugin is inert for projects that did not opt in);
2. `NESTJS_HEXAGONAL_DISABLE=1`: exit 0 (kill switch, also honoured by the CLI);
3. `file_path` resolving outside the project directory: exit 0;
4. the project's own `node_modules/.bin/nestjs-hexagonal-check` is preferred when present, so the version pinned in the project's lockfile is the one that runs; otherwise the plugin's `scripts/check.ts`;
5. missing `node_modules` (plugin loaded in place, or a failed install): an actionable message on stderr and exit 0 in hook mode, exit 1 in CLI mode.

The runtime is `bun`; when it is absent the script falls back to `node --experimental-strip-types`.

### Rulebooks shipped

| Rulebook | Scope |
|---|---|
| `hexagonal` | project-agnostic hexagonal + DDD + CQRS rules (`hex/*`) |
| `softtor-conventions` | multi-tenant scoping, no emoji, English identifiers (`softtor/*`); extend it only if those conventions apply |

Static rules have golden fixtures under `calibration/golden/<rule-id>/{good,bad}/`; `bun test` fails if a static rule lacks fixtures or a fixture stops behaving as labelled.

## Shared Examples

The `shared/` directory contains `.ts.example` reference implementations for projects that don't yet have base classes.
Expand Down
29 changes: 29 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Entity } from '@/shared/base-classes/entity';
import { PrismaService } from '../../infrastructure/prisma/prisma.service';

interface OrderProps {
total: number;
}

export class OrderEntity extends Entity<OrderProps> {
constructor(props: OrderProps, private readonly prisma: PrismaService) {
super(props);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';

@Injectable()
export class OrderPricingService {
price(quantity: number, unitPrice: number): number {
return quantity * unitPrice;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { AggregateRoot } from '@nestjs/cqrs';
import { OrderCreatedEvent } from '../events/order-created.event';

interface OrderProps {
total: number;
}

export class OrderEntity extends AggregateRoot {
private constructor(private readonly props: OrderProps) {
super();
}

static create(props: OrderProps): OrderEntity {
const entity = new OrderEntity(props);
entity.apply(new OrderCreatedEvent(props.total));
return entity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { IEvent } from '@nestjs/cqrs';

export class OrderCreatedEvent implements IEvent {
constructor(public readonly total: number, public readonly occurredOn: Date = new Date()) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class CustomerEntity {
constructor(public readonly id: string, public readonly name: string) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class InvoiceEntity {
private readonly id: number;

constructor(id: number, private readonly amount: number) {
this.id = id;
}

get total(): number {
return this.amount;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Entity } from '@/shared/base-classes/entity';

interface CustomerProps {
name: string;
}

export class CustomerEntity extends Entity<CustomerProps> {
private constructor(props: CustomerProps, id?: string) {
super(props, id);
}

static create(props: CustomerProps): CustomerEntity {
return new CustomerEntity(props);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { AggregateRoot } from '@nestjs/cqrs';
import { UniqueEntityID } from '@/shared/base-classes/unique-entity-id';

export class InvoiceEntity extends AggregateRoot {
constructor(public readonly id: UniqueEntityID, private readonly amount: number) {
super();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { OrderCreatedEvent } from '../../domain/events/order-created.event';

@EventsHandler(OrderCreatedEvent)
export class OrderCreatedBroadcastHandler implements IEventHandler<OrderCreatedEvent> {
constructor(private readonly repo: { get(id: string): Promise<unknown> }) {}

async handle(event: OrderCreatedEvent): Promise<void> {
const fresh = await this.repo.get(event.aggregateId);
void fresh;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Inject } from '@nestjs/common';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { OrderPaidEvent } from '../../domain/events/order-paid.event';
import { ORDER_REPOSITORY_TOKEN, OrderRepository } from '../../domain/repositories/order.repository';

@EventsHandler(OrderPaidEvent)
export class OrderPaidInvoiceHandler implements IEventHandler<OrderPaidEvent> {
constructor(@Inject(ORDER_REPOSITORY_TOKEN) private readonly orderRepository: OrderRepository.Repository) {}

async handle(event: OrderPaidEvent): Promise<void> {
const order = await this.orderRepository.findById(event.aggregateId);
if (!order) {
return;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Inject, Injectable } from '@nestjs/common';
import { ORDER_REPOSITORY_TOKEN, OrderRepository } from '../../domain/repositories/order.repository';

@Injectable()
export class OrderLookupAdapter {
constructor(@Inject(ORDER_REPOSITORY_TOKEN) private readonly orderRepository: OrderRepository.Repository) {}

async exists(id: string): Promise<boolean> {
return (await this.orderRepository.findById(id)) !== null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Inject } from '@nestjs/common';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { OrderPaidEvent } from '../../domain/events/order-paid.event';
import { INVOICING_PORT, InvoicingPort } from '../../application/ports/invoicing.port';

@EventsHandler(OrderPaidEvent)
export class OrderPaidInvoiceHandler implements IEventHandler<OrderPaidEvent> {
constructor(@Inject(INVOICING_PORT) private readonly invoicing: InvoicingPort) {}

async handle(event: OrderPaidEvent): Promise<void> {
try {
await this.invoicing.issue({ orderId: event.aggregateId, total: event.total, currency: event.currency });
} catch (error) {
void error;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { PlaceCommand } from './place.command';

@CommandHandler(PlaceCommand)
export class PlaceHandler implements ICommandHandler<PlaceCommand, { id: string }> {
async execute(command: PlaceCommand): Promise<{ id: string }> {
const step1 = command.value + 1;
const step2 = command.value + 2;
const step3 = command.value + 3;
const step4 = command.value + 4;
const step5 = command.value + 5;
const step6 = command.value + 6;
const step7 = command.value + 7;
const step8 = command.value + 8;
const step9 = command.value + 9;
const step10 = command.value + 10;
const step11 = command.value + 11;
const step12 = command.value + 12;
const step13 = command.value + 13;
const step14 = command.value + 14;
const step15 = command.value + 15;
const step16 = command.value + 16;
const step17 = command.value + 17;
const step18 = command.value + 18;
const step19 = command.value + 19;
const step20 = command.value + 20;
const step21 = command.value + 21;
const step22 = command.value + 22;
const step23 = command.value + 23;
const step24 = command.value + 24;
const step25 = command.value + 25;
const step26 = command.value + 26;
const step27 = command.value + 27;
const step28 = command.value + 28;
const step29 = command.value + 29;
const step30 = command.value + 30;
const step31 = command.value + 31;
const step32 = command.value + 32;
return { id: String(command.value) };
}
}
Loading
Loading