Install
$ agentstack add skill-softtor-nestjs-hexagonal-domain ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README — it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming — see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps — measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Domain Layer
The domain layer contains the pure business logic of a bounded context. It has zero framework dependencies except @nestjs/cqrs for AggregateRoot (entity base class). No @Injectable, no PrismaService, no HTTP concerns.
Decision Tree
What are you creating?
Domain artifact needed?
│
├── An entity (aggregate root with lifecycle methods)
│ └── Go to: Entity Pattern
│
├── A simple value (email, amount, name, phone)
│ └── Go to: VO — Scalar
│
├── A grouped value (address, bank details, money)
│ └── Go to: VO — Composed
│
├── A fixed set of values (payment method, channel type)
│ └── Go to: VO — Enum
│
├── A status with allowed transitions (order status, withdrawal status)
│ └── Go to: VO — State Machine
│
├── The persistence contract for the entity
│ └── Go to: Repository Interface
│
├── Something that happened in the domain
│ └── Go to: Domain Events
│
├── Logic that belongs to the domain but not to one entity
│ └── Go to: Domain Services
│
├── A domain-specific error with context
│ └── Go to: Domain Errors
│
├── Entity invariant enforcement (field-level rules)
│ └── Go to: Validators
│
└── Fake data for tests
└── Go to: Data Builders
Entity Pattern
Full reference: references/entity-patterns.md
An entity is identified by its UniqueEntityID, not by its value. It extends AggregateRoot from @nestjs/cqrs so that events flow through the NestJS EventBus once committed.
Two factory methods are mandatory:
static create(props, id?)— for new instances; callsthis.apply(new XCreatedEvent(...))static restore(props, id)— for DB hydration; emits no events
Event lifecycle (every entity follows this):
entity.apply(event) // 1. records the event in memory
publisher.mergeObjectContext(entity) // 2. wires EventBus (in handler)
await repo.save(entity) // 3. persists
entity.commit() // 4. publishes events to EventBus
Condensed template:
// domain/entities/.entity.ts
import { Entity } from '@/shared/base-classes/entity';
import { CreatedEvent } from '../events/-created.event';
export interface Props {
tenantId: string;
// ... business fields
createdAt: Date;
updatedAt?: Date;
}
export class Entity extends EntityProps> {
private constructor(props: Props, id?: string) {
super(props, id);
}
static create(
input: OmitProps, 'createdAt' | 'updatedAt'>,
id?: string,
): Entity {
const entity = new Entity({ ...input, createdAt: new Date() }, id);
entity.apply(new CreatedEvent(entity.id));
return entity;
}
static restore(props: Props, id: string): Entity {
return new Entity(props, id);
}
// Mutating method
update(name: string): void {
this.props.name = name;
this.touch();
this.apply(new UpdatedEvent(this.id, name));
}
get tenantId(): string { return this.props.tenantId; }
// ... other getters
get createdAt(): Date { return this.props.createdAt; }
get updatedAt(): Date | undefined { return this.props.updatedAt; }
}
See references/entity-patterns.md for: child entities, validate() integration, toJSON() override, and the full test template.
Value Object Variants
Full reference: references/value-object-patterns.md
All VOs extend ValueObject from @/shared/base-classes/value-object. Rules:
- Constructor is
private— only static factories create instances validate()throws on invariant violations; it is called automatically in the constructor- Never import
@nestjs/*orclass-validatorin a VO
Scalar VO (single value)
// domain/value-objects/email.vo.ts
import { ValueObject } from '@/shared/base-classes/value-object';
import { InvalidArgumentError } from '@/shared/domain-errors/errors';
export class EmailVO extends ValueObject {
private constructor(value: string) { super(value); }
protected validate(): void {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this._value)) {
throw new InvalidArgumentError(`Invalid email: "${this._value}"`);
}
}
static create(email: string): EmailVO {
return new EmailVO(email.toLowerCase().trim());
}
get domain(): string { return this._value.split('@')[1]; }
}
Composed VO (multiple props)
// domain/value-objects/address.vo.ts
import { ValueObject } from '@/shared/base-classes/value-object';
import { InvalidArgumentError } from '@/shared/domain/errors';
interface AddressProps { street: string; city: string; country: string; }
export class AddressVO extends ValueObject {
private constructor(props: AddressProps) { super(props); }
protected validate(): void {
if (!this._value.street) throw new InvalidArgumentError('Street is required');
if (!this._value.city) throw new InvalidArgumentError('City is required');
if (!this._value.country) throw new InvalidArgumentError('Country is required');
}
static create(props: AddressProps): AddressVO {
return new AddressVO(props);
}
get street(): string { return this._value.street; }
get city(): string { return this._value.city; }
get country(): string { return this._value.country; }
}
Enum VO (fixed set of named values)
// domain/value-objects/payment-method.vo.ts
import { ValueObject } from '@/shared/base-classes/value-object';
export enum PaymentMethodEnum { CREDIT = 'CREDIT', DEBIT = 'DEBIT', PIX = 'PIX' }
export class PaymentMethodVO extends ValueObject {
private constructor(value: PaymentMethodEnum) { super(value); }
protected validate(): void {
if (!Object.values(PaymentMethodEnum).includes(this._value)) {
throw new InvalidArgumentError(`Unknown payment method: ${this._value}`);
}
}
static credit(): PaymentMethodVO { return new PaymentMethodVO(PaymentMethodEnum.CREDIT); }
static debit(): PaymentMethodVO { return new PaymentMethodVO(PaymentMethodEnum.DEBIT); }
static pix(): PaymentMethodVO { return new PaymentMethodVO(PaymentMethodEnum.PIX); }
static from(value: string): PaymentMethodVO {
return new PaymentMethodVO(value as PaymentMethodEnum);
}
isCredit(): boolean { return this._value === PaymentMethodEnum.CREDIT; }
isPix(): boolean { return this._value === PaymentMethodEnum.PIX; }
}
State Machine VO (controlled transitions)
// domain/value-objects/order-status.vo.ts
import { ValueObject } from '@/shared/base-classes/value-object';
export enum OrderStatusEnum {
PENDING = 'PENDING', CONFIRMED = 'CONFIRMED', SHIPPED = 'SHIPPED', COMPLETED = 'COMPLETED',
}
const ALLOWED_TRANSITIONS: Record = {
[OrderStatusEnum.PENDING]: [OrderStatusEnum.CONFIRMED],
[OrderStatusEnum.CONFIRMED]: [OrderStatusEnum.SHIPPED],
[OrderStatusEnum.SHIPPED]: [OrderStatusEnum.COMPLETED],
[OrderStatusEnum.COMPLETED]: [],
};
export class OrderStatusVO extends ValueObject {
private constructor(value: OrderStatusEnum) { super(value); }
protected validate(): void {
if (!Object.values(OrderStatusEnum).includes(this._value)) {
throw new InvalidArgumentError(`Invalid order status: ${this._value}`);
}
}
static pending(): OrderStatusVO { return new OrderStatusVO(OrderStatusEnum.PENDING); }
static confirmed(): OrderStatusVO { return new OrderStatusVO(OrderStatusEnum.CONFIRMED); }
static from(value: string): OrderStatusVO {
return new OrderStatusVO(value as OrderStatusEnum);
}
canTransitionTo(target: OrderStatusEnum): boolean {
return ALLOWED_TRANSITIONS[this._value].includes(target);
}
transitionTo(target: OrderStatusEnum): OrderStatusVO {
if (!this.canTransitionTo(target)) {
throw new InvalidArgumentError(
`Cannot transition from ${this._value} to ${target}`,
);
}
return new OrderStatusVO(target);
}
isPending(): boolean { return this._value === OrderStatusEnum.PENDING; }
isCompleted(): boolean { return this._value === OrderStatusEnum.COMPLETED; }
}
See references/value-object-patterns.md for full VO test templates and edge-case guidance.
Repository Interface
Full reference: references/repository-interface.md
The repository interface is the domain's port for persistence. It lives in domain/repositories/. The implementation lives in infrastructure/. No @Injectable or Prisma imports here.
// domain/repositories/.repository.ts
import type { Entity } from '../entities/.entity';
import {
SearchParams as DefaultSearchParams,
SearchResult as DefaultSearchResult,
SearchableRepositoryInterface,
} from '@/shared/repository-contracts/searchable-repository-contracts';
export namespace Repository {
export type Filter = string;
export class SearchParams extends DefaultSearchParams {}
export class SearchResult extends DefaultSearchResultEntity, Filter> {}
export interface Repository
extends SearchableRepositoryInterfaceEntity, Filter, SearchParams, SearchResult
> {
findById(id: string): PromiseEntity | null>;
findByTenant(tenantId: string): PromiseEntity[]>;
save(entity: Entity): Promise;
delete(id: string): Promise;
}
}
export const _REPOSITORY = Symbol('Repository');
See references/repository-interface.md for: extended SearchParams with custom filters, domain-specific query methods, and the in-memory test double pattern.
Domain Events
Full reference: references/domain-event-patterns.md
Domain events capture what happened in the domain. They implement IEvent from @nestjs/cqrs.
Naming: Event (e.g., OrderPlacedEvent, CustomerActivatedEvent)
// domain/events/-created.event.ts
import { IEvent } from '@nestjs/cqrs';
export class CreatedEvent implements IEvent {
constructor(
public readonly Id: string,
public readonly tenantId: string,
// include all data handlers will need
) {}
}
Rules:
- Events are applied via
entity.apply(event)inside entity methods - The handler calls
publisher.mergeObjectContext(entity)thenentity.commit()to publish - Event handlers use
@EventsHandler+IEventHandler— never@OnEventfor new code - Include enough payload so handlers do not need to re-fetch the entity
See references/domain-event-patterns.md for: full event catalog pattern, testing uncommitted events, and handler template.
Domain Services
Full reference: references/domain-service-patterns.md
Use a domain service when a domain operation involves multiple entities or rules that do not belong to a single entity. Domain services are pure classes — no decorators, no DI, no I/O.
// domain/services/.service.ts
export class FeeCalculationService {
static calculate(amount: number, feePercentage: number): number {
if (amount .validator.ts
import { IsString, IsNotEmpty, IsUUID, IsOptional, MaxLength } from 'class-validator';
import { ClassValidatorFields } from '@/shared/base-classes/class-validator-fields';
import type { Props } from '../entities/.entity';
class Rules {
@IsUUID() @IsNotEmpty() tenantId: string;
@IsString() @IsNotEmpty() @MaxLength(255) name: string;
@IsOptional() @IsString() description?: string;
constructor(props: Props) { Object.assign(this, props); }
}
class Validator extends ClassValidatorFieldsRules> {
validate(data: Props): boolean {
return super.validate(new Rules(data));
}
}
export class ValidatorFactory {
static create(): Validator { return new Validator(); }
}
Usage in entity:
static validate(props: Props): void {
const validator = ValidatorFactory.create();
if (!validator.validate(props)) {
throw new EntityValidationError(validator.errors);
}
}
See references/validator-patterns.md for: the ClassValidatorFields base, nested validation, and full test template.
Data Builders
Full reference: references/data-builder-patterns.md
Data builders provide deterministic fake data for unit tests. They live in domain/testing/helpers/.
// domain/testing/helpers/.data-builder.ts
import { faker } from '@faker-js/faker';
import type { Props } from '../../entities/.entity';
export function DataBuilder(overrides?: PartialProps>): Props {
return {
tenantId: overrides?.tenantId ?? faker.string.uuid(),
name: overrides?.name ?? faker.commerce.productName(),
createdAt: overrides?.createdAt ?? new Date(),
updatedAt: overrides?.updatedAt,
...overrides,
};
}
See references/data-builder-patterns.md for: composed builders, state-specific variants (Active, Pending, Rejected), and how to wire builders into test describe blocks.
Domain Errors
Specialized errors that extend the shared error hierarchy (DomainError, NotFoundError, ConflictError, etc.) with domain-specific context.
Live in /domain/errors/. Name convention: Error.
// domain/errors/order-not-found.error.ts
import { NotFoundError } from '@/shared/domain/errors';
export class OrderNotFoundError extends NotFoundError {
constructor(public readonly orderId: string) {
super(`Order ${orderId} not found`);
}
}
// domain/errors/invalid-order-status-transition.error.ts
import { DomainError } from '@/shared/domain/errors';
export class InvalidOrderStatusTransitionError extends DomainError {
constructor(
public readonly currentStatus: string,
public readonly targetStatus: string,
) {
super(`Cannot transition from ${currentStatus} to ${targetStatus}`);
}
}
When to specialize: When the error carries domain context (IDs, amounts, statuses) that helps debugging. Don't specialize if the generic message is sufficient.
See references/domain-error-patterns.md for: full error catalog, naming conventions, error filter mapping, testing patterns.
Rules and Anti-Patterns
What belongs in the domain layer
- Entity classes, VO classes, domain events, repository interfaces, domain service classes, validators
- Types and interfaces that describe domain concepts
What does NOT belong here
| Anti-pattern | Correct approach | |---|---| | import { Injectable } from '@nestjs/common' | Only allowed in infrastructure/ and application/services/ | | import { PrismaService } | Only in Prisma repository implementations | | import { IsEmail } from 'class-validator' | Only in validators (not in VOs) | | new PrismaClient() inside an entity | Repositories handle persistence | | Calling another aggregate's repository | Use a domain service or application service | | Throwing HTTP exceptions (NotFoundException) | Throw domain errors; HTTP mapping is in infrastructure/ | | entity.addDomainEvent() / entity.pullDomainEvents() | Use entity.apply(event) (NestJS CQRS native) |
Event invariants
create()ALWAYS callsthis.apply(new XCreatedEvent(...))restore()NEVER emits events- Mutating methods call
this.touch()thenthis.apply(new XUpdatedEvent(...)) - The repository saves the entity; the CQRS handler commits events
VO invariants
- VOs are immutable — mutating methods return a NEW VO instance
- State Machine VOs always validate transition legality in
transitionTo() - Enum VOs provide named static factories (
static pending()) — callers never pass raw strings
Testing
Entity spec skeleton
// domain/entities/__tests__/unit/.entity.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { Entity } from '../../.entity';
import { DataBuilder } from '../../../testing/helpers/.data-builder';
import { CreatedEvent } from '../../../events/-created.event';
describe('Entity', () => {
describe('create()', () => {
it('should create entity with valid props', () => {
const props = DataBuilder();
const entity = Entity.create(props);
expect(entity.id).toBeDefined();
expect(entity.name).toBe(props.name);
expect(entity.createdAt).toBeInstanceOf(Date);
});
it('should apply Crea
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [Softtor](https://github.com/Softtor)
- **Source:** [Softtor/nestjs-hexagonal](https://github.com/Softtor/nestjs-hexagonal)
- **License:** MIT
- **Homepage:** https://github.com/Softtor/nestjs-hexagonal#readme
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.