API-DD

Guía práctica

La dirección que no necesita llamarse NullAddress

Una dirección puede estar provista o no. NullAddress y AbstractAddress introducen categorías técnicas que no pertenecen a la conversación del consumidor.

Por Lautaro Mei

Un pedido puede tener una dirección de envío. A veces el cliente ya la ha indicado; a veces todavía no.

Ese hecho suele acabar con nombres como estos:

go
type AbstractAddress interface {
    IsValid() bool
}

type NullAddress struct{}

Los nombres parecen resolver un problema de modelado. En realidad describen categorías del código: una abstracción y un objeto nulo. No describen la conversación que necesita mantener quien usa el pedido.

Nadie dice: «este pedido tiene una dirección nula». Dice: «todavía no se ha proporcionado la dirección».

null no es el concepto del negocio#

null responde a una pregunta técnica: ¿hay una referencia a un objeto? Pero esa no suele ser la pregunta del consumidor.

Al preparar un envío, la pregunta útil es otra:

go
address := order.ShippingAddress()
if !address.IsProvided() {
    return ErrShippingAddressRequired
}

La llamada se puede leer sin conocer el lenguaje, el patrón Null Object ni la implementación: el pedido tiene una dirección de envío y esa dirección aún no está provista.

La API puede expresar ese vocabulario directamente:

go
type Address struct {
    provided bool
    street   string
    city     string
    postcode string
}

func UnprovidedAddress() Address {
    return Address{}
}

func NewAddress(street, city, postcode string) (Address, error) {
    if strings.TrimSpace(street) == "" {
        return Address{}, ErrStreetRequired
    }
    if strings.TrimSpace(city) == "" {
        return Address{}, ErrCityRequired
    }
    if strings.TrimSpace(postcode) == "" {
        return Address{}, ErrPostcodeRequired
    }
    return Address{
        provided: true,
        street:   street,
        city:     city,
        postcode: postcode,
    }, nil
}

func (a Address) IsProvided() bool { return a.provided }

El estado no provisto es una posibilidad válida del concepto Address. No es una no-dirección. El detalle de que internamente se represente con campos vacíos, una referencia ausente o un tipo alternativo no tiene por qué cruzar la frontera pública.

Ausente e inválida no son lo mismo#

El prefijo Null también suele ocultar una diferencia importante. Una dirección no provista puede ser admisible mientras se crea el pedido. Una dirección provista pero incompleta no debería serlo.

go
address := UnprovidedAddress() // válida: aún no se eligió la dirección

invalid, err := NewAddress("", "Madrid", "28001")
// err == ErrStreetRequired: no existe una dirección válida con esos datos

Si ambos casos se reducen a nil, el consumidor debe adivinar si falta una decisión o si alguien intentó construir un valor incorrecto. Si ambos se reducen a NullAddress, el tipo tampoco explica qué ocurrió.

El contrato puede hacer observable la diferencia que el consumidor necesita:

go
func (o Order) CanBeShipped() error {
    if !o.ShippingAddress().IsProvided() {
        return ErrShippingAddressRequired
    }
    return nil
}

No hace falta exponer cómo se guarda esa ausencia. Basta con nombrar la regla: para enviar, se requiere una dirección de envío.

abstract tampoco añade significado#

Puede ser útil que una implementación use una interfaz o una clase abstracta. Eso no convierte AbstractAddress en un buen nombre para la API.

go
type AbstractAddress interface {
    Country() string
}

¿Qué diferencia observable hay entre una dirección abstracta y una dirección? El consumidor no puede responder. Tendrá que abrir las implementaciones para descubrir si existen HomeAddress, WarehouseAddress o NullAddress y qué significa cada una.

Si el dominio distingue destinos, esos son los nombres que pueden aparecer:

go
type ShippingDestination interface {
    Country() string
    IsProvided() bool
}

O quizá la distinción no merece una jerarquía pública y basta con Address. La abstracción es una propiedad del diseño; «destino de envío» y «dirección» son conceptos de la conversación.

La misma regla sirve para Base, Impl, Default, Manager, DTO o Response. Pueden ayudar a orientarse dentro de una base de código, pero como nombre de una capacidad pública obligan al consumidor a aprender cómo pensamos el software antes de entender qué puede hacer.

El nombre debe sobrevivir a otra representación#

Hoy Address puede usar un booleano. Mañana puede representar la ausencia con un puntero, un Option, una variante sellada o una fila pendiente en una base de datos:

go
type Address struct {
    state addressState
}

Ninguno de esos cambios altera la pregunta del consumidor: ¿se proporcionó la dirección de envío?

Address e IsProvided siguen siendo ciertos. NullAddress, en cambio, ata el vocabulario público a una estrategia concreta para representar la ausencia. AbstractAddress ata el nombre a una estrategia de herencia o polimorfismo.

Una comprobación sencilla es cambiar mentalmente la implementación. Si el nombre deja de encajar al sustituir un nil por una variante, o una clase abstracta por composición, probablemente nombraba el mecanismo y no la promesa.

La precisión no exige palabras técnicas#

Evitar palabras técnicas no significa usar nombres vagos. Data, State o Value tampoco cuentan la historia. La precisión viene de nombrar la diferencia relevante para quien consume la API:

Nombre públicoPregunta que responde
Address¿cuál es la dirección?
IsProvided¿el cliente ya la indicó?
ErrShippingAddressRequired¿por qué no se puede enviar?
NewAddress¿se puede crear una dirección válida con estos datos?

Los nombres técnicos responden preguntas distintas: cómo se representa la ausencia, qué mecanismo de extensión se eligió o en qué capa vive un tipo. Son preguntas legítimas, pero pertenecen al interior del módulo salvo que sean una decisión que el consumidor realmente deba tomar.

Un test que habla como el usuario#

El vocabulario también hace más claro el contrato que verifican los tests:

go
func TestOrderCannotBeShippedWithoutAProvidedAddress(t *testing.T) {
    order := NewOrder(UnprovidedAddress())

    err := order.CanBeShipped()

    if !errors.Is(err, ErrShippingAddressRequired) {
        t.Fatalf("expected shipping address to be required, got %v", err)
    }
}

El test no conoce nil, una superclase ni un patrón. Declara una situación reconocible y el resultado esperado. Por eso seguirá describiendo el contrato aunque se reemplace la representación interna.

Nombra lo que una persona del dominio puede reconocer. La ausencia de una dirección es una situación; null y abstract son decisiones de implementación.