npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rivia-labs/ddd-ts

v1.0.0

Published

A shared foundation for TypeScript projects following our interpretation of DDD, including entities, domain events, aggregates, functional error handling, and other reusable components shared across modules.

Readme

@rivia-labs/ddd-ts

Uma base compartilhada para projetos TypeScript que seguem nossa interpretação de Domain-Driven Design: entidades, agregados, eventos de domínio, tratamento funcional de erros (Either) e outros blocos reutilizáveis pensados para serem compartilhados entre múltiplos módulos/serviços.

License: MIT TypeScript Node

Sumário

Por que esta biblioteca existe

Ao construir vários serviços seguindo DDD, o mesmo punhado de classes se repete em todo projeto: uma base de entidade, um jeito de gerar identificadores únicos, agregados que acumulam eventos de domínio, erros tipados com código/status HTTP, um Either para não estourar exceção a cada regra de negócio quebrada, etc.

@rivia-labs/ddd-ts extrai isso tudo para um pacote único, sem dependências de runtime, para ser instalado em qualquer projeto TypeScript (API, worker, CLI...) que queira seguir essa mesma convenção.

Requisitos

  • Node.js 18+ (o pacote usa node:crypto para gerar UUIDs)
  • TypeScript 5+ no projeto consumidor (o pacote é distribuído com .d.ts, mas os exemplos abaixo assumem TS)

Instalação

npm install @rivia-labs/ddd-ts

Se o pacote ainda não estiver publicado no seu registry, instale diretamente do repositório Git:

npm install git+https://github.com/Victor-Palha/rivia-ddd-ts.git

Guia rápido (passo a passo)

Este guia monta, do zero, um pequeno domínio de Pedidos usando cada peça principal da biblioteca. Cada passo é independente e pode ser copiado direto para o seu projeto.

1. Um Value Object

ValueObject<Props> compara por valor (todas as props, via JSON.stringify), não por referência. O construtor é protected, então toda entidade concreta expõe um método estático de criação:

import { ValueObject } from "@rivia-labs/ddd-ts";

interface MoneyProps {
  amountInCents: number;
  currency: "BRL" | "USD";
}

export class Money extends ValueObject<MoneyProps> {
  private constructor(props: MoneyProps) {
    super(props);
  }

  static create(
    amountInCents: number,
    currency: MoneyProps["currency"] = "BRL",
  ): Money {
    return new Money({ amountInCents, currency });
  }

  get amountInCents(): number {
    return this.props.amountInCents;
  }
}

const priceA = Money.create(1000);
const priceB = Money.create(1000);

priceA.equals(priceB); // true — mesmas props, instâncias diferentes

2. Um identificador único

Use UUIDUniqueEntityId quando quiser IDs gerados automaticamente (crypto.randomUUID()), ou NumericUniqueEntityId quando o ID vem de outro lugar (ex.: SERIAL do banco):

import { UUIDUniqueEntityId } from "@rivia-labs/ddd-ts";

const id = new UUIDUniqueEntityId(); // gera um UUID v4
const sameId = new UUIDUniqueEntityId(id.toValue());

id.equals(sameId); // true

3. Um agregado com eventos de domínio

AggregateRoot<PROPS, ID> estende EntityBase e acrescenta uma lista de domainEvents. Chamar addDomainEvent também registra o agregado em DomainEvents, o "barramento" estático que despacha os eventos depois:

import { AggregateRoot, DomainEvent, UUIDUniqueEntityId } from "@rivia-labs/ddd-ts";

interface OrderProps {
  total: Money;
  status: "PENDING" | "CONFIRMED";
}

class OrderConfirmedEvent implements DomainEvent<UUIDUniqueEntityId> {
  public readonly occurredAt = new Date();

  constructor(private readonly order: Order) {}

  getAggregateId(): UUIDUniqueEntityId {
    return this.order.id;
  }
}

export class Order extends AggregateRoot<OrderProps, UUIDUniqueEntityId> {
  private constructor(props: OrderProps, id: UUIDUniqueEntityId) {
    super(props, id);
  }

  static create(total: Money, id = new UUIDUniqueEntityId()): Order {
    return new Order({ total, status: "PENDING" }, id);
  }

  confirm(): void {
    this._props.status = "CONFIRMED";
    this.addDomainEvent(new OrderConfirmedEvent(this));
  }
}

const order = Order.create(Money.create(5000));
order.confirm();

