Os Value Objects no Node.js representam conceitos definidos pelos próprios valores, como dinheiro, e-mail, CPF, período, quantidade, endereço ou porcentagem. Diferentemente de entidades, eles não possuem identidade independente: dois objetos com os mesmos valores são equivalentes.
Encapsular esses conceitos reduz strings e números primitivos espalhados pelo sistema. A validação ocorre uma vez, operações ganham nomes claros e estados inválidos deixam de circular entre controllers, serviços e repositories.
Neste guia, você aprenderá imutabilidade, igualdade, factories, validação, serialização, persistência, TypeScript, testes e exemplos práticos com Money, Email, DateRange e Quantity.
O que é Value Object?
Value Object é um padrão tático de Domain-Driven Design. O conceito é definido pelo conteúdo, não por um identificador. Um valor de R$ 10,00 em BRL é igual a outro com os mesmos dados, independentemente de terem sido criados em momentos diferentes.
Martin Fowler descreve o padrão em Value Object. Para o contexto completo de DDD, consulte Domain-Driven Design no Node.js.
Primitive Obsession
Primitive Obsession ocorre quando conceitos importantes são representados apenas por strings e números:
function createUser(
email: string,
document: string,
balance: number
) {}Nada impede e-mail inválido, documento vazio ou saldo em unidade incorreta.
Tipos específicos
function createUser(
email: Email,
document: DocumentNumber,
balance: Money
) {}O contrato comunica significado e garante que os valores passaram pelas regras de criação.
Imutabilidade
Value Objects devem ser imutáveis. Uma operação retorna outro objeto:
export class Money {
private constructor(
readonly cents: number,
readonly currency: Currency
) {}
add(other: Money): Money {
this.assertSameCurrency(other);
return Money.create(
this.cents + other.cents,
this.currency
);
}
}O objeto original não é alterado.
Factory privada
export class Email {
private constructor(readonly value: string) {}
static create(raw: string): Email {
const normalized = raw.trim().toLowerCase();
if (!EMAIL_REGEX.test(normalized)) {
throw new InvalidEmailError(raw);
}
return new Email(normalized);
}
}O construtor privado impede instâncias inválidas fora da classe.
Regex de e-mail
Não tente reproduzir toda a RFC com uma expressão gigantesca. Valide formato básico, tamanho e requisitos do produto. Confirmação por e-mail é a prova real de posse.
Equality
equals(other: Email): boolean {
return this.value === other.value;
}Para objetos com vários campos:
equals(other: Money): boolean {
return this.cents === other.cents
&& this.currency.equals(other.currency);
}Não compare apenas referências com ===.
Classe base opcional
abstract class ValueObject<T> {
protected constructor(
protected readonly props: Readonly<T>
) {}
equals(other: ValueObject<T>): boolean {
return deepEqual(this.props, other.props);
}
}Uma classe base reduz repetição, mas igualdade profunda genérica pode esconder regras. Implementações explícitas são mais claras para conceitos críticos.
Money
export class Money {
private constructor(
readonly cents: number,
readonly currency: string
) {}
static create(cents: number, currency: string) {
if (!Number.isSafeInteger(cents)) {
throw new InvalidMoneyError();
}
if (!SUPPORTED_CURRENCIES.has(currency)) {
throw new UnsupportedCurrencyError(currency);
}
return new Money(cents, currency);
}
}Evite float para dinheiro
Valores como 0.1 + 0.2 não são exatos em ponto flutuante. Use centavos inteiros ou uma biblioteca decimal quando a precisão exigir.
Operações de Money
subtract(other: Money): Money {
this.assertSameCurrency(other);
return Money.create(
this.cents - other.cents,
this.currency
);
}
multiply(quantity: number): Money {
if (!Number.isSafeInteger(quantity)) {
throw new InvalidQuantityError();
}
return Money.create(
this.cents * quantity,
this.currency
);
}Formatação
format(locale = 'pt-BR'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: this.currency
}).format(this.cents / 100);
}A formatação pode ficar em presenter quando o domínio não deve conhecer locale.
Quantity
export class Quantity {
private constructor(readonly value: number) {}
static create(value: number): Quantity {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new InvalidQuantityError(value);
}
return new Quantity(value);
}
}Depois disso, métodos que recebem Quantity não precisam repetir a validação.
Percentage
export class Percentage {
private constructor(readonly basisPoints: number) {}
static fromPercent(value: number) {
const basisPoints = Math.round(value * 100);
if (basisPoints < 0 || basisPoints > 10000) {
throw new InvalidPercentageError();
}
return new Percentage(basisPoints);
}
applyTo(money: Money): Money {
return Money.create(
Math.round(
money.cents * this.basisPoints / 10000
),
money.currency
);
}
}DateRange
export class DateRange {
private constructor(
readonly start: Date,
readonly end: Date
) {}
static create(start: Date, end: Date) {
if (end.getTime() <= start.getTime()) {
throw new InvalidDateRangeError();
}
return new DateRange(
new Date(start),
new Date(end)
);
}
}Copiar Date evita alteração externa, porque Date é mutável.
Operações temporais
contains(date: Date): boolean {
const time = date.getTime();
return time >= this.start.getTime()
&& time < this.end.getTime();
}
overlaps(other: DateRange): boolean {
return this.start < other.end
&& other.start < this.end;
}Decida se o fim é inclusivo ou exclusivo e documente.
Address
export class Address {
private constructor(
readonly street: string,
readonly number: string,
readonly city: string,
readonly postalCode: PostalCode,
readonly country: CountryCode
) {}
}Endereço pode ser Value Object em um contexto e entidade em outro, como logística com histórico próprio. O modelo depende do domínio.
IDs tipados
export class OrderId {
private constructor(readonly value: string) {}
static from(value: string): OrderId {
if (!UUID_REGEX.test(value)) {
throw new InvalidOrderIdError();
}
return new OrderId(value);
}
}OrderId impede passar CustomerId por engano mesmo que ambos usem string.
Branded Types
TypeScript também permite marcas:
type OrderId = string & {
readonly __brand: 'OrderId';
};É leve em runtime, mas não encapsula comportamento. Classes são úteis quando há validação e operações.
Parse sem exceção
Em entradas esperadamente inválidas, uma factory pode retornar Result:
static parse(raw: string): Result<Email, EmailError> {
const normalized = raw.trim().toLowerCase();
if (!EMAIL_REGEX.test(normalized)) {
return err(new InvalidEmailError(raw));
}
return ok(new Email(normalized));
}O próximo artigo sobre Result Pattern aprofunda essa abordagem.
Exceção ou Result?
Use exceção para violação inesperada de contrato interno. Use Result quando falha é parte normal do fluxo, como formulário inválido ou tentativa de converter dado externo.
Serialização
toJSON() {
return {
cents: this.cents,
currency: this.currency
};
}Não exponha propriedades privadas automaticamente. Defina representação pública.
Presenter
const output = {
total: order.total.format('pt-BR'),
totalCents: order.total.cents,
currency: order.total.currency
};Escolha campos conforme contrato da API.
Persistência
O mapper converte Value Objects:
toPersistence(order: Order) {
return {
id: order.id.value,
email: order.customerEmail.value,
total_cents: order.total.cents,
currency: order.total.currency
};
}Consulte Repository Pattern no Node.js.
Reconstituição
Dados do banco ainda podem estar inválidos devido a legado ou mudanças. Use factories seguras e trate falha de reconstituição como problema de integridade.
ORMs
ORMs geralmente persistem tipos primitivos. Use custom types, transformers ou mappers separados para manter Value Objects fora do modelo de infraestrutura.
Prisma
Prisma retorna objetos simples. Um mapper cria Money, Email e IDs. Não passe o registro Prisma diretamente ao domínio.
Drizzle e Kysely
Mesmo com tipagem forte, os campos representam persistência. Mapeie para conceitos do domínio.
Consulte Drizzle ORM com PostgreSQL e Kysely com TypeScript.
Validação externa e interna
Ajv ou Zod validam formato de request. A factory do Value Object protege todas as entradas, inclusive filas, scripts e banco.
Consulte Ajv no Node.js.
Não duplique regras
O schema pode rejeitar quantidade zero para resposta rápida, mas Quantity continua protegendo a invariante. Mantenha uma fonte conceitual e testes consistentes.
Value Object composto
class Price {
constructor(
readonly amount: Money,
readonly tax: Percentage
) {}
gross(): Money {
return this.amount.add(
this.tax.applyTo(this.amount)
);
}
}Imutabilidade profunda
readonly do TypeScript não congela objetos em runtime. Evite expor arrays mutáveis:
get items(): readonly OrderItem[] {
return [...this._items];
}Value Objects com arrays devem copiar e congelar quando necessário.
Hash e Map
JavaScript Map compara objetos por referência. Para usar Value Object como chave, use uma chave canônica:
map.set(email.value, user);Ou implemente uma coleção que usa toKey().
toString
toString(): string {
return this.value;
}Não inclua segredo em toString, como token ou credencial.
Dados sensíveis
Um Password ou ApiKey pode encapsular redaction:
toString() {
return '[REDACTED]';
}O valor bruto deve ter acesso controlado.
Value Object e eventos
Eventos de integração usam valores serializados, não instâncias de classe:
{
"orderId": order.id.value,
"totalCents": order.total.cents,
"currency": order.total.currency
}Versione o contrato do evento.
Testes
test('soma dinheiro da mesma moeda', () => {
const a = Money.create(1000, 'BRL');
const b = Money.create(500, 'BRL');
const total = a.add(b);
assert.equal(total.cents, 1500);
assert.equal(a.cents, 1000);
});Teste de igualdade
test('emails normalizados são iguais', () => {
const a = Email.create('USER@example.com');
const b = Email.create(' user@example.com ');
assert.equal(a.equals(b), true);
});Testes de propriedade
Property-based testing pode gerar muitos valores e verificar invariantes, como:
- soma mantém moeda;
- Quantity nunca é zero;
- DateRange sempre possui fim posterior;
- parse e serialize preservam valor;
- igualdade é reflexiva, simétrica e transitiva.
Erros comuns
- Objeto mutável: valor muda depois de validado.
- Construtor público: estados inválidos são criados.
- Igualdade por referência: valores iguais parecem diferentes.
- Value Object para tudo: complexidade aumenta.
- Persistência vazando: classe recebe nomes de colunas.
- Float em dinheiro: precisão é perdida.
- Regex excessiva: validação fica frágil.
Quando criar um Value Object?
- o valor possui regras próprias;
- há operações significativas;
- o mesmo conceito aparece em vários lugares;
- confundir unidades é perigoso;
- normalização é necessária;
- estado inválido causa bugs caros.
Quando manter primitivo?
Um campo técnico simples e local pode permanecer primitivo. Não crie uma classe para cada string apenas para seguir um padrão.
Boas práticas
- Use factories.
- Mantenha imutabilidade.
- Implemente igualdade por valor.
- Encapsule operações.
- Evite floats monetários.
- Copie objetos mutáveis.
- Mapeie persistência.
- Defina serialização.
- Proteja dados sensíveis.
- Teste invariantes.
Conclusão
Os Value Objects no Node.js transformam strings e números frágeis em conceitos explícitos. Email, Money, Quantity e DateRange passam a validar e operar seus próprios valores.
Com factories privadas, imutabilidade, igualdade e mapeamento nas fronteiras, estados inválidos deixam de circular pela aplicação. O padrão deve ser aplicado em conceitos importantes, sem criar classes desnecessárias para todo primitivo.




