Featured image of post Primary card - Value object

Primary card - Value object

El 'Value object' es la pieza más pequeña del 'Domain'.

Especificaciones

Value object Primary reverse

Suele tener una complejidad baja salvo excepciones muy puntuales, ya que sólo valida que su información es consistente y alguna operación que lo modifique. Pero como actúan como imanes de lógica de negocio, le subimos un punto la complejidad, con lo que le damos una complejidad de 2.

Su principal habilidad es contener pequeñas unidades de información y que cumplan sus reglas.

Los Value objects son fáciles de diferenciar de los Aggregates porque no tienen un identificador que los haga únicos. Aparte contienen uno o más primitivos.

Como característica remarcable, cabe decir que son inmutables. Eso quiere decir que si se intenta alterar el contenido del Value object, nos devolverá un Value object nuevo. Esto se hace para evitar los llamados “side effects”, porque podemos enviar un valor a través de las funciones u otros objetos y podría ser alterado sin nuestro conocimiento al ser pasado por referencia.

Si tenemos que comparar si dos Value objects son iguales, lo haremos sobreescribiendo el método “equals” o implementando uno para que compare si sus valores coinciden entre los dos.

Se relaciona con los que lo contienen, que pueden ser Aggregate root, Aggregate y otros Value objects.

¿Qué valor me aporta implementar un Value object?

Al ser la pieza más pequeña del Domain, también es el responsable de definir las pequeñas reglas que existen en dicho Domain.

  • Por un lado, todas esas pequeñas normas y validaciones que podrían parecer un grano de arena, en un desarrollo iterativo e incremental, a medida que el sistema se vuelve más complejo o maduro, se vuelven una montaña.
  • Por otro lado, si se define una regla de Domain nueva que afecta a un Value object, ya tenemos el objeto candidato a centralizar dicha verificación.
  • También mantiene a raya el uso de librerías en nuestro Domain. Si necesitamos una librería que nos gestione temas complejos, como validar un NIF, nuestro Value object actuará de wrapper para envolver dicha dependencia, y si en un futuro queremos cambiar de librería o implementar directamente la validación, sólo tendremos que cambiar una sola clase en toda la aplicación.

¿Un Value object puede contener otros Value objects?

Pues la verdad es que si. La idea es que siempre vayamos pasando valores con un tipado fuerte para evitar bailes de parámetros en funciones y tener las pequeñas validaciones ubicadas en su lugar correcto, que son los Value objects.

¿Cómo se expresa esta carta en el mundo real?

Como indica el icono de arriba a la izquierda, corresponde a una clase.