order.domainEvents.length; // 1

4. Erros de domínio tipados

DomainError, DatabaseError e ExternalError têm a mesma forma — code, message, status? (padrão HttpStatus.BAD_REQUEST) e causes? — mas indicam a origem do problema, o que ajuda o middleware de erro HTTP a decidir como logar/responder:

import { DomainCode, DomainError, HttpStatus } from "@rivia-labs/ddd-ts";

function assertCanConfirm(order: Order): void {
  if (order.props.status === "CONFIRMED") {
    throw new DomainError({
      code: DomainCode.NOT_ALLOWED_ERROR,
      message: "Este pedido já foi confirmado.",
      status: HttpStatus.CONFLICT,
    });
  }
}

5. Either para casos de uso sem exceções

Em vez de lançar DomainError, um caso de uso normalmente retorna um Either<DomainError, Order> — assim o chamador é forçado a tratar o caminho de erro:

import { DomainError, Either, failure, success } from "@rivia-labs/ddd-ts";

function confirmOrder(order: Order): Either<DomainError, Order> {
  if (order.props.status === "CONFIRMED") {
    return failure(
      new DomainError({
        code: DomainCode.NOT_ALLOWED_ERROR,
        message: "Este pedido já foi confirmado.",
      }),
    );
  }

  order.confirm();
  return success(order);
}

const result = confirmOrder(order);

if (result.failure()) {
  // result.value tem o tipo DomainError
  console.error(result.value.message);
} else {
  // result.value tem o tipo Order
  console.log(result.value.domainEvents.length);
}

6. Assinando e despachando eventos

Um EventHandler se inscreve em DomainEvents para reagir a um evento pelo nome da classe. O despacho em si só acontece quando você chama DomainEvents.dispatchEventsForAggregate(id) — tipicamente depois que o agregado foi persistido com sucesso:

import { DomainEvents, EventHandler } from "@rivia-labs/ddd-ts";

class SendOrderConfirmationEmail implements EventHandler {
  setupSubscriptions(): void {
    DomainEvents.register((event) => {
      console.log(
        "Enviando e-mail de confirmação para o pedido",
        event.getAggregateId().toValue(),
      );
    }, OrderConfirmedEvent.name);
  }
}

new SendOrderConfirmationEmail().setupSubscriptions();

// depois de order.confirm() e de persistir o agregado:
DomainEvents.dispatchEventsForAggregate(order.id);

⚠️ DomainEvents casa o handler pelo nome real da classe do evento (event.constructor.name). Evite minificadores que renomeiam classes em produção, a menos que configurados para preservar class names.

7. Repositórios paginados

PaginationParams, FindAllResponseParams e PaginatedResult<T> padronizam a assinatura de métodos de listagem em qualquer repositório:

import { PaginatedResult, PaginationParams } from "@rivia-labs/ddd-ts";

interface OrderFilters {
  status: OrderProps["status"];
}

interface OrderRepository {
  findAll(
    params: PaginationParams<OrderFilters>,
  ): Promise<PaginatedResult<Order>>;
}

Convenções recomendadas: Entities, Value Objects e Function Error Handling

O Guia rápido acima mostra cada peça isolada. Esta seção documenta o padrão que seguimos ao montar uma camada de domínio inteira em cima do @rivia-labs/ddd-ts, incluindo o ponto mais importante: validação é tratada com function error handling (Either), não com throw. Uma exceção lançada só deve representar algo realmente inesperado (bug, infra fora do ar); uma regra de negócio violada ou um dado inválido é sempre um failure(...) retornado, nunca um throw.

Entities

Entidades estendem EntityBase<Props, UUIDUniqueEntityId> e sempre expõem exatamente duas factories estáticas:

  • create(props, id?) — para entidades novas. id é opcional; UUIDUniqueEntityId gera um quando omitido.
  • reconstitute(props, id) — para reconstruir a partir da persistência. id é obrigatório. Usado exclusivamente em mappers.
import { EntityBase, UUIDUniqueEntityId } from "@rivia-labs/ddd-ts";

export type ExampleEntityProps = {
  tenantId: UUIDUniqueEntityId;
  name: string;
};

export class ExampleEntity extends EntityBase<
  ExampleEntityProps,
  UUIDUniqueEntityId
