@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.
Sumário
- Por que esta biblioteca existe
- Requisitos
- Instalação
- Guia rápido (passo a passo)
- Convenções recomendadas: Entities, Value Objects e Function Error Handling
- Integração com NestJS: capturando erros globalmente
- Referência da API
- Estrutura do projeto
- Scripts disponíveis
- Testes
- Como contribuir
- Licença
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:cryptopara 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-tsSe 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.gitGuia 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 diferentes2. 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); // true3. 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; // 14. 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);⚠️
DomainEventscasa 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 preservarclass 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;UUIDUniqueEntityIdgera 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>Propsao lado da classe e exporte os dois. - Props que referenciam outra entidade usam
UUIDUniqueEntityId, nãostringcru. - 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/createsanitiza, valida e retornafailure(new InvalidXxxError(raw))— nuncathrow.reconstitutepula 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 retornaEither<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, semEither.- Não existe
reconstitute— VOs de enum não têm identidade. - Métodos
isXxxsã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é umEither<DomainError, Email>. Oif (emailVO.failure())faz o TypeScript estreitaremailVO.valueparaDomainErrordentro do bloco — e o próprio use case só repassa essefailureadiante, sem precisar saber qual VO falhou nem re-envelopar o erro.- Erros de infra (
userExists) e de domínio (Emailinválido) chegam ao chamador pelo mesmo tipo de retorno,Either<DomainError, null>— o controller/handler HTTP trata um único formato, olhandoerror.statuspara decidir o código de resposta. user.id.toValue()—UniqueEntityIDexpõetoValue(), nãotoString(), para ler o valor bruto do identificador.- Nenhum
try/catché necessário no caminho feliz nem no de erro esperado —try/catchfica 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-tse@nestjs/commonexportam, cada um, o seu próprio enumHttpStatus. 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 queDefaultFilter— 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, enquantoDatabaseExceptionFilter(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()deEntityBasecompara identidade de instância (entity === this), não oid. Se você precisa de igualdade por identificador, implemente seu próprioequalsna subclasse usandothis.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.jsonScripts 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:
test/**/*.tsespelhasrc/**/*.ts(um arquivo de teste por arquivo de produção, mesmo caminho relativo).tsconfig.test.jsoncompilasrc/etest/juntos paradist-test/.node --test "dist-test/test/**/*.test.js"executa tudo recursivamente.
Para rodar a suíte:
npm testPara rodar só um arquivo (depois de compilar uma vez com npm run pretest):
node --test dist-test/test/core/types/either.test.jsPara rodar em modo watch enquanto você edita, abra dois terminais:
npm run test:watchnode --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(comoEntityBase,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. DomainEventsguarda estado em camposstatic, compartilhado entre todos os testes do mesmo arquivo. Sempre chameDomainEvents.clearHandlers()eDomainEvents.clearMarkedAggregates()em umbeforeEachquando o teste tocar nele (vejatest/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; otscdo passo de compilação já é, nesse caso, a principal verificação.
Como contribuir
Faça um fork e crie uma branch a partir de
main:git checkout -b feat/nome-da-sua-mudancaInstale as dependências:
npm installFaça sua alteração em
src/. Se estiver adicionando um novo bloco (uma nova classe, enum ou type), exporte-o emsrc/index.ts— é o único jeito de ele ficar disponível para quem consome o pacote.Adicione ou atualize os testes correspondentes em
test/(mesmo caminho relativo desrc/, sufixo.test.ts). Toda peça pública da biblioteca deve ter cobertura.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/Estilo de código: o projeto usa Biome (não ESLint/Prettier) — tabs para indentação, aspas duplas,
lineWidthde 100 colunas.npm run checkjá aplica a maior parte disso automaticamente; não é necessário formatar manualmente.Abra o Pull Request contra
maindescrevendo 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.
