The Lost Art of Constraint-Based Thinking

There's a peculiar amnesia in our profession. We learned about preconditions, postconditions, and invariants, perhaps in a university course on formal methods, or from a book on defensive programming, or during a discussion about Design by Contract. We nodded along, maybe even implemented some assertions in our code. And then we stopped.

We stopped developing our ability to think in constraints. We reduced an entire dimension of software design to three bullet points and a few assert statements sprinkled at method boundaries. This is like learning three chords on a guitar and declaring you've mastered music.

The tragedy isn't that preconditions, postconditions, and invariants are unimportant - they're foundational. The tragedy is that we've mistaken the foundation for the entire edifice.

The Comfortable Three

Let's acknowledge what we do know. Very few developers, even seasoned ones can accurately and completely identify these - especially for non-trivial cases.

Preconditions: What must be true before this operation begins? Is the file open? Is the pointer non-null? Is the account balance positive?

Postconditions: What must be true after this operation completes? Did the file get written? Is the list now sorted? Did the balance decrease by exactly the withdrawal amount?

Invariants: What must remain true throughout the lifetime of this object or structure? Is the binary search tree still ordered? Does the size field still match the actual count? Is the cache still consistent with the backing store?

Languages like Dafny, Eiffel, and tools like TLA+ have shown us that when we formalize these constraints, we can mathematically prove program correctness. - this is the closest we've come to truly knowing that our code is right.

But here's the uncomfortable question: if these constraints are so powerful, and if you understand them so well, why is your production system still full of bugs?

The Illusion of Completeness

The problem is that we've confused understanding these three types of constraints with thinking in constraints. We've treated them as a checklist item rather than as an entry point into a deeper way of reasoning about software.

Consider this: when you add a precondition check at the start of a method, you're performing runtime enforcement of a constraint. You're catching violations after they've already occurred in program logic, at the moment they're about to cause damage. It's certainly better than letting the violation propagate. But it's the weakest form of constraint enforcement, the last line of defense when all else has failed.

Developers who stop at preconditions, postconditions, and invariants have learned to fight fires. But they haven't learned to think about what makes fires impossible in the first place.

The Broader Landscape of Constraints

Here's what we're missing: constraints aren't just about correctness checks at function boundaries. Constraints are embedded in every design decision we make, whether we're conscious of them or not.

A type system doesn't enable you to write programs—it prevents you from writing certain classes of incorrect programs. When Haskell's type system forces you to handle the Nothing case of a Maybe, it's not giving you power. It's taking away your ability to ignore the possibility of absence. When Rust's borrow checker rejects your code, it's not being helpful, it's making data races impossible to compile.

An interface doesn't give you capabilities. It restricts what you can do to a defined set of operations. When you program against java.util.List instead of ArrayList, you're not gaining flexibility. You're constraining yourself to only the operations that work on any list, preventing your code from depending on ArrayList-specific behavior. This constraint is what makes your code flexible.

An architecture doesn't create freedom. It constrains how components may interact. When you adopt hexagonal architecture, you're not empowering your domain layer. You're preventing it from depending on infrastructure. When you introduce an event bus, you're not enabling communication, you're restricting it to asynchronous, decoupled messages.

This is the insight that separates journeyman programmers from architects: good design is the art of choosing the right constraints.

The Hierarchy of Constraint Strength

Not all constraints are created equal. They exist in a hierarchy of strength, and understanding this hierarchy is essential to building reliable systems:

1. Impossible

The constraint is enforced by the structure of reality itself - by the type system, the language semantics, or the laws of physics. The incorrect state cannot be represented, even in principle.

Example: In a language with algebraic data types, you can make illegal states unrepresentable. Consider modeling a traffic light:

// Bad: Can set multiple to true
type TrafficLightBad = {
    red: boolean;
    yellow: boolean;
    green: boolean;
};

// Good: Can only be one
type TrafficLight = 
    | { kind: 'red' }
    | { kind: 'green' }
    | { kind: 'yellow' };