Lo ideal es que sea una clase sin herencia, ya que cada Value object, aunque contenga primitivos, tendrá sus propias normas y operaciones. Aunque hay maneras de asegurar que podamos heredar sin las consecuencias más perjudiciales derivadas de ello.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
final readonly class Email
{
    private function __construct(private string $email)
    {
    }

    /**
     * @throws InvalidEmailException
     */
    public static function fromString(string $email): EmailValue
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw InvalidEmailException::badFormat($email);
        }

        return new static($email);
    }

    public function value(): string
    {
        return $this->email;
    }

    public function equals(Email $other): bool
    {
        return $this->value() === $other->value();
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
data class Email private constructor(private val email: String) {

    companion object {
        @Throws(InvalidEmailException::class)
        fun fromString(email: String): Email {
            if (!email.matches(Regex(".+@.+\\..+"))) { // Regex for basic email validation
                throw InvalidEmailException.badFormat(email)
            }
            return Email(email)
        }
    }

    fun value(): String {
        return email
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Email) return false
        return email == other.email
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@final
class Email {
  private readonly _email: string;

  private constructor(email: string) {
    if (!/^\S+@\S+\.\S+$/.test(email)) {
      throw new InvalidEmailException("Invalid email format");
    }
    this._email = email;
  }

  static fromString(email: string): Email {
    return new Email(email);
  }

  value(): string {
    return this._email;
  }

  equals(other: Email): boolean {
    return this.value() === other.value();
  }
}

Actividad: ¿Value object o no?

A continuación veremos ejemplos con código y decidiremos si es un Value object o no.

Caso 1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
final class Money
{
    public function __construct(
        private string $amount, 
        private string $currency,
    ) {
        if (!is_numeric($amount)) {
            throw new InvalidAmountException::notNumeric($amount);
        }
    }

    public function amount(): string
    {
        return $this->amount;
    }

    public function currency(): string
    {
        return $this->currency;
    }


    public function equals(Money $other): bool
    {
        return $this->amount() === $other->amount() &&
            $this->currency() === $other->currency();
    }
}
Solución
  • NO lo es.
  • No valida el currency.
  • Aspectos interesantes

    • En php no es heredable, con lo que no sufre los problemas intrínsecos de la herencia.
    • En php, la validación que hace tiene una excepción personalizada con constructor semántico.
Caso 2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
final class Money
{
    public function __construct(
        private string $amount, 
        private string $currency,
    ) {

        if (!is_numeric($amount)) {
            throw new InvalidAmountException::notNumeric($amount);
        }

        if (!in_array(strtoupper($currency), ['EUR', 'USD'])) {
            throw new InvalidCurrencyException::notInTheAcceptedCurrencies($currency);
        }
    }

    public function amount(): string
    {
        return $this->amount;
    }

    public function currency(): string
    {
        return $this->currency;
    }

    public function equals(Money $other): bool
    {
        return $this->amount() === $other->amount() &&
            $this->currency() === $other->currency();
    }

    public function add(string $amount): void
    {
        $this->amount = bcadd($this->amount, $amount, 2);
    } 
}
Solución
  • NO lo es.
  • Es mutable, con lo que puede crear “side effects” inesperados.
  • El parámetro que acepta la función add debería ser del mismo tipo que la clase que lo recibe. O sea, Money.
  • No valida el Currency en la función add
Caso 3
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
final class Money
{
    private string $amount;
    private string $currency;

    public function __construct(
        private string $amount, 
        private string $currency,
    ) {

        if (!is_numeric($amount)) {
            throw new InvalidAmountException::notNumeric($amount);
        }

        if (!in_array(strtoupper($currency), ['EUR', 'USD'])) {
            throw new InvalidCurrencyException::notInTheAcceptedCurrencies($currency);
        }
   }

    public function amount(): string
    {
        return $this->amount;
    }

    public function currency(): string
    {
        return $this->currency;
    }

    public function equals(Money $other): bool
    {
        return $this->amount() === $other->amount() &&
            $this->currency() === $other->currency();
    }

    public function add(Money $other): Money
    {
        if ($this->currency !== $other->currency()) {
            throw new InvalidCurrencyException::notEquals(
                $this->currency(), 
                $other->currency(),
            );
        }

        $sum = bcadd($this->amount(), $other->amount(), 2);

        return new self($sum, $this->currency);
    }
}
Solución
  • SI lo es.
  • Valida los valores
  • Valida que las dos monedas son iguales a nivel de Currency.
  • Ahora devuelve un objeto nuevo si se le agrega un valor.
Caso 4
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
final class Money
{
    public function __construct(
        private string $amount, 
        private Currency $currency,
    ) {
        if (!is_numeric($amount)) {
            throw new InvalidAmountException::notNumeric($amount);
        }
    }

    public function amount(): string
    {
        return $this->amount;
    }

    public function currency(): Currency
    {
        return $this->currency;
    }

    public function add(Money $other): Money
    {
        if (!$this->currency->equals($other->currency())) {
            throw new InvalidCurrencyException::notEquals(
                $this->currency(), 
                $other->currency(),
            );
        }

        $sum = bcadd($this->amount(), $other->amount(), 2);

        return new self($sum, $this->currency);
    }

    public function equals(Money $other): bool
    {
        return bccomp($this->amount(), $other->amount(), 2) === 0 &&
               $this->currency->equals($other->currency());
    }
}
Solución
  • SI lo es.
  • Contiene otro value object y ahora es más robusto.
  • La lógica que regula el Currency se ha traspasado a un Value object propio.
  • En PHP evitamos el riesgo de cruzar valores primitivos sin querer en el constructor.
Aún podemos darle una vuelta de tuerca más...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
readonly class Money
{
    private function __construct(
        private string $amount, 
        private Currency $currency,
    ) {

        if (!is_numeric($amount)) {
            throw new InvalidAmountException::notNumeric($amount);
        }
    }

    public static function eur(string $amount): self
    {
        return new static($amount, Currency::eur());
    }

    public function amount(): string
    {
        return $this->amount;
    }

    public function currency(): Currency
    {
        return $this->currency;
    }

    public function add(Money $other): Money
    {
        if (get_class($this) != get_class($other)) {
            throw new InvalidArgumentException(
                "Expected " . get_class($this) . " value object, not " . get_class($other)
            );
        }

        if (!$this->currency->equals($other->currency())) {
            throw new InvalidCurrencyException::notEquals(
                $this->currency(), 
                $other->currency(),
            );
        }

        $sum = bcadd($this->amount(), $other->amount(), 2);

        return new self($sum, $this->currency);
    }
    
    public function equals(Money $other): bool
    {
        if (get_class($this) != get_class($other)) {
            throw new InvalidArgumentException(
                "Expected " . get_class($this) . " value object, not " . get_class($other)
            );
        }
        
        return bccomp($this->amount(), $other->amount(), 2) === 0 &&
               $this->currency->equals($other->currency());
    }
}
Explicación
  • Nos aseguramos que la clase sea de solo lectura después de crearla.
  • En el caso que dejemos que haya herencia, nos aseguramos que aunque se creen clases que extienden de Money, solo puedan compararse entre sus mismas clases finales.
  • Agregamos un constructor semántico para aportar contexto. En este caso creamos el Value object con Currency Euro.
Caso 1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Money(private val amount: Float, private val currency: String) {

    fun amount(): Float = amount

    fun currency(): String = currency

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) 
            throw InvalidClassException("Expected $javaClass value object.")

        val otherMoney = other as Money

        if (amount != otherMoney.amount) return false
        if (currency != otherMoney.currency) return false

        return true
    }

    override fun hashCode(): Int {
        return amount.hashCode() + currency.hashCode()
    }
}
Solución
  • NO lo es.
  • No valida el currency.
  • Aspectos interesantes

    • Kotlin gestiona la posibilidad de herencia en el equals de otra manera, ya que te fuerza a implementar su equals con Any? como tipo de argumento.
