Practical guide
The boolean that hides four different results
Reserve bool looks simple until the consumer needs to distinguish a new reservation, a repeated request, out of stock, and a rejection.
An inventory module offers this operation:
func (i *Inventory) Reserve(
ctx context.Context,
request Request,
) boolThe handler uses it like this:
if !inventory.Reserve(ctx, request) {
http.Error(w, "could not reserve", http.StatusConflict)
return
}
w.WriteHeader(http.StatusCreated)The signature is small. The question it introduces is a big one: what does false mean?
When investigating, four situations appear:
- a reservation was created;
- the same request had already created that reservation;
- there is no stock left;
- the account is blocked and the policy rejects the reservation.
The boolean forces them to be grouped into two values. Any grouping loses a difference.
true doesn't mean a single thing either#
We could decide that true means "a reservation exists when the call returns." Both the first execution and a retry would then return true.
It is useful for a consumer who just needs to continue. Not enough for everyone:
- the endpoint may return
201 Createdthe first time and200 OKon a retry; - business metrics should not count repetition as another reservation;
- a flow can emit an event only when the reservation is created;
- support needs to explain why a new reservation did not appear.
false mixes even greater differences. Lack of stock may invite you to choose another product. A locked account requires a different message and action. Replying 409 to everything forces the end consumer to guess.
The simplicity of the signature has been paid for by transferring meaning outside of the module.
Not all results are technical errors#
A first reaction is to keep the boolean and add error:
func (i *Inventory) Reserve(
ctx context.Context,
request Request,
) (bool, error)But the pair still allows for ambiguous combinations:
true, nil
false, nil
false, ErrOutOfStock
false, ErrAccountBlocked
true, ErrSomethingWhich represents a repeated request? Is out of stock a module failure or an expected result of the conversation? What should the consumer do with true and an error?
An error is useful when Go's conventional control flow matches the semantics. It does not replace the work of naming results.
Design the response set from the consumer#
We can represent the four observable outcomes as a closed set:
type ReserveResult uint8
const (
Reserved ReserveResult = iota
AlreadyReserved
OutOfStock
Rejected
)
func (i *Inventory) Reserve(
ctx context.Context,
request Request,
) (ReserveResult, error)error is for failures that prevent the result from being known: unavailable storage, context cancellation or corrupted data. ReserveResult contains expected situations that the consumer can act upon.
Now the handler can translate without inventing meaning:
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 does not decide the four possibilities. It only translates the vocabulary of the module to a specific transport.
The named result improves the test#
With a boolean, this test does not explain which alternative it rejects:
if inventory.Reserve(ctx, request) {
t.Fatal("expected reservation to fail")
}"Fail" can mean out of stock, a policy, a technical error, or a repeat interpreted as a failure. The case does not protect any of those decisions individually.
With named results, each case declares a promise:
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)
}
}The test distinguishes a repeated request from a new reservation and verifies that stock is decremented only once. An implementation that returns Reserved again or reduces stock below zero fails.
Other cases may separate OutOfStock from Rejected without inspecting the rule or structure that produced each result.
Don't convert every boolean to an enum#
A boolean is appropriate when it represents a complete statement and both values are understood in the call:
inventory.Contains(productID)
reservation.IsExpired(now)true and false directly answer a question. They do not hide a third state that changes behavior.
There is also no need to publish differences that no legitimate consumer needs. If two causes produce exactly the same response and the module does not promise to preserve them, separating them adds vocabulary without capacity.
The signal to abandon the boolean is not the number of internal lines. It is the existence of more than two observable results that matter outside.
Names must remain true#
Rejected may still be too broad if the consumer must distinguish account locked, limit exceeded, and product restricted. Or it may be the correct level if all those policies require the same behavior and their details should not leave the module.
The question is not "how many states does the implementation know?", but:
What differences does the consumer need to understand to proceed correctly?
Published names compromise compatibility. It is advisable to choose them from that conversation, not copy all the internal reasons.
The vocabulary foundation proposes that each public difference have a name that allows the conversation to be understood without opening the implementation.
A boolean simplifies an API when it answers a binary question; it impoverishes one when consumers must reconstruct the result it erased.