AgentStack
SKILL verified MIT Self-run

Domain

skill-softtor-nestjs-hexagonal-domain · by Softtor

Use when creating domain layer artifacts for a NestJS bounded context — entities (AggregateRoot), value objects, domain events, repository interfaces, domain services, validators, or data builders. Covers Hexagonal Architecture + DDD patterns with NestJS CQRS native event support.

No reviews yet
0 installs
0 views
view→install

Install

$ agentstack add skill-softtor-nestjs-hexagonal-domain

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README — it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-softtor-nestjs-hexagonal-domain)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
4mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming — see below.

Preview Execution monitoring

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 →
Are you the author of Domain? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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; calls this.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/* or class-validator in 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) then entity.commit() to publish
  • Event handlers use @EventsHandler + IEventHandler — never @OnEvent for 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 calls this.apply(new XCreatedEvent(...))
  • restore() NEVER emits events
  • Mutating methods call this.touch() then this.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.

Versions

  • v0.1.0 Imported from the upstream source.