Value Objects en Go
2025-03-25
Llevo años experimentando con Value Objects (VO) en varios lenguajes, desde JavaScript y TypeScript hasta Python y, más recientemente, Go.
Llevo dos años sumergido en Go por lo que he tenido la necesidad de implementar Value Objects en este lenguaje. Abajo comparto el acercamiento que he estado usando recientemente con bastante éxito.
Esta implementación cumple con todos mis requisitos:
- Es imposible instanciar un VO inválido
- Métodos
New*
devuelven unerror
- Métodos
MustNew*
hacen panic si hay un error - Inmutabilidad
- Igualdad basada en valor
- Encapsulación del comportamiento
Para la mayoría de los casos uso el método New*
que devuelve un error
y me permite
decidir como manejar los casos en que la instanciación falla. El método MustNew*
lo uso principalmente
en pruebas, dónde puedo estar seguro que estoy creando un VO válido manualmente y de esta forma evito tener que
manejar
un error que nunca sucederá.
En todo caso es imposible crear un VO usando uno de estos métodos sin que cumpla con todos los requisitos de mi dominio. Y donde veo que se crea un VO sin usar uno de estos dos métodos puedo rechazar ese código sin pensarlo dos veces. Aunque esto require un poco de disciplina, es una regla fácil de seguir y hacer cumplir.
Este es un ejemplo sintético de como implemento VO en Go. Es intencionalmente simple para que sea fácil de entender y
aplicar en otros codebases. Aunque los patrones y el diseño del VO está bien y correcto esto no es una
implementación válida de Amount
así que no hagas copy y paste y lo uses en producción, te va a doler.
package amount
import (
"errors"
"fmt"
)
type Currency string
const (
USD Currency = "USD"
EUR Currency = "EUR"
)
func (c Currency) IsValid() bool {
validCurrencies := map[Currency]bool{
USD: true,
EUR: true,
}
return validCurrencies[c]
}
type Amount struct {
value int
currency Currency
}
func NewAmount(value int, currency Currency) (Amount, error) {
if value < 0 {
return Amount{}, errors.New("amount must be positive")
}
if value > 1_000_000 {
return Amount{}, errors.New("amount must be less than or equal to 1,000,000")
}
if !currency.IsValid() {
return Amount{}, errors.New("invalid currency")
}
return Amount{
value: value,
currency: currency,
}, nil
}
func MustNewAmount(value int, currency Currency) Amount {
a, err := NewAmount(value, currency)
if err != nil {
panic(err)
}
return a
}
func (a Amount) Value() int {
return a.value
}
func (a Amount) Currency() Currency {
return a.currency
}
func (a Amount) String() string {
switch a.currency {
case USD:
return fmt.Sprintf("$%d", a.value)
case EUR:
return fmt.Sprintf("%d€", a.value)
default:
return fmt.Sprintf("%d %s", a.value, a.currency)
}
}
func (a Amount) Equal(other Amount) bool {
return a.value == other.value && a.currency == other.currency
}
func (a Amount) ConvertTo(newCurrency Currency) (Amount, error) {
if a.currency == newCurrency {
return a, nil
}
// Assume 1 USD = 0.85 EUR
conversionRate := 0.85
if a.currency == EUR {
conversionRate = 1 / conversionRate
}
newValue := int(float64(a.value) * conversionRate)
return NewAmount(newValue, newCurrency)
}
func (a Amount) Add(other Amount) (Amount, error) {
if a.currency != other.currency {
return Amount{}, errors.New("cannot add amounts with different currencies")
}
return NewAmount(a.value+other.value, a.currency)
}
func (a Amount) Subtract(other Amount) (Amount, error) {
if a.currency != other.currency {
return Amount{}, errors.New("cannot subtract amounts with different currencies")
}
return NewAmount(a.value-other.value, a.currency)
}
Siento que si un veterano de Go mira este código no le encantaría. Pero para mí que llevo solo dos años en Go se siente natural y creo que encaja bien con los otros APIs que he visto en el "standard library". Si tienes una mejor forma de implementar VOs en Go me gustaría verla.