> {
  public static create(props: ExampleEntityProps, id?: string): ExampleEntity {
    return new ExampleEntity(props, new UUIDUniqueEntityId(id));
  }

  public static reconstitute(
    props: ExampleEntityProps,
    id: string,
  ): ExampleEntity {
    return new ExampleEntity(props, new UUIDUniqueEntityId(id));
  }
}
  • Sempre defina um tipo <Entity>Props ao lado da classe e exporte os dois.
  • Props que referenciam outra entidade usam UUIDUniqueEntityId, não string cru.
  • Entidades não validam nada em create/reconstitute — se um caso de uso precisa rejeitar dados inválidos, isso acontece nas Value Objects que compõem as props (veja abaixo), antes de a entidade ser montada.
  • Só adicione métodos à entidade quando eles encapsularem regra de domínio de verdade (ex.: isAtivo()).

Value Objects — VOs de campo

Encapsulam um único escalar validado (Email, CPF, CNPJ, PhoneNumber, ...). A prop é sempre { value: string }.

A diferença para exceções tradicionais: create/createFromText retorna Either<DomainError, Xxx> em vez de lançar. reconstitute continua sem validar (dado já veio confiável do banco) e sem Either, já que não pode falhar.

import {
  DomainError,
  Either,
  ValueObject,
  failure,
  success,
} from "@rivia-labs/ddd-ts";
import { InvalidEmailError } from "./errors/invalid-email-error";

type EmailProps = { value: string };

export class Email extends ValueObject<EmailProps> {
  private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  private constructor(props: EmailProps) {
    super(props);
  }

  get value(): string {
    return this.props.value;
  }

  public static createFromText(raw: string): Either<DomainError, Email> {
    const sanitized = raw.trim().toLowerCase();
    if (!Email.isValid(sanitized)) {
      return failure(new InvalidEmailError(raw));
    }
    return success(new Email({ value: sanitized }));
  }

  public static reconstitute(raw: string): Email {
    return new Email({ value: raw });
  }

  private static isValid(raw: string): boolean {
    return Email.EMAIL_REGEX.test(raw);
  }
}

E o erro correspondente, estendendo DomainError — code é uma string livre (DomainCode cobre só os casos mais comuns, não precisa se limitar a ele):

import { DomainError } from "@rivia-labs/ddd-ts";

export class InvalidEmailError extends DomainError {
  constructor(raw: string) {
    super({
      code: "INVALID_EMAIL_ERROR",
      message: `"${raw}" não é um e-mail válido.`,
    });
  }
}
  • createFromText/create sanitiza, valida e retorna failure(new InvalidXxxError(raw)) — nunca throw.
  • reconstitute pula a validação — dado vindo do banco já é confiável.
  • Construtor sempre private.

Value Objects — VOs de enum

Encapsulam um enum do TypeScript. O enum é declarado no mesmo arquivo e exportado junto com a VO.

import {
  DomainError,
  Either,
  ValueObject,
  failure,
  success,
} from "@rivia-labs/ddd-ts";
import { InvalidContactTypeEnumError } from "./errors/invalid-contact-type-enum-error";

export enum ContactTypeEnum {
  PHONE = 1,
  EMAIL = 2,
  WHATSAPP = 3,
}

export class ContactTypeEnumVO extends ValueObject<ContactTypeEnum> {
  private constructor(props: ContactTypeEnum) {
    super(props);
  }

  static create(type: ContactTypeEnum): ContactTypeEnumVO {
    return new ContactTypeEnumVO(type);
  }

  static createFromNumber(
    value: number,
  ): Either<DomainError, ContactTypeEnumVO> {
    if (!Object.values(ContactTypeEnum).includes(value as ContactTypeEnum)) {
      return failure(new InvalidContactTypeEnumError(value));
    }
    return success(new ContactTypeEnumVO(value as ContactTypeEnum));
  }

  get value(): ContactTypeEnum {
    return this.props;
  }

  toNumber(): number {
    return this.props;
  }

  toString(): string {
    return ContactTypeEnum[this.props];
  }

  isPhone(): boolean {
    return this.props === ContactTypeEnum.Phone;
  }

  isEmail(): boolean {
    return this.props === ContactTypeEnum.Email;
  }

  isWhatsapp(): boolean {
    return this.props === ContactTypeEnum.Whatsapp;
  }
}
  • createFromNumber é a factory de fronteira (usada ao ler um número cru vindo de HTTP ou do banco) — pode falhar, então retorna Either<DomainError, Xxx>.
  • create é a factory interna, usada quando quem chama já tem um valor de enum tipado em mãos — não pode falhar, então retorna a VO diretamente, sem Either.
  • Não existe reconstitute — VOs de enum não têm identidade.
  • Métodos isXxx são usados para verificar o valor do enum. Facilitando a verificação sem precisar comparar diretamente com o valor do enum.

