Guía práctica
El booleano que esconde cuatro resultados distintos
Reserve bool parece simple hasta que el consumidor necesita distinguir una reserva nueva, una repetición, falta de stock y un rechazo.
Un módulo de inventario ofrece esta operación:
func (i *Inventory) Reserve(
ctx context.Context,
request Request,
) boolEl handler la utiliza así:
if !inventory.Reserve(ctx, request) {
http.Error(w, "could not reserve", http.StatusConflict)
return
}
w.WriteHeader(http.StatusCreated)La firma es pequeña. La pregunta que introduce es grande: ¿qué significa false?
Al investigar aparecen cuatro situaciones:
- se creó una reserva;
- la misma petición ya había creado esa reserva;
- no queda stock;
- la cuenta está bloqueada y la política rechaza la reserva.
El booleano obliga a agruparlas en dos valores. Cualquier agrupación pierde una diferencia.
true tampoco significa una sola cosa#
Podríamos decidir que true significa «al terminar existe una reserva». Así, tanto la primera ejecución como el reintento devuelven true.
Es útil para un consumidor que solo necesita continuar. No basta para todos:
- el endpoint puede devolver
201 Createdla primera vez y200 OKen un reintento; - métricas de negocio no deben contar la repetición como otra reserva;
- un flujo puede emitir un evento solo cuando se crea la reserva;
- soporte necesita explicar por qué no apareció una reserva nueva.
false mezcla diferencias todavía mayores. Falta de stock puede invitar a elegir otro producto. Una cuenta bloqueada necesita un mensaje y una acción distintos. Responder 409 a todo obliga al consumidor final a adivinar.
La simplicidad de la firma se ha pagado trasladando significado fuera del módulo.
No todos los resultados son errores técnicos#
Una primera reacción consiste en mantener el booleano y añadir error:
func (i *Inventory) Reserve(
ctx context.Context,
request Request,
) (bool, error)Pero el par todavía permite combinaciones ambiguas:
true, nil
false, nil
false, ErrOutOfStock
false, ErrAccountBlocked
true, ErrSomething¿Cuál representa una petición repetida? ¿Es falta de stock un fallo del módulo o un resultado esperado de la conversación? ¿Qué debe hacer el consumidor con true y un error?
Un error es útil cuando el flujo idiomático de Go coincide con la semántica. No sustituye la tarea de nombrar resultados.
Diseña el conjunto de respuestas desde el consumidor#
Podemos representar los cuatro resultados observables como un conjunto cerrado:
type ReserveResult uint8
const (
Reserved ReserveResult = iota
AlreadyReserved
OutOfStock
Rejected
)
func (i *Inventory) Reserve(
ctx context.Context,
request Request,
) (ReserveResult, error)El error queda para fallos que impiden conocer el resultado: almacenamiento no disponible, cancelación del contexto o datos corruptos. ReserveResult contiene situaciones esperadas ante las que el consumidor puede actuar.
Ahora el handler puede traducir sin inventar significado:
result, err := inventory.Reserve(r.Context(), request)
if err != nil {
return writeInternalError(w, err)
}
switch result {
case inventory.Reserved:
w.WriteHeader(http.StatusCreated)
case inventory.AlreadyReserved:
w.WriteHeader(http.StatusOK)
case inventory.OutOfStock:
writeProblem(w, http.StatusConflict, "out_of_stock")
case inventory.Rejected:
writeProblem(w, http.StatusForbidden, "reservation_rejected")
default:
return fmt.Errorf("unknown reserve result: %d", result)
}HTTP no decide las cuatro posibilidades. Solo traduce el vocabulario del módulo a un transporte concreto.
El resultado nombrado mejora el test#
Con un booleano, este test no explica qué alternativa rechaza:
if inventory.Reserve(ctx, request) {
t.Fatal("expected reservation to fail")
}«Fallar» puede significar falta de stock, una política, un error técnico o una repetición interpretada como fallo. El caso no protege ninguna de esas decisiones de forma individual.
Con resultados nombrados, cada caso declara una promesa:
func TestRepeatedRequestReturnsAlreadyReserved(t *testing.T) {
inventory := newInventoryWithStock(1)
request := Request{
ID: "request-42",
Account: "account-7",
Product: "product-3",
Quantity: 1,
}
first, err := inventory.Reserve(context.Background(), request)
if err != nil || first != Reserved {
t.Fatalf("first reserve: result=%v err=%v", first, err)
}
repeated, err := inventory.Reserve(context.Background(), request)
if err != nil || repeated != AlreadyReserved {
t.Fatalf("repeat: result=%v err=%v", repeated, err)
}
if got := inventory.Available("product-3"); got != 0 {
t.Fatalf("expected stock 0, got %d", got)
}
}El test distingue repetición de nueva reserva y comprueba que el stock se descuenta una sola vez. Una implementación que devuelve Reserved otra vez o reduce el stock por debajo de cero deja de pasar.
Otros casos pueden separar OutOfStock de Rejected sin inspeccionar la regla o la estructura que produjo cada resultado.
No conviertas cada booleano en un enum#
Un booleano es adecuado cuando representa una proposición completa y ambos valores se entienden en la llamada:
inventory.Contains(productID)
reservation.IsExpired(now)true y false contestan directamente a una pregunta. No esconden un tercer estado que cambie la conducta.
Tampoco hace falta publicar diferencias que ningún consumidor legítimo necesita. Si dos causas producen exactamente la misma respuesta y el módulo no promete conservarlas, separarlas añade vocabulario sin capacidad.
La señal para abandonar el booleano no es el número de líneas internas. Es la existencia de más de dos resultados observables que importan fuera.
Los nombres deben seguir siendo ciertos#
Rejected todavía puede ser demasiado amplio si el consumidor debe distinguir cuenta bloqueada, límite superado y producto restringido. O puede ser el nivel correcto si todas esas políticas exigen la misma conducta y sus detalles no deben salir del módulo.
La pregunta no es «¿cuántos estados conoce la implementación?», sino:
¿Qué diferencias necesita comprender el consumidor para continuar correctamente?
Los nombres publicados comprometen compatibilidad. Conviene elegirlos desde esa conversación, no copiar todos los motivos internos.
El fundamento de vocabulario propone que cada diferencia pública tenga un nombre que permita comprender la conversación sin abrir la implementación.
Un booleano simplifica una API cuando responde una pregunta binaria; la empobrece cuando obliga al consumidor a reconstruir el resultado que borró.