// This is impossible:
const light: TrafficLight = { kind: 'red', kind: 'green' };  // Syntax error

You cannot construct a traffic light that is both red and green simultaneously. The compiler doesn't need to check this. Your tests don't need to verify it. It is structurally impossible.

We can take this even further by not allowing illegal state transitions :

// Bad: All states possible, transitions checked at runtime
class TrafficLightBad {
    private state: 'red' | 'green' | 'yellow';
    
    constructor() {
        this.state = 'red';
    }
    
    next() {
        // Runtime checks - easy to forget, easy to get wrong
        if (this.state === 'red') {
            this.state = 'green';
        } else if (this.state === 'green') {
            this.state = 'yellow';
        } else if (this.state === 'yellow') {
            this.state = 'red';
        }
    }
    
    // What if someone adds this method later?
    skipToYellow() {
        // Oops! Invalid transition from red->yellow
        this.state = 'yellow';  // No compiler error!
    }
}

// Good: Invalid states AND transitions are impossible
type RedLight = {
    state: 'red';
    next: () => GreenLight;  // Red can ONLY go to Green
};

type GreenLight = {
    state: 'green';
    next: () => YellowLight;  // Green can ONLY go to Yellow
};

type YellowLight = {
    state: 'yellow';
    next: () => RedLight;  // Yellow can ONLY go to Red
};

type TrafficLight = RedLight | GreenLight | YellowLight;

function createRed(): RedLight {
    return {
        state: 'red',
        next: () => createGreen()
    };
}

function createGreen(): GreenLight {
    return {
        state: 'green',
        next: () => createYellow()
    };
}

function createYellow(): YellowLight {
    return {
        state: 'yellow',
        next: () => createRed()
    };
}

// In the bad version: you can add invalid transitions anytime
// In the good version: the type system prevents invalid transitions from existing

Example: In a purely functional language, functions literally cannot have side effects. It's not that side effects are discouraged or checked—they cannot occur. The constraint is enforced by the mathematical foundations of the language itself.

2. Compile-Time Error

The system rejects the violation before the program ever runs. The error is caught during static analysis, type checking, or compilation. No runtime code is needed to enforce the constraint.

Example: Rust's borrow checker preventing data races:

let mut data = vec![1, 2, 3];
let reference = &data[0];
data.push(4);  // Compile error: cannot mutate while borrowed
println!("{}", reference);

This code will never compile. You cannot accidentally ship a data race to production. The constraint is enforced by static analysis.

3. Runtime Enforcement

The system checks the constraint during execution and rejects violations when they occur. This is where preconditions, postconditions, and most validation logic lives.

Example: Defensive checks at function boundaries:

def withdraw(account: Account, amount: float) -> None:
    if amount <= 0:
        raise ValueError("Amount must be positive")
    if account.balance < amount:
        raise InsufficientFundsError()
    account.balance -= amount

These checks are necessary because we couldn't push the constraints higher. We can't make negative amounts unrepresentable in Python's type system. We can't make the comparison at compile time because the balance is only known at runtime.

Example: Database constraints:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    age INTEGER CHECK (age >= 0 AND age < 200)
);

The constraint is enforced, but only when you try to insert or update data.

4. Discouraged

The system makes the undesired behavior awkward or verbose, or requires explicit acknowledgment that you're doing something unusual. The constraint is communicated through friction.

Example: Java's checked exceptions forcing you to acknowledge potential failures:

public void readFile(String path) throws IOException {
    // Must either handle or declare the exception
    Files.readString(Paths.get(path));
}

It's not impossible to ignore the exception, but you have to be explicit about it. The default path is to acknowledge the constraint.

Example: Rust's unsafe keyword:

let mut data = [1, 2, 3];
let ptr = data.as_mut_ptr();

unsafe {
    // Only here can we violate memory safety guarantees
    *ptr.offset(10) = 42;
}

Memory unsafety isn't impossible, but it's visibly marked. You have to opt into the risk explicitly.