Caso 2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Money(private var amount: Float, private var currency: String) {

    init {
        require(currency.uppercase() in listOf("EUR", "USD")) {
            throw InvalidCurrencyException.notInTheAcceptedCurrencies(currency)
        }
    }

    fun amount(): Float = amount

    fun currency(): String = currency

    fun add(amount: Float) {
        this.amount += amount
    }
            
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) 
            throw InvalidClassException("Expected $javaClass value object.")

        val otherMoney = other as Money

        if (amount != otherMoney.amount) return false
        if (currency != otherMoney.currency) return false

        return true
    }

    override fun hashCode(): Int {
        return amount.hashCode() + currency.hashCode()
    }
}
Solución
  • NO lo es.
  • Es mutable, con lo que puede crear “side effects” inesperados.
  • El parámetro que acepta la función add debería ser del mismo tipo que la clase que lo recibe. O sea, Money.
  • No valida el Currency en la función add
Caso 3
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Money(private val amount: Float, private val currency: String) {

    init {
        require(currency.uppercase() in listOf("EUR", "USD")) {
            throw InvalidCurrencyException.notInTheAcceptedCurrencies(currency)
        }
    }

    fun amount(): Float = amount

    fun currency(): String = currency

    fun add(other: Money): Money {

        if (this.currency !== other.currency())
            throw InvalidCurrencyException.notEquals(this.currency, other.currency());

        val amount = this.amount + other.amount()
        return Money(amount, this.currency)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) 
            throw InvalidClassException("Expected $javaClass value object.")

        val otherMoney = other as Money

        if (amount != otherMoney.amount) return false
        if (currency != otherMoney.currency) return false

        return true
    }

    override fun hashCode(): Int {
        return amount.hashCode() + currency.hashCode()
    }
}
Solución
  • SI lo es.
  • Valida los valores
  • Valida que las dos monedas son iguales a nivel de Currency.
  • Ahora devuelve un objeto nuevo si se le agrega un valor.
