API-DD

Guía práctica

El constructor que permite existir a un valor inválido

Comparar setters más Validate con una creación atómica de Interval muestra cómo proteger invariantes y definir el significado del valor cero en Go.

Por Lautaro Mei

Queremos representar un intervalo cuyo inicio sea menor que su final. La primera versión permite construirlo paso a paso:

go
type Interval struct {
    Start int
    End   int
}

func (i *Interval) SetStart(start int) {
    i.Start = start
}

func (i *Interval) SetEnd(end int) {
    i.End = end
}

func (i Interval) Validate() error {
    if i.Start >= i.End {
        return ErrInvalidInterval
    }
    return nil
}

El uso esperado necesita cuatro decisiones coordinadas:

go
interval := Interval{}
interval.SetStart(2)
interval.SetEnd(8)
if err := interval.Validate(); err != nil {
    return err
}

Entre esas llamadas existen varios valores observables: 0..0, 2..0 y, si se intercambian los setters, 0..8. Ninguno representa necesariamente la intención final. Aun así, pueden pasar a otra función, guardarse o utilizarse antes de validar.

El constructor vacío no creó un intervalo. Creó una tarea pendiente que todos los consumidores deben saber terminar.

Validate no protege una invariante si es opcional#

El método detecta un problema cuando alguien recuerda llamarlo. No impide que otro método opere antes:

go
func (i Interval) Contains(point int) bool {
    return point >= i.Start && point < i.End
}

Este código no falla de forma visible con Interval{Start: 8, End: 2}. Simplemente devuelve false para todo. El valor inválido se comporta como un intervalo vacío y oculta el error que lo originó.

También puede ocurrir lo contrario: cada operación llama a Validate para defenderse.

go
func (i Interval) Contains(point int) (bool, error) {
    if err := i.Validate(); err != nil {
        return false, err
    }
    return point >= i.Start && point < i.End, nil
}

Ahora todas las operaciones repiten una preocupación de construcción. Cada consumidor debe gestionar un error que no depende del punto consultado, sino de que el valor nunca llegó a estar completo.

Expresa la intención en una sola llamada#

La API puede recibir las dos partes necesarias y comprobar la relación antes de entregar el valor:

go
var ErrInvalidInterval = errors.New("start must be lower than end")

type Interval struct {
    start int
    end   int
    valid bool
}

func NewInterval(start, end int) (Interval, error) {
    if start >= end {
        return Interval{}, ErrInvalidInterval
    }
    return Interval{
        start: start,
        end:   end,
        valid: true,
    }, nil
}

Los campos privados evitan combinaciones posteriores que rompan la relación. La creación tiene dos resultados claros:

text
NewInterval(2, 8) → intervalo válido
NewInterval(8, 2) → ErrInvalidInterval

El consumidor no recibe un objeto a medio montar. Recibe capacidad utilizable o un fallo.

go
func (i Interval) Contains(point int) bool {
    return i.valid && point >= i.start && point < i.end
}

func (i Interval) Bounds() (start, end int, ok bool) {
    if !i.valid {
        return 0, 0, false
    }
    return i.start, i.end, true
}

Esta no es la única representación posible. El campo valid hace explícita una decisión que en Go no podemos evitar: qué significa Interval{}.

Publicar NewInterval no elimina el valor cero#

En Go todo tipo concreto tiene un valor cero. Aunque los campos sean privados y la documentación diga que se use el constructor, esto siempre compila:

go
var interval Interval

Por eso «obligar a usar el constructor» no describe completamente el contrato. Hay que elegir qué hace el valor cero.

Existen tres políticas habituales:

En este ejemplo elegimos la tercera. Contains devuelve false y Bounds devuelve ok == false. La creación exitosa sigue siendo el único camino para obtener límites válidos.

Otra API podría definir el intervalo vacío como concepto legítimo y convertir el valor cero en útil. Lo importante no es copiar esta política; es que exista y que los consumidores puedan observarla sin adivinar.

Un puntero tampoco resuelve toda la decisión#

Devolver *Interval permite usar nil como ausencia:

go
func NewInterval(start, end int) (*Interval, error)

Puede ser apropiado si identidad, tamaño o ausencia justifican el puntero. No evita valores inválidos dentro del paquete ni explica qué hacen los métodos ante un receptor nil. Tampoco impide que alguien declare var interval Interval cuando el tipo sigue exportado.

Elegir valor o puntero es una decisión diferente de proteger la invariante.

El test compara creación atómica con montaje parcial#

Los primeros casos deberían exigir los dos resultados del constructor:

go
func TestNewIntervalCreatesAUsableValue(t *testing.T) {
    interval, err := NewInterval(2, 8)
    if err != nil {
        t.Fatalf("new interval: %v", err)
    }

    if !interval.Contains(2) || interval.Contains(8) {
        t.Fatal("expected half-open interval [2, 8)")
    }
}

func TestNewIntervalRejectsReversedBounds(t *testing.T) {
    _, err := NewInterval(8, 2)
    if !errors.Is(err, ErrInvalidInterval) {
        t.Fatalf("expected invalid interval, got %v", err)
    }
}

Y un caso separado conserva la política del valor cero:

go
func TestZeroIntervalIsInvalidButSafe(t *testing.T) {
    var interval Interval

    if interval.Contains(0) {
        t.Fatal("zero interval must not contain values")
    }
    if _, _, ok := interval.Bounds(); ok {
        t.Fatal("zero interval must not expose valid bounds")
    }
}

Estos tests no exigen el campo valid. Una representación con un estado interno distinto podría pasar. Protegen la creación, la semántica de los límites y el comportamiento seguro del valor cero.

Cuándo los setters sí representan una conversación real#

Los setters no son incorrectos por definición. Un editor de intervalos puede necesitar estados parciales mientras una persona escribe. En ese caso, el valor que se edita no es todavía un Interval; es un borrador con otro contrato:

text
IntervalDraft.SetStart
IntervalDraft.SetEnd
IntervalDraft.Build → Interval | InvalidInterval

Nombrar el borrador evita que una representación incompleta circule como si ya cumpliera la invariante.

El fundamento de autonomía pide que un valor conserve su validez sin que cada consumidor tenga que terminar de construirlo o repararlo.

Un constructor protege el contrato cuando entrega un valor completo o un fallo; su nombre por sí solo no vuelve imposible el estado inválido.