Caso de uso ponta a ponta

Reaproveitando o Email definido acima, um caso de uso típico encadeia várias VOs e nunca lança DomainError — ele sempre retorna o Either, e quem chama decide o que fazer com a failure. Por brevidade, UserRepository, Logger e a VO de enum UserStatus (uma VO de enum como ContactTypeEnumVO, com create/createFromNumber) não são repetidos aqui — seguem exatamente os padrões já mostrados acima:

import {
  DomainCode,
  DomainError,
  Either,
  HttpStatus,
  failure,
  success,
} from "@rivia-labs/ddd-ts";
import { Email } from "../../enterprise/entities/value-objects/email";
import { UserEntity } from "../../enterprise/entities/user-entity";

export class ResourceAlreadyExistsError extends DomainError {
  constructor(resource: string) {
    super({
      code: DomainCode.RESOURCE_ALREADY_EXISTS_ERROR,
      message: `${resource} já existe.`,
      status: HttpStatus.CONFLICT,
    });
  }
}

type CreateUserRequest = {
  email: string;
  name: string;
  age: number;
};

type CreateUserResponse = Either<DomainError, null>;

export class CreateUserUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly logger: Logger,
  ) {}

  public async execute(data: CreateUserRequest): Promise<CreateUserResponse> {
    const emailVO = Email.createFromText(data.email);
    if (emailVO.failure()) {
      return failure(emailVO.value);
    }

    this.logger.log(`Creating user with email: ${emailVO.value.value}`);

    const userExists = await this.userRepository.findByEmail(
      emailVO.value.value,
    );
    if (userExists) {
      return failure(new ResourceAlreadyExistsError("Usuário com esse email"));
    }

    const status = UserStatus.create(UserStatusEnum.ACTIVE);
    const user = UserEntity.create({
      email: emailVO.value,
      name: data.name,
      age: data.age,
      status,
    });

    this.logger.log(`User created with ID: ${user.id.toValue()}`);
    await this.userRepository.create(user);

    return success(null);
  }
}

O que vale notar nesse fluxo:

  • emailVO é um Either<DomainError, Email>. O if (emailVO.failure()) faz o TypeScript estreitar emailVO.value para DomainError dentro do bloco — e o próprio use case só repassa esse failure adiante, sem precisar saber qual VO falhou nem re-envelopar o erro.
  • Erros de infra (userExists) e de domínio (Email inválido) chegam ao chamador pelo mesmo tipo de retorno, Either<DomainError, null> — o controller/handler HTTP trata um único formato, olhando error.status para decidir o código de resposta.
  • user.id.toValue() — UniqueEntityID expõe toValue(), não toString(), para ler o valor bruto do identificador.
  • Nenhum try/catch é necessário no caminho feliz nem no de erro esperado — try/catch fica reservado para falhas de fato inesperadas (ex.: conexão com o banco caiu no meio da query).

Integração com NestJS: capturando erros globalmente

Either resolve o erro dentro da camada de aplicação (use cases), mas em algum ponto da borda HTTP alguém precisa transformar um failure(...) — ou uma exceção genuína — em uma resposta JSON. Como NestJS é o framework mais usado do ecossistema Node/TypeScript, esta seção mostra como capturar DomainError, DatabaseError e ExternalError globalmente com Exception Filters, sem precisar de try/catch em cada controller.

A ideia: um filtro por tipo de erro, todos registrados uma única vez no bootstrap da aplicação.

O formato de resposta

Um DTO simples que sabe se montar a partir de cada tipo de erro da biblioteca (e também de um HttpException nativo do Nest):

// src/errors/error-response.ts
import { HttpException } from "@nestjs/common";
import { DatabaseError, DomainError, ExternalError, HttpStatus } from "@rivia-labs/ddd-ts";

export class ErrorResponse {
  code: string;
  message: string;
  causes: string[] = [];
  timestamp: Date = new Date();

  static ofError(error: Error): ErrorResponse {
    const response = new ErrorResponse();
    response.code = "ERR_GENERIC";
    response.message = error.message;
    return response;
  }

