Skip to content

Value Objects

A Value Object represents a domain concept that is immutable and has no conceptual identity. Unlike entities, value objects are defined purely by their attributes. They are used to describe characteristics of an entity and often encapsulate logic related to validation or domain-specific constraints.

For example, an Account Name can be modeled as a Value Object since it is defined by its value and doesn’t need a unique identity.

In this library, you can create a Value Object by extending the ValueObject class. Value Objects are expected to be immutable and should encapsulate any validation or business rules required for their creation.

import { ValueObject } from '@ocoda/event-sourcing';
export class AccountName extends ValueObject {
public static fromString(name: string) {
if(name.length < 3) {
throw new Error('Account name should contain at least 3 characters');
}
return new AccountName({ value: name });
}
get value(): string {
return this.props.value;
}
}

By extending the ValueObject class, you get access to the equals method, which compares two Value Objects based on their properties. This method is used to determine if two Value Objects are in fact equal.

Ids are value objects too. Extend UUID or ULID for generated ids, or Id for ids of your own format:

import { ULID, UUID } from '@ocoda/event-sourcing';
export class AccountId extends UUID {}
export class AccountOwnerId extends UUID {}
export class OrderId extends ULID {}
const accountId = AccountId.generate(); // an AccountId
const sameAccountId = AccountId.from(accountId.value); // an AccountId
const ownerIds = ['b6bca415-b7a6-499c-9f39-bf8fbf980a82'].map(AccountOwnerId.from); // AccountOwnerIds, typed as UUID[]
  • generate() and from() create an instance of the class they are called on (since 4.0; 3.x returned a UUID or ULID), also when they are passed around, as in values.map(AccountOwnerId.from). Passed around, a factory is typed as the base class, so ownerIds is a UUID[] that holds AccountOwnerIds: write values.map((value) => AccountOwnerId.from(value)) for an AccountOwnerId[]. ULID.factory() returns a generator of ids that increase monotonically.
  • Ids of different classes are never equal, even with the same value: AccountId.from(value).equals(AccountOwnerId.from(value)) is false.
  • The factories call new this(value). An id class whose constructor takes anything other than the id alone declares its own generate() and from(), and a constructor of your own is protected, not private.
  • from() throws an InvalidIdException for an empty value or a value that doesn’t have the format of the class. A UUID is 32 hexadecimal digits with its four dashes, in either case. A ULID is 26 characters of Crockford’s base32 in either case (the digits and the letters without I, L, O and U), starting with 0 to 7 (since 4.0; 3.x accepted any 26 letters and digits).
  1. Value Objects are immutable, meaning once they are created, they cannot be modified. This ensures consistency and prevents unintended side effects. If a change is needed, you should create a new instance of the Value Object rather than modifying an existing one. For example, if an account name changes, a new AccountName object should be created rather than mutating the existing one. That’s why we provide a ValueObject class to extend, in the background this will freeze the object to prevent any changes.

  2. Value Objects should encapsulate any necessary validation logic at the time of creation. This guarantees that invalid states are not possible. In the example above, we validate that the account name must have at least three characters before creating the AccountName object:

    public static fromString(name: string) {
    if(name.length < 3) {
    throw new Error('Account name should contain at least 3 characters');
    }
    return new AccountName({ value: name });
    }

    By placing validation logic inside the factory method (fromString in this case), we ensure that any AccountName object created is always valid.

  3. Since Value Objects don’t have an identity, they are compared based on the values of their properties. Two Value Objects are considered equal if all their properties are the same. For example, two AccountName objects with the same name would be equal. The ValueObject class implements this equality check for you, ensuring that value comparisons are based on the object’s properties.

    const name1 = AccountName.fromString('John Doe');
    const name2 = AccountName.fromString('John Doe');
    name1.equals(name2); // true

    Value objects of different classes are never equal, even with the same properties, and equals(null) or equals(undefined) returns false (since 4.0).

  4. Although Value Objects are often simple, they can encapsulate domain-specific logic related to their properties. For instance, you might add formatting logic or rules for special cases. However, keep in mind that Value Objects should remain focused and not include complex domain behaviors, which are usually the responsibility of entities or aggregates.

    For example, you could add a method for formatting the account name:

    public formatName() {
    return this.props.value.toUpperCase();
    }
  5. A Value Object should focus only on encapsulating simple domain attributes and logic. Don’t overload it with responsibilities outside of representing and validating its value.