Notes on building systems that work and other things... maybe?

Encode the invariant, don't document it

· 4 min

Documentation is advisory. A type error is not.

That is the whole argument, but it took me a long time to actually believe it. For years I wrote down the rules. "Always validate the tenant id." "Never store money in a float." "Use the safe subtraction helper." I put them in READMEs, in code review comments, in onboarding docs. And people broke them anyway, not because they were careless but because nobody rereads a README before writing a line of code. A rule you have to remember is a rule you will eventually forget.

So the question I started asking is: can the compiler remember it for me?

Money is never a float

Here is the rule everyone agrees with and half the codebase violates. The fix is not a doc. It is a type the compiler accepts and a float it rejects.

// illustrative
pub struct Money(i64); // minor units, e.g. cents

impl Money {
    pub fn from_minor(units: i64) -> Self {
        Money(units)
    }
}

fn charge(amount: Money) { /* ... */ }

Now the mistake stops compiling.

// illustrative
charge(19.99);            // error: expected Money, found f64
charge(Money::from_minor(1999)); // fine

There is no float anywhere near the API. You cannot accidentally pass 19.99 because the function does not accept it. The doc that said "money is never a float" is now redundant, which is the point. The best documentation is the one you can delete because the code says it instead.

Amounts can't underflow

The next rule is subtler because the dangerous thing looks completely normal. Subtraction is just -. Nobody flags - in review. But a balance that goes negative because two debits raced is a real incident, and the operator that caused it is the most boring character on the keyboard.

The usual advice is "remember to check before you subtract." That advice is worth nothing. So hide the panic-prone operator behind a safe one and stop exposing the raw thing.

// illustrative
impl Money {
    pub fn checked_sub(self, other: Money) -> Option<Money> {
        self.0.checked_sub(other.0).map(Money)
    }
}

If Money does not implement Sub, then balance - amount does not compile. There is no minus operator to reach for. The only way to subtract is the one that hands you back an Option, and now you have to deal with the empty case before you can continue.

// illustrative
let remaining = balance
    .checked_sub(amount)
    .ok_or(Error::InsufficientFunds)?;

The underflow did not become impossible because someone was disciplined. It became impossible because the unsafe path was never built. You cannot forget to check when there is nothing else you are allowed to do.

Every request carries a tenant id

The first two are about types. This one is about construction, which is the same idea pointed at a different layer. In a multi-tenant system the cardinal sin is a query that forgets which tenant it belongs to, because that is how one customer ends up reading another customer's data.

You can write "every request must carry a tenant id" at the top of every handler file. People will copy a handler that happens to be missing it and the rule evaporates.

Instead, make a bare request impossible to construct. The shared client requires the id, and the only public constructor takes it.

// illustrative
type Request struct {
    tenantID string // unexported, no way to build a zero value from outside
    body     []byte
}

func NewRequest(tenantID string, body []byte) (*Request, error) {
    if tenantID == "" {
        return nil, errors.New("tenant id required")
    }
    return &Request{tenantID: tenantID, body: body}, nil
}

There is no Request{} literal you can write from another package, because the field is unexported. The only door into the type checks the invariant on the way in. Same move on the server side: an interceptor in the shared library reads the id off every inbound call and rejects the ones without it, so a handler cannot run against an un-scoped request even if someone wrote one.

// illustrative
func TenantInterceptor(next Handler) Handler {
    return func(ctx context.Context, r *Request) error {
        if r.tenantID == "" {
            return status.Error(codes.InvalidArgument, "missing tenant id")
        }
        return next(WithTenant(ctx, r.tenantID), r)
    }
}

Now the rule lives in one place, runs on every request, and cannot be skipped by copying the wrong file. Forgetting is not an available outcome.

The pattern under all three

Each example replaces a sentence with a structure. "Money is never a float" becomes a type that rejects floats. "Amounts can't underflow" becomes a missing operator. "Every request carries a tenant id" becomes a constructor and an interceptor that refuse to produce the bad state.

The common thread is that the wrong thing has to stop being reachable. Not discouraged, not flagged in review, not written down somewhere. Unreachable. If the only way to express the operation is the correct way, then correctness is not a thing anyone has to remember.

This costs more up front. A newtype is more work than a comment. A constructor is more work than a struct literal. An interceptor is more work than a note in the wiki. You pay that cost once, when you build the gate. The doc looks cheaper because its cost is hidden: it shows up later, distributed across every person who skips it, in incidents nobody connects back to the paragraph they never read.

A rule that lives only in prose gets skipped the same way docs get skipped, because it is a doc. Put it where it cannot be skipped. A failing build does not care that you were in a hurry.