  static ofDomainError(exception: DomainError): ErrorResponse {
    const response = new ErrorResponse();
    response.code = exception.code;
    response.message = exception.message;
    response.causes = exception.causes;
    return response;
  }

  static ofDatabaseError(exception: DatabaseError): ErrorResponse {
    const response = new ErrorResponse();
    response.code = exception.code;
    response.message = exception.message;
    response.causes = exception.causes;
    return response;
  }

  static ofExternalError(exception: ExternalError): ErrorResponse {
    const response = new ErrorResponse();
    response.code = exception.code;
    response.message = exception.message;
    response.causes = exception.causes;
    return response;
  }

  static ofHttpException(exception: HttpException): ErrorResponse {
    const response = new ErrorResponse();
    const status = exception.getStatus();
    const body = exception.getResponse();

    // Reaproveita o reverse-mapping do enum numérico do @rivia-labs/ddd-ts
    // (404 -> "NOT_FOUND") para gerar um `code` estável a partir do status.
    response.code = `HTTP_${HttpStatus[status] ?? status}`;

    if (typeof body === "string") {
      response.message = body;
      return response;
    }

    // ValidationPipe do Nest devolve { message: string[] } quando um DTO falha.
    const { message } = body as { message?: string | string[] };
    if (Array.isArray(message)) {
      response.message = message.join(", ");
      response.causes = message;
    } else {
      response.message = message ?? exception.message;
    }

    return response;
  }
}

⚠️ @rivia-labs/ddd-ts e @nestjs/common exportam, cada um, o seu próprio enum HttpStatus. Se algum dia precisar dos dois no mesmo arquivo, dê um alias em um deles no import (import { HttpStatus as NestHttpStatus } from "@nestjs/common").

Um filtro por tipo de erro

// src/errors/filters/domain-exception.filter.ts
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch } from "@nestjs/common";
import type { Response } from "express";
import { DomainError } from "@rivia-labs/ddd-ts";
import { ErrorResponse } from "../error-response";

@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter<DomainError> {
  catch(exception: DomainError, host: ArgumentsHost): void {
    const response = host.switchToHttp().getResponse<Response>();
    response.status(exception.status).json(ErrorResponse.ofDomainError(exception));
  }
}
// src/errors/filters/database-exception.filter.ts
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, Logger } from "@nestjs/common";
import type { Response } from "express";
import { DatabaseError } from "@rivia-labs/ddd-ts";
import { ErrorResponse } from "../error-response";

@Catch(DatabaseError)
export class DatabaseExceptionFilter implements ExceptionFilter<DatabaseError> {
  private readonly logger = new Logger(DatabaseExceptionFilter.name);

  catch(exception: DatabaseError, host: ArgumentsHost): void {
    // Falha de persistência sempre vale logar — o cliente só vê code/message/causes.
    this.logger.error(exception.message, exception.stack);

    const response = host.switchToHttp().getResponse<Response>();
    response.status(exception.status).json(ErrorResponse.ofDatabaseError(exception));
  }
}
// src/errors/filters/external-exception.filter.ts
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, Logger } from "@nestjs/common";
import type { Response } from "express";
import { ExternalError } from "@rivia-labs/ddd-ts";
import { ErrorResponse } from "../error-response";

@Catch(ExternalError)
export class ExternalExceptionFilter implements ExceptionFilter<ExternalError> {
  private readonly logger = new Logger(ExternalExceptionFilter.name);

  catch(exception: ExternalError, host: ArgumentsHost): void {
    this.logger.warn(`${exception.code}: ${exception.message}`);

    const response = host.switchToHttp().getResponse<Response>();
    response.status(exception.status).json(ErrorResponse.ofExternalError(exception));
  }
}
// src/errors/filters/http-exception.filter.ts
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpException } from "@nestjs/common";
import type { Response } from "express";
import { ErrorResponse } from "../error-response";

// Captura o que o próprio Nest lança: NotFoundException, guards, o
// ValidationPipe de um DTO, etc. — tudo que já é um HttpException nativo.
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
  catch(exception: HttpException, host: ArgumentsHost): void {
    const response = host.switchToHttp().getResponse<Response>();
    response.status(exception.getStatus()).json(ErrorResponse.ofHttpException(exception));
  }
}
// src/errors/filters/default.filter.ts
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
import type { Response } from "express";
import { ErrorResponse } from "../error-response";

