API-DD

Practical guide

The constructor that allows an invalid value to exist

Comparing setters plus Validate with atomic Interval creation shows how to protect invariants and define the meaning of Go's zero value.

By Lautaro Mei

We want to represent an interval whose start is less than its end. The first version allows you to build it step by step:

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
}

The expected use requires four coordinated decisions:

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

Among those calls there are several observable values: 0..0, 2..0 and, if setters are swapped, 0..8. Neither necessarily represents the final intention. They can still be passed to another function, saved, or used before validating.

The empty construction did not create an interval. It created unfinished work that every consumer must know how to complete.

Validate does not protect an invariant if it is optional#

The method detects a problem when someone remembers to call it. It does not prevent another method from operating before:

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

This code does not visibly fail with Interval{Start: 8, End: 2}. It just returns false for everything. The invalid value behaves as an empty interval and hides the error that caused it.

The opposite can also happen: each operation calls Validate to defend itself.

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
}

Now all operations repeat a construction concern. Each consumer must manage an error that does not depend on the point consulted, but rather on the fact that the value was never complete.

Express intent in a single call#

The API can receive the two necessary parts and check the relationship before delivering the value:

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
}

Private fields prevent later assignments from breaking the relationship. Creation has two clear results:

text
NewInterval(2, 8) → valid interval
NewInterval(8, 2) → ErrInvalidInterval

The consumer does not receive a half-assembled object. It receives a usable value or a failure.

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
}

This is not the only possible representation. The valid field makes explicit a decision that in Go we cannot avoid: what Interval{} means.

Publishing NewInterval does not remove the zero value#

In Go every concrete type has a zero value. Even though the fields are private and the documentation says to use the constructor, this always compiles:

go
var interval Interval

That is why "force callers to use the constructor" does not completely describe the contract. You still have to decide what the zero value means.

There are three common policies:

In this example we choose the third. Contains returns false and Bounds returns ok == false. Successful creation remains the only path to valid limits.

Another API could define an empty interval as a legitimate concept and make the zero value useful. The important thing is not to copy this policy. It is to define one that consumers can observe without guessing.

A pointer also does not solve the entire decision#

Returning *Interval allows nil to be used as absence:

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

It may be appropriate if identity, size, or absence justify the pointer. It does not prevent invalid values within the package or explain what methods do with a nil receiver. It also does not prevent someone from declaring var interval Interval while the type remains exported.

Choosing value or pointer is a different decision than protecting the invariant.

The test compares atomic creation with partial assembly#

The first cases should require both results of the 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)
    }
}

And a separate case preserves the zero value policy:

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")
    }
}

These tests do not require the valid field. A representation with a different internal state could pass. They protect the creation, semantics of boundaries, and safe behavior of the zero value.

When setters do represent a real conversation#

Setters are not inherently wrong. An interval editor may need partial states while a person is typing. In that case, the value being edited is not yet an Interval; it is a draft with a different contract:

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

Naming the draft prevents an incomplete representation from circulating as if it already satisfied the invariant.

The autonomy foundation calls for a value to remain valid without each consumer having to finish building or repairing it.

A constructor protects the contract when it returns a complete value or a failure; its name alone does not make invalid states impossible.