Example: Go's explicit error handling:

file, err := os.Open("data.txt")
if err != nil {
    return err
}
// Yes, this is verbose. That's the point.

The language makes it more work to ignore errors than to handle them. The constraint is enforced by social convention and tooling, not by the compiler.

5. Possible

The system places no barrier. The constraint exists only in documentation, code reviews, or the developer's memory. This is where most bugs live.

Example: Comments pleading for correct usage:

// IMPORTANT: Must call init() before using this class
// TODO: Don't forget to close the connection
// NOTE: This assumes the array is sorted

These aren't constraints—they're wishes.

The Strategic Question

The fundamental question of software design is: How high in this hierarchy can I push each constraint?

Every time you enforce something at runtime that could have been caught at compile time, you've created a liability. Every time you rely on documentation instead of structure, you've created a bug waiting to happen.

Consider a function that expects a non-empty list:

Level 5 (Possible): Document it in a comment and hope.

def get_first(items):
    """Returns first item. WARNING: List must not be empty!"""
    return items[0]

Level 3 (Runtime): Check at runtime and fail loudly.

def get_first(items):
    if not items:
        raise ValueError("List must not be empty")
    return items[0]

Level 2 (Compile-Time): Use the type system to require proof.

type NonEmptyArray<T> = [T, ...T[]];

function getFirst<T>(items: NonEmptyArray<T>): T {
    return items[0];
}

Level 1 (Impossible): Make the empty list unrepresentable in this context.

-- NonEmpty is a type that cannot be empty by construction
import Data.List.NonEmpty (NonEmpty((:|)))

getFirst :: NonEmpty a -> a
getFirst (x :| _) = x

Each level up the hierarchy makes your software more reliable and easier to maintain. Each level down increases the cognitive load on developers and the surface area for bugs.

Why We Stop at the Three

So why do we stop developing our constraint-thinking muscles after learning about preconditions, postconditions, and invariants?

Familiarity: These three fit comfortably into our existing procedural thinking. They map to places in our code where we're already doing things, at function entries, exits, and in class methods. They don't require us to rethink how we structure programs.

Tool support: Most languages have built-in support for runtime assertions. Few have sophisticated type systems or static analysis tools that let us push constraints higher.

Immediate feedback: Runtime checks fail obviously and immediately. Type system constraints often feel like they're fighting us, especially when we're trying to do something quick and dirty.

Legacy: We're working in codebases and with patterns that assume runtime enforcement is the primary tool. Changing this requires architectural rethinking, not just local refactoring.

Education: We're taught to write code that works, not code that cannot be written incorrectly. The former is testable in a few minutes; the latter requires deep design thinking.

But these are reasons, not excuses. And they're becoming less valid as our systems become more complex and our cost of failure increases.

Constraint-Based Design in Practice

Let's look at how constraint-based thinking changes the way we approach common problems.

Example 1: User Roles and Permissions

The conventional approach (runtime enforcement):

class Document {
    void publish() {
        if (!currentUser.hasRole("EDITOR")) {
            throw new UnauthorizedException();
        }
        this.status = Status.PUBLISHED;
    }
}

Every method that does something privileged needs to check permissions. These checks are scattered throughout the codebase. Forgetting one creates a security vulnerability.

The constraint-based approach (compile-time enforcement):

// Actions are represented as objects with type-level permissions
interface Action<Permission> {
    void execute();
}

class PublishDocument implements Action<EditorPermission> {
    void execute() {
        this.document.status = Status.PUBLISHED;
    }
}

// Executor requires proof of permission at compile time
class ActionExecutor {
    <P extends Permission> void execute(Action<P> action, P proof) {
        action.execute();
    }
}

Now you cannot execute a privileged action without providing proof of permission. The type system enforces the constraint. You can't forget the check because there's no way to call the code without it.

Example 2: State Machines

The conventional approach (runtime enforcement):