// Último recurso: algo que não é DomainError, DatabaseError, ExternalError
// nem HttpException — ou seja, um bug de verdade. @Catch() sem argumentos
// captura absolutamente qualquer coisa lançada, `Error` ou não.
@Catch()
export class DefaultFilter implements ExceptionFilter {
  private readonly logger = new Logger(DefaultFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const error = exception instanceof Error ? exception : new Error(String(exception));
    this.logger.error(error.message, error.stack);

    const response = host.switchToHttp().getResponse<Response>();
    response.status(HttpStatus.INTERNAL_SERVER_ERROR).json(ErrorResponse.ofError(error));
  }
}

Registrando tudo no bootstrap

// src/errors/configure-error.ts
import type { INestApplication } from "@nestjs/common";
import { DatabaseExceptionFilter } from "./filters/database-exception.filter";
import { DefaultFilter } from "./filters/default.filter";
import { DomainExceptionFilter } from "./filters/domain-exception.filter";
import { ExternalExceptionFilter } from "./filters/external-exception.filter";
import { HttpExceptionFilter } from "./filters/http-exception.filter";

export function configureError(app: INestApplication): void {
  app.useGlobalFilters(
    new DefaultFilter(),
    new HttpExceptionFilter(),
    new DomainExceptionFilter(),
    new ExternalExceptionFilter(),
    new DatabaseExceptionFilter()
  );
}
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { configureError } from "./errors/configure-error";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  configureError(app);
  await app.listen(3000);
}
bootstrap();

⚠️ A ordem em useGlobalFilters(...) importa, e é contraintuitiva. Internamente, o Nest inverte a lista de filtros globais antes de decidir qual usar para uma exceção (ele testa cada filtro em ordem e usa o primeiro cujo @Catch() bate, e o filtro registrado por último é testado primeiro). É por isso que DefaultFilter — o catch-all, @Catch() sem argumentos, que bateria com qualquer exceção — vem primeiro na chamada: ele acaba sendo testado por último, funcionando como fallback de verdade, enquanto DatabaseExceptionFilter (registrado por último) é o primeiro a ser testado. Se você registrar o catch-all por último, ele sequestra toda exceção e os outros filtros nunca rodam.

Referência da API

Entidades

| Export | Descrição | | -------------------------- | ---------------------------------------------------------------------------------------------------------- | | EntityBase<T, ID> | Classe abstrata base para entidades. Guarda id e props, expõe equals() (compara por referência). | | AggregateRoot<PROPS, ID> | Estende EntityBase. Acrescenta domainEvents, addDomainEvent() (protegido) e clearEvents(). |

abstract class EntityBase<T, ID> {
  protected constructor(props: T, id: ID);
  get id(): ID;
  get props(): T;
  equals(entity: EntityBase<any, any>): boolean;
}

abstract class AggregateRoot<
  PROPS,
  ID extends UniqueEntityID<string | number>,
> extends EntityBase<PROPS, ID> {
  get domainEvents(): DomainEvent<ID>[];
  protected addDomainEvent(domainEvent: DomainEvent<ID>): void;
  clearEvents(): void;
}

equals() de EntityBase compara identidade de instância (entity === this), não o id. Se você precisa de igualdade por identificador, implemente seu próprio equals na subclasse usando this.id.equals(other.id).

Identificadores

| Export | Descrição | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | UniqueEntityID<T = string \| number> | Classe abstrata base. Guarda um value e expõe toValue() / equals(). | | UUIDUniqueEntityId | UniqueEntityID<string>. Gera um UUID v4 automaticamente via node:crypto quando nenhum valor é passado. | | NumericUniqueEntityId | UniqueEntityID<number>. Não gera valor — use quando o ID vem de outro lugar (ex.: auto incremento do banco). |

new UUIDUniqueEntityId(); // gera um novo UUID v4
new UUIDUniqueEntityId("id-existente"); // reaproveita um valor existente
new NumericUniqueEntityId(42);

Value Objects

| Export | Descrição | | -------------------- | ----------------------------------------------------------------------------------------------------------------- | | ValueObject<Props> | Classe base para objetos de valor. equals() compara todas as props via JSON.stringify (igualdade estrutural). |

Eventos de domínio

| Export | Descrição | | ----------------- | ----------------------------------------------------------------------------------------- | | DomainEvent<ID> | Interface: { occurredAt: Date; getAggregateId(): ID }. | | EventHandler | Interface: { setupSubscriptions(): void } — implementada por cada handler da aplicação. | | DomainEvents | Classe estática que guarda agregados marcados e os handlers registrados. |

