The Boolean Flag Graveyard: Modelling Domains with State Machines
If your entity has four boolean flags, you probably have a state machine in disguise — and it's already bleeding bugs.
At Delivery Hero I inherited an inventory system with a stock_item table that looked like this:
CREATE TABLE stock_item (
id BIGSERIAL PRIMARY KEY,
is_available BOOLEAN,
is_reserved BOOLEAN,
is_picked BOOLEAN,
is_shipped BOOLEAN,
is_returned BOOLEAN,
is_restocked BOOLEAN,
...
);Six booleans. Which means 2^6 = 64 theoretical combinations — and only about 6 of them are valid. The rest are bugs waiting to happen. And yes, we had all of them. "Reserved but not available." "Shipped but not picked." "Returned but already restocked." Every one of these had production incidents attached.
This is what I call the boolean flag graveyard. It's the single most common domain-modelling mistake I see.
The real shape is a state machine
A stock_item doesn't have six independent flags. It has one state that moves through a small number of transitions:
AVAILABLE ─reserve─▶ RESERVED ─pick─▶ PICKED ─ship─▶ SHIPPED
▲ │ │ │
│ │ │ │
└──── release ───────┘ │ ▼
│ RETURNED
│ │
└── restock ───┘
There are only ~6 valid states and ~7 valid transitions. Everything else is impossible by construction.
Model the states, not the flags
CREATE TYPE stock_state AS ENUM (
'available', 'reserved', 'picked', 'shipped', 'returned'
);
CREATE TABLE stock_item (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
state stock_state NOT NULL DEFAULT 'available',
state_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
...
);Now illegal states can't exist. No more "shipped but not picked" because you can only be in one state at a time.
Enforce transitions, not just states
States alone aren't enough. You also need to guarantee that transitions are legal. Two complementary layers:
In the application
const transitions: Record<State, State[]> = {
available: ["reserved"],
reserved: ["available", "picked"],
picked: ["shipped"],
shipped: ["returned"],
returned: ["available"],
};
function transition(item: StockItem, to: State): StockItem {
const allowed = transitions[item.state];
if (!allowed.includes(to)) {
throw new IllegalTransition(item.state, to);
}
return { ...item, state: to, stateUpdatedAt: new Date() };
}Clean, testable, and the type system helps.
In the database
Application code isn't the only writer. Migrations, imports, debugging scripts — anything can touch the table. Belt and braces:
CREATE TABLE stock_transition (
id BIGSERIAL PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES stock_item(id),
from_state stock_state NOT NULL,
to_state stock_state NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor TEXT NOT NULL,
reason TEXT,
CONSTRAINT valid_transition CHECK (
(from_state = 'available' AND to_state = 'reserved') OR
(from_state = 'reserved' AND to_state IN ('available', 'picked')) OR
(from_state = 'picked' AND to_state = 'shipped') OR
(from_state = 'shipped' AND to_state = 'returned') OR
(from_state = 'returned' AND to_state = 'available')
)
);Every state change writes a row. The table becomes an audit log for free, and the CHECK constraint guarantees no one bypasses the rules.
Event-sourcing the transitions
The stock_transition table is effectively an event log. I've used this to:
- Debug bugs. "Why is this item in RESERVED?" → read the last 5 transitions.
- Reconcile with vendors. Diff our event log against their records.
- Replay into new consumers. When a new analytics system came online, we replayed historical events into it.
The cost: one extra write per state change. At warehouse scale this was a rounding error.
The result
At Delivery Hero, after migrating to this model:
- Ghost-stock bugs dropped to zero in the first quarter.
- Warehouse ops moved from nightly reconciliation to exception-only triage.
- New engineers onboarded faster because the model was legible.
The big shift isn't technical. It's conceptual: your domain already has states. Either you model them explicitly, or they hide in boolean flags and bite you later.
The rule of thumb
If you see three or more boolean flags on an entity — is_active, is_approved, is_archived, is_deleted — there is almost certainly a state machine hiding in there. Find it. Name the states. Make illegal combinations unrepresentable.
You'll write less code and ship fewer bugs.
Have a domain that's drowning in edge cases? Let's talk.
Have a system that needs to scale — or stop breaking?
I work with a small number of teams each month on architecture reviews, scaling, and hands-on backend engineering. If that sounds like you, let's talk.