Caso 4
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Money(private val amount: Float, private val currency: Currency) {

    fun amount(): Float = amount

    fun currency(): Currency = currency

    fun add(other: Money): Money {

        if (!this.currency.equals(other.currency()))
            throw InvalidCurrencyException.currencyNotEquals(this.currency, other.currency());

        val amount = this.amount + other.amount()
        return Money(amount, this.currency)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) 
            throw InvalidClassException("Expected $javaClass value object.")

        val otherMoney = other as Money

        if (amount != otherMoney.amount) return false
        if (!currency.equals(otherMoney.currency)) return false

        return true
    }

    override fun hashCode(): Int {
        return amount.hashCode() + currency.hashCode()
    }
}
Solución
  • SI lo es.
  • Contiene otro value object y ahora es más robusto.
  • La lógica que regula el Currency se ha traspasado a un Value object propio.
Aún podemos darle una vuelta de tuerca más...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
open class Money private constructor(private val amount: Float, private val currency: Currency) {

    companion object {
        fun eur(amount: Float): Money {
            return Money(amount, Currency.eur())
        }
    }

    fun amount(): Float = amount

    fun currency(): Currency = currency

    fun add(other: Money): Money {
        if (other == null || javaClass != other.javaClass)
            throw GenericException.incompatibleValueObject(javaClass, other.javaClass);

        if (!this.currency.equals(other.currency()))
            throw InvalidCurrencyException.currencyNotEquals(this.currency, other.currency());

        val amount = this.amount + other.amount()
        return Money(amount, this.currency)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) 
            throw InvalidClassException("Expected $javaClass value object.")

        val otherMoney = other as Money

        if (amount != otherMoney.amount) return false
        if (!currency.equals(otherMoney.currency)) return false

        return true
    }

    override fun hashCode(): Int {
        return amount.hashCode() + currency.hashCode()
    }
}
Explicación
  • Agregamos un constructor semántico para aportar contexto. En este caso creamos el Value object con Currency Euro.
Caso 1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Money {
    private _amount: number;
    private _currency: string;

    constructor(amount: number, currency: string) {
        if (!Number.isFinite(Number(amount))) {
            throw InvalidAmountException.invalidAmount(amount);
        }

        this._amount = amount;
        this._currency = currency;
    }

    amount(): number {
        return this._amount;
    }

    currency(): string {
        return this._currency;
    }

    equals(other: Money): boolean {
        return this.amount() === other.amount() &&
            this.currency() === other.currency();
    }
}
Solución
  • NO lo es.
  • No valida el currency.
  • Aspectos interesantes

    • La validación que hace tiene una excepción personalizada con constructor semántico.
Caso 2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@final
class Money {
    private _amount: number;
    private _currency: string;

    constructor(amount: number, currency: string) {
        const acceptedCurrencies: string[] = ['EUR', 'USD'];

        if (!acceptedCurrencies.includes(currency.toUpperCase())) {
            throw InvalidCurrencyException.notInTheAcceptedCurrencies(currency);
        }

        if (!Number.isFinite(Number(amount))) {
            throw InvalidAmountException.invalidAmount(amount);
        }

        this._amount = amount;
        this._currency = currency;
    }

    add(amount: number): void {
        this._amount += amount;
    }

    amount(): number {
        return this._amount;
    }

    currency(): string {
        return this._currency;
    }

    equals(other: Money): boolean {
        return this.amount() === other.amount() &&
            this.currency() === other.currency();
    }
}
Solución
  • NO lo es.
  • Es mutable, con lo que puede crear “side effects” inesperados.
  • El parámetro que acepta la función add debería ser del mismo tipo que la clase que lo recibe. O sea, Money.
  • No valida el Currency en la función add