class Order:
    def __init__(self):
        self.state = "PENDING"
    
    def ship(self):
        if self.state != "PAID":
            raise InvalidStateError("Can only ship paid orders")
        self.state = "SHIPPED"
    
    def cancel(self):
        if self.state == "SHIPPED":
            raise InvalidStateError("Cannot cancel shipped orders")
        self.state = "CANCELLED"

The state transitions are validated at runtime. Every method must remember which transitions are valid. The invalid states are possible to represent—we just reject them when they occur.

The constraint-based approach (impossible states):

// Each state is a distinct type
struct Pending { order_id: String }
struct Paid { order_id: String, payment_id: String }
struct Shipped { order_id: String, payment_id: String, tracking: String }
struct Cancelled { order_id: String, reason: String }

impl Pending {
    fn pay(self, payment_id: String) -> Paid {
        Paid { order_id: self.order_id, payment_id }
    }
    
    fn cancel(self, reason: String) -> Cancelled {
        Cancelled { order_id: self.order_id, reason }
    }
}

impl Paid {
    fn ship(self, tracking: String) -> Shipped {
        Shipped { 
            order_id: self.order_id, 
            payment_id: self.payment_id, 
            tracking 
        }
    }
}

// Shipped orders have no transition methods - they're final

Now it's impossible to cancel a shipped order. The type Shipped doesn't have a cancel method. It's impossible to ship an unpaid order—you can't get a Paid instance without calling pay() first. The constraints are enforced by structure, not by checks.

Example 3: API Design

The conventional approach (discouraged):

class DatabaseConnection {
    constructor(connectionString) {
        this.connectionString = connectionString;
        this.connected = false;
    }
    
    async connect() {
        // ... connection logic
        this.connected = true;
    }
    
    async query(sql) {
        if (!this.connected) {
            throw new Error("Must call connect() first!");
        }
        // ... query logic
    }
}

We rely on developers remembering to call connect() before query(). The constraint exists but is weakly enforced.

The constraint-based approach (impossible):

class DatabaseConnection {
    private constructor(private handle: ConnectionHandle) {}
    
    static async connect(connectionString: string): Promise<DatabaseConnection> {
        const handle = await establishConnection(connectionString);
        return new DatabaseConnection(handle);
    }
    
    async query(sql: string) {
        // No check needed - we can't have a DatabaseConnection without a valid handle
        return executeQuery(this.handle, sql);
    }
}

The constructor is private. The only way to get a DatabaseConnection is through the static connect() method, which ensures we have a valid handle. It's structurally impossible to query a disconnected database.

The Subtle Power of Constraints

The magic of pushing constraints up the hierarchy is that they disappear from your conscious attention. You stop thinking about them.

When you enforce a constraint at runtime, you must remember it every time you write code that touches that area. You must write tests for it. You must review for it. You must debug it when it fails.

When you enforce a constraint in the type system or architecture, it becomes invisible. It's just how things are. You can't write the wrong code because the wrong code doesn't compile. Your attention is freed to think about the actual problem domain.

The Real Goal

The goal isn't to eliminate all runtime checks. It isn't to make every program fully formally verified. It isn't to only use languages with dependent type systems.

The goal is to develop a mindset that sees constraints not as obstacles but as tools. To recognize that limitations can be liberating. To understand that the art of software design is choosing what to make impossible.

Preconditions, postconditions, and invariants are important. They're a crucial part of the toolkit. But they're the beginning of constraint-based thinking, not the end.

The developers who master this art don't just write code that works. They write code that cannot be written incorrectly. They build systems where bugs aren't just caught—they're prevented. They create architectures where the right thing is the easy thing, and the wrong thing is impossible.

This is the next level of engineering maturity: moving from testing correctness to designing for correctness, from catching errors to preventing them, from defensive programming to impossible-by-construction programming.

The question isn't whether you know what a precondition is. The question is: how many levels up can you push it?


The best code is code that cannot be misused. The best constraints are the ones you never have to think about. And the best programmers are those who realize that their job is as much about limiting possibilities as it is about creating them.

Read more