class DomainEvents {
  static markAggregateForDispatch(aggregate: AggregateRoot<any, any>): void; // chamado internamente por addDomainEvent
  static dispatchEventsForAggregate(id: UniqueEntityID<string | number>): void;
  static register(callback: (event: any) => void, eventClassName: string): void;
  static clearHandlers(): void;
  static clearMarkedAggregates(): void;
}

clearHandlers() e clearMarkedAggregates() existem principalmente para testes (resetar o estado global entre casos de teste) — veja Testes.

Erros

| Export | Descrição | | --------------- | ------------------------------------------------------------------------------------------------- | | DomainError | Regra de negócio violada (ex.: pedido já confirmado). | | DatabaseError | Falha vinda da camada de persistência. | | ExternalError | Falha vinda de um serviço externo/terceiro. | | DomainCode | Enum de códigos de erro de domínio comuns (RESOURCE_NOT_FOUND_ERROR, NOT_ALLOWED_ERROR, ...). | | HttpStatus | Enum com os códigos de status HTTP padrão. |

As três classes de erro compartilham a mesma forma:

interface ErrorParams {
  code: string;
  message: string;
  status?: HttpStatus; // padrão: HttpStatus.BAD_REQUEST
  causes?: string[]; // padrão: []
}

Either (tratamento funcional de erros)

| Export | Descrição | | ----------------------------------- | --------------------------------------------------------------------------------- | | Either<L, R> | Failure<L, R> \| Success<L, R> | | Success<L, R> | Carrega um value: R. .success() retorna true, .failure() retorna false. | | Failure<L, R> | Carrega um value: L. .success() retorna false, .failure() retorna true. | | success(value) / failure(value) | Funções fábrica para criar Success/Failure sem usar new. |

function divide(a: number, b: number): Either<string, number> {
  if (b === 0) return failure("divisão por zero");
  return success(a / b);
}

const result = divide(10, 2);

if (result.success()) {
  result.value; // number
}

Paginação

| Export | Descrição | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | PaginationParams<T = Record<string, any>> | { page, limit, sortBy?, sortOrder?: "asc" \| "desc", filters?: Partial<T> } | | FindAllResponseParams | Metadados de paginação: { has_next_page, has_previous_page, start_cursor, end_cursor, total_count } | | PaginatedResult<T> | FindAllResponseParams & { data: T[] } |

Tipos utilitários

| Export | Descrição | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Optional<T, K extends keyof T> | Torna opcionais apenas as chaves K de T, mantendo as demais obrigatórias. Útil para DTOs de criação (ex.: id gerado pelo banco). | | LogLevel | Enum de prefixos de log por camada ([USE_CASE], [REPOSITORY], [CONTROLLER], ...). |

interface User {
  id: string;
  name: string;
  email: string;
}

// id não é obrigatório ao criar — name e email continuam sendo
type CreateUserInput = Optional<User, "id">;

Estrutura do projeto

ddd-ts-rivia/
├── src/
│   ├── core/
│   │   ├── entities/
│   │   │   ├── id/
│   │   │   │   ├── numeric-unique-entity-id.ts
│   │   │   │   └── uuid-unique-entity-id.ts
│   │   │   ├── aggregate-root.ts
│   │   │   ├── entity-base.ts
│   │   │   ├── unique-entity-id.ts
│   │   │   └── value-object.ts
│   │   ├── errors/
│   │   │   ├── enums/
│   │   │   │   ├── domain-code-enum.ts
│   │   │   │   └── http-status-error-enum.ts
│   │   │   ├── database-error.ts
│   │   │   ├── domain-error.ts
│   │   │   └── external-error.ts
│   │   ├── events/
│   │   │   ├── domain-event.ts
│   │   │   ├── domain-events.ts
│   │   │   └── event-handler.ts
│   │   ├── repositories/
│   │   │   ├── pagination-params.ts
│   │   │   └── response-params.ts
│   │   └── types/
│   │       ├── either.ts
│   │       ├── logger.ts
│   │       └── optional.ts
│   └── index.ts        # barril público — tudo que o pacote exporta passa por aqui
├── test/                # espelha a estrutura de src/, um *.test.ts por arquivo
├── dist/                # gerado por `npm run build` (não versionado)
└── package.json

Scripts disponíveis