Caso 3
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@final
class Money {
    private _amount: number;
    private _currency: string;

    constructor(amount: number, currency: string) {
        const acceptedCurrencies: string[] = ['EUR', 'USD'];

        if (!acceptedCurrencies.includes(currency.toUpperCase())) {
            throw InvalidCurrencyException.notInTheAcceptedCurrencies(currency);
        }

        if (!Number.isFinite(Number(amount))) {
            throw InvalidAmountException.invalidAmount(amount);
        }

        this._amount = amount;
        this._currency = currency;
    }

    add(other:  Money): Money {
        if(this.currency() !== other.currency()) {
            throw InvalidCurrencyException.notEquals(this.currency(), other.currency());
        }

        const sum = this.amount() + other.amount();

        return new Money(sum, this._currency);
    }

    amount(): number {
        return this._amount;
    }

    currency(): string {
        return this._currency;
    }

    equals(other: Money): boolean {
        return this.amount() === other.amount() &&
            this.currency() === other.currency();
    }
}
Solución
  • SI lo es.
  • Valida los valores
  • Valida que las dos monedas son iguales a nivel de Currency.
  • Ahora devuelve un objeto nuevo si se le agrega un valor.
Caso 4
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@final
class Money {
    private _amount: number;
    private _currency: Currency;

    constructor(amount: number, currency: Currency) {
        if (!Number.isFinite(Number(amount))) {
            throw InvalidAmountException.invalidAmount(amount);
        }

        this._amount = amount;
        this._currency = currency;
    }

    add(other:  Money): Money {
        if(!this.currency().equals(other.currency())) {
            throw InvalidCurrencyException.notEquals(this.currency, other.currency());
        }

        const sum = this.amount() + other.amount();

        return new Money(sum, this._currency);
    }

    amount(): number {
        return this._amount;
    }

    currency(): Currency {
        return this.currency;
    }

    equals(other: Money): boolean {
        return this.amount() === other.amount() &&
            this._currency.equals(other.currency());
    }
}
Solución
  • SI lo es.
  • Contiene otro value object y ahora es más robusto.
  • La lógica que regula el Currency se ha traspasado a un Value object propio.
Aún podemos darle una vuelta de tuerca más...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Money {
    private readonly _amount: number;
    private readonly _currency: Currency;

    public constructor(amount: number, currency: Currency) {
        if (!Number.isFinite(Number(amount))) {
            throw InvalidAmountException.invalidAmount(amount);
        }

        this._amount = amount;
        this._currency = currency;
    }

    public static eur(amount: number): Money {
        return new Money(amount, Currency.eur())
    }

    add(other:  Money): Money {
        if (this instanceof other.constructor === false) {
            throw new TypeError("Incompatible value object");
        }

        if(!this.currency.equals(other.currency())) {
            throw InvalidCurrencyException.notEquals(this.currency, other.currency());
        }

        const sum = this.amount() + other.amount();

        return new Money(sum, this._currency);
    }

    amount(): number {
        return this._amount;
    }

    currency(): Currency {
        return this._currency;
    }

    equals(other: Money): boolean {
        if (this instanceof other.constructor === false) {
            throw new TypeError("Incompatible value object");
        }

        return this.amount() === other.amount() &&
            this._currency.equals(other.currency());
    }
}
Explicación
  • Nos aseguramos que la clase sea de solo lectura después de crearla.
  • En el caso que usemos herencia, nos aseguramos que aunque se creen clases que extienden de Money, solo puedan compararse entre sus mismas clases finales.
  • Agregamos un constructor semántico para aportar contexto. En este caso creamos el Value object con Currency Euro.

Para profundizar más…

Implementing Domain-Driven Desing: Chapter 6 - Value objects

Patterns Principles and Practices of Domain Driven Design: Chapter 15 - Value objects

Domain-Driven Design: Chapter 5 - A Model Expressed in Software - Value objects


Licensed under CC BY-NC-SA 4.0
Creado con Hugo
Tema Stack diseñado por Jimmy