| Script | O que faz | | ----------------------- | ---------------------------------------------------------------------------------------- | | npm run build | Compila src/ para dist/ (JS + .d.ts) via tsc. | | npm run clean | Remove dist/ e dist-test/. | | npm run format | Formata o projeto com o Biome. | | npm run format:check | Só verifica formatação, sem escrever nada. | | npm run check | Roda lint + formatação do Biome, com auto-fix. | | npm test | Compila os testes e roda a suíte com o runner nativo do Node (node --test). | | npm run test:coverage | Igual ao anterior, mas com cobertura (--experimental-test-coverage). | | npm run test:watch | Recompila os testes em modo watch (combine com node --test --watch em outro terminal). |

Testes

Este pacote usa exclusivamente o test runner nativo do Node (node:test + node:assert/strict) — sem Jest, Vitest ou qualquer outra dependência de teste.

Como os enums e o tratamento de decorators do projeto (experimentalDecorators) exigem uma transformação real de TypeScript — e não apenas remoção de tipos —, os testes passam por um passo de compilação antes de rodar:

  1. test/**/*.ts espelha src/**/*.ts (um arquivo de teste por arquivo de produção, mesmo caminho relativo).
  2. tsconfig.test.json compila src/ e test/ juntos para dist-test/.
  3. node --test "dist-test/test/**/*.test.js" executa tudo recursivamente.

Para rodar a suíte:

npm test

Para rodar só um arquivo (depois de compilar uma vez com npm run pretest):

node --test dist-test/test/core/types/either.test.js

Para rodar em modo watch enquanto você edita, abra dois terminais:

npm run test:watch
node --test --watch "dist-test/test/**/*.test.js"

Escrevendo um novo teste

  • Crie o arquivo em test/, no mesmo caminho relativo do arquivo que ele testa, com o sufixo .test.ts (ex.: src/core/types/either.ts → test/core/types/either.test.ts).
  • Classes com construtor protected (como EntityBase, ValueObject, AggregateRoot) não podem ser instanciadas diretamente — crie uma subclasse mínima só para o teste, do mesmo jeito que um consumidor real da biblioteca faria.
  • DomainEvents guarda estado em campos static, compartilhado entre todos os testes do mesmo arquivo. Sempre chame DomainEvents.clearHandlers() e DomainEvents.clearMarkedAggregates() em um beforeEach quando o teste tocar nele (veja test/core/events/domain-events.test.ts).
  • Interfaces e types puros (PaginationParams, Optional, etc.) não existem em tempo de execução — o teste deles serve para documentar o uso esperado e garantir que o formato compila; o tsc do passo de compilação já é, nesse caso, a principal verificação.

Como contribuir

  1. Faça um fork e crie uma branch a partir de main:

    git checkout -b feat/nome-da-sua-mudanca
  2. Instale as dependências:

    npm install
  3. Faça sua alteração em src/. Se estiver adicionando um novo bloco (uma nova classe, enum ou type), exporte-o em src/index.ts — é o único jeito de ele ficar disponível para quem consome o pacote.

  4. Adicione ou atualize os testes correspondentes em test/ (mesmo caminho relativo de src/, sufixo .test.ts). Toda peça pública da biblioteca deve ter cobertura.

  5. Antes de abrir o PR, rode localmente:

    npm run check   # lint + formatação (Biome), com auto-fix
    npm test        # suíte de testes
    npm run build   # garante que o pacote ainda compila para dist/
  6. Estilo de código: o projeto usa Biome (não ESLint/Prettier) — tabs para indentação, aspas duplas, lineWidth de 100 colunas. npm run check já aplica a maior parte disso automaticamente; não é necessário formatar manualmente.

  7. Abra o Pull Request contra main descrevendo o quê mudou e por quê. Se a mudança for observável por quem consome o pacote (uma classe nova, uma mudança de assinatura), inclua um exemplo de uso na descrição — isso também ajuda a manter este README atualizado.

Diretrizes gerais

  • Mantenha os construtores das classes de domínio (EntityBase, ValueObject, AggregateRoot, UniqueEntityID) protected, com criação via método estático — é o padrão usado em toda a biblioteca.
  • Evite adicionar dependências de runtime ao pacote: ele é pensado para ser uma base leve, sem opiniões sobre banco de dados, framework HTTP, etc.
  • Mudanças que quebram compatibilidade (assinatura de método público, remoção de export) devem ser sinalizadas claramente na descrição do PR.

Licença

MIT