Modelling Real Business Workflows in SQL
Learn SQL in Action - Part 022
Modelling real business workflows in SQL: state machines, lifecycle tables, audit trails, approvals, escalation, temporal facts, events, and defensible case management data models.
Part 022 — Modelling Real Business Workflows in SQL
1. Why This Part Exists
Many SQL courses stop at orders, customers, and products.
Real enterprise systems are often about workflows:
- case intake,
- verification,
- assignment,
- review,
- escalation,
- approval,
- rejection,
- investigation,
- remediation,
- appeal,
- closure,
- reopening,
- audit,
- reporting,
- statutory deadlines.
These systems fail when workflow state is treated as a simple column:
status text NOT NULL
A status column is not enough to explain:
- how the entity got there,
- who moved it,
- whether the transition was valid,
- whether mandatory review happened,
- whether escalation was required,
- whether SLA was breached,
- what rule version was used,
- what the system believed at the time,
- what should happen next.
A workflow data model must support both operation and reconstruction.
This part teaches how to model workflow truth in SQL.
2. Kaufman Framing: The Sub-Skill We Are Training
The sub-skill is:
Given a real business lifecycle, design SQL tables that represent current state, valid transitions, historical events, assignments, approvals, deadlines, and audit evidence without losing operational performance.
You are training to:
- separate current state from historical transitions,
- encode state machines explicitly,
- decide which rules belong in constraints vs application/service logic,
- store approvals as facts, not booleans,
- model assignment and ownership over time,
- model escalation as event plus active condition,
- design deadline/SLA tables,
- support audit reconstruction,
- avoid contradictory lifecycle columns,
- support operational dashboards without corrupting source-of-truth tables.
The Kaufman-style drills are:
- Draw the lifecycle.
- List valid transitions.
- Identify actors and authorizations.
- Identify current-state queries.
- Identify reconstruction/audit queries.
- Identify deadlines and escalation rules.
- Decide source-of-truth vs projection.
- Write SQL invariants and assertions.
3. Mental Model: Workflow Has Three Truth Layers
A robust workflow model usually has at least three layers:
| Layer | Example | Purpose |
|---|---|---|
| Current state | cases.current_status | Fast reads and command guards |
| Transition history | case_status_transitions | Lifecycle reconstruction |
| Audit/evidence event | case_events, audit_log | Explain who/why/source/context |
A system can store all three separately, or combine some carefully. But the distinction must be clear.
4. Core Case Management Schema
We will use a case management domain throughout this part.
CREATE TABLE cases (
case_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_number text NOT NULL UNIQUE,
case_type text NOT NULL,
current_status text NOT NULL,
priority text NOT NULL,
created_by_user_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
CHECK (priority IN ('low', 'normal', 'high', 'critical')),
CHECK (closed_at IS NULL OR closed_at >= created_at)
);
This table is the current operational header.
It should not try to store everything.
5. State Machine as Data
A workflow lifecycle is a state machine.
5.1 State Catalog
CREATE TABLE workflow_states (
workflow_code text NOT NULL,
state_code text NOT NULL,
state_name text NOT NULL,
terminal boolean NOT NULL DEFAULT false,
active boolean NOT NULL DEFAULT true,
PRIMARY KEY (workflow_code, state_code)
);
5.2 Transition Catalog
CREATE TABLE workflow_transitions (
workflow_code text NOT NULL,
from_state text NOT NULL,
to_state text NOT NULL,
transition_code text NOT NULL,
requires_reason boolean NOT NULL DEFAULT false,
requires_approval boolean NOT NULL DEFAULT false,
active boolean NOT NULL DEFAULT true,
PRIMARY KEY (workflow_code, from_state, to_state),
UNIQUE (workflow_code, transition_code),
FOREIGN KEY (workflow_code, from_state)
REFERENCES workflow_states(workflow_code, state_code),
FOREIGN KEY (workflow_code, to_state)
REFERENCES workflow_states(workflow_code, state_code)
);
Now valid transitions are data, not scattered if statements.
5.3 Transition History
CREATE TABLE case_status_transitions (
transition_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
workflow_code text NOT NULL,
from_status text,
to_status text NOT NULL,
transition_code text NOT NULL,
changed_by_user_id bigint NOT NULL,
changed_at timestamptz NOT NULL DEFAULT now(),
reason_code text,
comment text,
request_id text,
FOREIGN KEY (workflow_code, to_status)
REFERENCES workflow_states(workflow_code, state_code),
FOREIGN KEY (workflow_code, transition_code)
REFERENCES workflow_transitions(workflow_code, transition_code)
);
The transition table tells the story.
The cases.current_status column tells the current operational fact.
6. Guarded State Transition
A state transition must be atomic.
Bad:
SELECT current_status
FROM cases
WHERE case_id = :case_id;
-- application checks status
UPDATE cases
SET current_status = :to_status
WHERE case_id = :case_id;
INSERT INTO case_status_transitions (...);
Between select and update, another transaction may change the case.
Better:
BEGIN;
UPDATE cases
SET current_status = :to_status,
updated_at = now(),
closed_at = CASE WHEN :to_status = 'closed' THEN now() ELSE closed_at END
WHERE case_id = :case_id
AND current_status = :from_status;
-- verify exactly one row updated in application or procedure
INSERT INTO case_status_transitions (
case_id,
workflow_code,
from_status,
to_status,
transition_code,
changed_by_user_id,
changed_at,
reason_code,
comment,
request_id
)
VALUES (
:case_id,
:workflow_code,
:from_status,
:to_status,
:transition_code,
:actor_user_id,
now(),
:reason_code,
:comment,
:request_id
);
COMMIT;
This pattern protects against lost workflow updates.
6.1 Validate Transition Existence
You can validate the transition using EXISTS:
UPDATE cases c
SET current_status = :to_status,
updated_at = now()
WHERE c.case_id = :case_id
AND c.current_status = :from_status
AND EXISTS (
SELECT 1
FROM workflow_transitions wt
WHERE wt.workflow_code = :workflow_code
AND wt.from_state = :from_status
AND wt.to_state = :to_status
AND wt.active = true
);
The database can reject invalid transitions before the history insert.
7. Idempotent Workflow Commands
Workflow systems often receive retries:
- user double-clicks,
- HTTP timeout,
- message redelivery,
- worker crash after commit,
- external integration retries.
Use command/request idempotency.
CREATE TABLE workflow_commands (
command_id text PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
command_type text NOT NULL,
requested_by_user_id bigint NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
result_status text NOT NULL CHECK (result_status IN ('started', 'completed', 'failed'))
);
Transition history can reference it:
ALTER TABLE case_status_transitions
ADD CONSTRAINT ux_case_transition_request UNIQUE (request_id);
When the same request_id appears again, the system can return the previous result instead of applying the transition twice.
8. Current State vs Event-Sourced State
There are two common patterns.
8.1 Current State + History
cases.current_status is canonical current state.
case_status_transitions records how it changed.
Pros:
- fast current reads,
- simpler command validation,
- easier indexing,
- familiar operational model.
Cons:
- must keep current state and transition history consistent,
- requires careful transaction boundaries,
- reconstruction may disagree if bugs occurred.
8.2 Event-Sourced State
case_events are canonical.
current state is derived.
Pros:
- strong history-first model,
- can rebuild projections,
- captures event intent.
Cons:
- harder ad hoc querying,
- harder invariant checks across event streams,
- projection lag,
- operational complexity,
- schema evolution of events.
8.3 Practical Recommendation
For many enterprise SQL systems:
Use current-state tables for operational correctness.
Use append-only history/event tables for audit and reconstruction.
Use projections for read-heavy dashboards.
This gives a balanced model.
9. Audit Trail: What Must Be Captured
An audit trail should answer:
- Who did it?
- What changed?
- From what value to what value?
- When did it happen?
- Through what channel?
- Under what authorization?
- Which request/command caused it?
- Which rule/version was applied?
- Was it automated or manual?
- What evidence supported the decision?
A minimal audit table:
CREATE TABLE audit_log (
audit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entity_type text NOT NULL,
entity_id text NOT NULL,
action_code text NOT NULL,
actor_user_id bigint,
actor_type text NOT NULL CHECK (actor_type IN ('user', 'system', 'integration')),
occurred_at timestamptz NOT NULL DEFAULT now(),
request_id text,
source_ip inet,
user_agent text,
before_data jsonb,
after_data jsonb,
metadata jsonb NOT NULL DEFAULT '{}'
);
This is generic and flexible, but it should not replace domain-specific transition tables.
Use both when needed:
- domain table for queryable lifecycle truth,
- audit table for broad forensic trail.
10. Domain Events vs Audit Log
They are not the same.
| Aspect | Domain event | Audit log |
|---|---|---|
| Meaning | Business-significant fact | Record of change/action |
| Consumer | Domain/integration/projection | Compliance/support/security |
| Structure | Typed and meaningful | Often generic |
| Example | CaseEscalated | UPDATE cases set current_status |
| Rebuild projections? | Usually yes | Usually not ideal |
| Legal/forensic value | Sometimes | Usually yes |
Example domain event:
CREATE TABLE case_events (
case_event_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
event_type text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_user_id bigint,
payload jsonb NOT NULL,
request_id text,
UNIQUE (request_id)
);
Example payload:
{
"fromStatus": "triage",
"toStatus": "investigation",
"reasonCode": "risk_threshold_exceeded",
"ruleVersion": "risk-rules-2026-06-01"
}
This payload should not hide facts that require frequent relational filtering. If toStatus, reasonCode, or ruleVersion are core query dimensions, store them as columns too.
11. Approval Modelling
Avoid approval booleans on the case row.
Bad:
ALTER TABLE cases
ADD COLUMN approved boolean NOT NULL DEFAULT false,
ADD COLUMN approved_by_user_id bigint,
ADD COLUMN approved_at timestamptz;
This supports only one approval and cannot model rejection, multi-step approval, delegated approval, quorum, or re-approval.
Better:
CREATE TABLE approval_requests (
approval_request_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
approval_type text NOT NULL,
requested_by_user_id bigint NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled')),
decided_at timestamptz
);
CREATE TABLE approval_decisions (
approval_decision_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
approval_request_id bigint NOT NULL REFERENCES approval_requests(approval_request_id),
approver_user_id bigint NOT NULL,
decision text NOT NULL CHECK (decision IN ('approved', 'rejected')),
decided_at timestamptz NOT NULL DEFAULT now(),
reason_code text,
comment text,
UNIQUE (approval_request_id, approver_user_id)
);
11.1 Approval Invariant
Possible invariant:
A pending approval request can have at most one decision per approver.
Already enforced by:
UNIQUE (approval_request_id, approver_user_id)
Another invariant:
A case cannot move from review to approved unless its approval request is approved.
This may require transactional command logic:
UPDATE cases c
SET current_status = 'approved',
updated_at = now()
WHERE c.case_id = :case_id
AND c.current_status = 'review'
AND EXISTS (
SELECT 1
FROM approval_requests ar
WHERE ar.case_id = c.case_id
AND ar.approval_type = 'final_review'
AND ar.status = 'approved'
);
12. Assignment and Ownership Over Time
Assignment is not just assigned_to_user_id.
You may need:
- current owner,
- previous owner,
- assignment reason,
- assignment source,
- assignment duration,
- team assignment,
- reviewer assignment,
- temporary delegation,
- workload balancing,
- auditability.
Model assignment as temporal fact:
CREATE TABLE case_assignments (
case_assignment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
assignee_type text NOT NULL CHECK (assignee_type IN ('user', 'team')),
assignee_id bigint NOT NULL,
assignment_role text NOT NULL CHECK (assignment_role IN ('primary_owner', 'reviewer', 'observer')),
assigned_by_user_id bigint NOT NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
released_at timestamptz,
reason_code text,
CHECK (released_at IS NULL OR released_at > assigned_at)
);
12.1 One Active Primary Owner
PostgreSQL-style partial unique index:
CREATE UNIQUE INDEX ux_case_one_active_primary_owner
ON case_assignments (case_id)
WHERE assignment_role = 'primary_owner'
AND released_at IS NULL;
If your database does not support partial unique indexes, alternatives include:
- generated active flag with unique constraint,
- separate
case_current_assignmentstable, - trigger-enforced invariant,
- application command with serializable isolation,
- filtered index in engines that support it.
12.2 Current Assignment Query
SELECT ca.case_id, ca.assignee_type, ca.assignee_id
FROM case_assignments ca
WHERE ca.assignment_role = 'primary_owner'
AND ca.released_at IS NULL;
12.3 Assignment History Query
SELECT case_id,
assignee_type,
assignee_id,
assigned_at,
released_at,
released_at - assigned_at AS assignment_duration
FROM case_assignments
WHERE case_id = :case_id
ORDER BY assigned_at;
13. Escalation Modelling
Escalation is often mis-modelled as a boolean.
Bad:
is_escalated boolean NOT NULL DEFAULT false
This cannot answer:
- why escalated?
- who escalated?
- when?
- from which level to which level?
- was it automatic?
- was it resolved?
- did it happen multiple times?
Better:
CREATE TABLE case_escalations (
escalation_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
escalation_level text NOT NULL,
escalated_at timestamptz NOT NULL DEFAULT now(),
escalated_by_user_id bigint,
escalation_source text NOT NULL CHECK (escalation_source IN ('manual', 'sla', 'risk_rule', 'system')),
reason_code text NOT NULL,
resolved_at timestamptz,
resolved_by_user_id bigint,
resolution_comment text,
CHECK (resolved_at IS NULL OR resolved_at >= escalated_at)
);
13.1 One Active Escalation Per Level
CREATE UNIQUE INDEX ux_case_active_escalation_level
ON case_escalations (case_id, escalation_level)
WHERE resolved_at IS NULL;
13.2 Active Escalation Query
SELECT case_id, escalation_level, escalated_at, reason_code
FROM case_escalations
WHERE resolved_at IS NULL;
14. Deadline and SLA Modelling
Deadlines are facts.
They should not be hidden inside application timers.
CREATE TABLE case_deadlines (
deadline_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
deadline_type text NOT NULL,
due_at timestamptz NOT NULL,
source_rule_code text,
source_rule_version text,
created_at timestamptz NOT NULL DEFAULT now(),
satisfied_at timestamptz,
cancelled_at timestamptz,
CHECK (satisfied_at IS NULL OR satisfied_at >= created_at),
CHECK (cancelled_at IS NULL OR cancelled_at >= created_at),
CHECK (NOT (satisfied_at IS NOT NULL AND cancelled_at IS NOT NULL))
);
14.1 Open Deadline Query
SELECT d.case_id, d.deadline_type, d.due_at
FROM case_deadlines d
WHERE d.satisfied_at IS NULL
AND d.cancelled_at IS NULL
ORDER BY d.due_at;
14.2 Breach Query
SELECT d.case_id,
d.deadline_type,
d.due_at,
now() - d.due_at AS overdue_by
FROM case_deadlines d
WHERE d.satisfied_at IS NULL
AND d.cancelled_at IS NULL
AND d.due_at < now();
14.3 Deadline Rule Versioning
Store the rule version that created the deadline.
Why?
Because future policy changes should not rewrite history.
This deadline was calculated under rule version SLA-2026-04.
That is defensible.
15. Tasks and Work Items
Workflow systems often need human work queues.
A task is not the same as case status.
CREATE TABLE work_items (
work_item_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
work_type text NOT NULL,
status text NOT NULL CHECK (status IN ('open', 'claimed', 'completed', 'cancelled')),
priority text NOT NULL CHECK (priority IN ('low', 'normal', 'high', 'critical')),
available_at timestamptz NOT NULL DEFAULT now(),
claimed_by_user_id bigint,
claimed_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (completed_at IS NULL OR completed_at >= created_at)
);
15.1 Claim Work Safely
UPDATE work_items
SET status = 'claimed',
claimed_by_user_id = :user_id,
claimed_at = now()
WHERE work_item_id = (
SELECT work_item_id
FROM work_items
WHERE status = 'open'
AND available_at <= now()
ORDER BY priority DESC, available_at, work_item_id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING work_item_id, case_id, work_type;
This combines queue semantics with row locking.
The exact syntax varies by engine, but the pattern is:
Find one eligible row.
Lock it.
Mark it claimed.
Return it.
16. Workflow and Authorization
Workflow transitions usually depend on actor role.
Bad:
Application checks role somewhere before update.
Better model:
CREATE TABLE workflow_transition_permissions (
workflow_code text NOT NULL,
transition_code text NOT NULL,
role_code text NOT NULL,
PRIMARY KEY (workflow_code, transition_code, role_code),
FOREIGN KEY (workflow_code, transition_code)
REFERENCES workflow_transitions(workflow_code, transition_code)
);
Then command logic can verify:
SELECT 1
FROM workflow_transition_permissions wtp
JOIN user_roles ur ON ur.role_code = wtp.role_code
WHERE wtp.workflow_code = :workflow_code
AND wtp.transition_code = :transition_code
AND ur.user_id = :actor_user_id;
This does not mean all authorization must live in SQL. It means the data model can represent permissions explicitly enough to audit and test them.
17. Workflow Rule Modelling
Rules change.
Hardcoding every rule into schema constraints makes change hard.
But hiding every rule in application code makes audit hard.
A balanced approach:
CREATE TABLE workflow_rules (
rule_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workflow_code text NOT NULL,
rule_code text NOT NULL,
rule_version text NOT NULL,
active_from timestamptz NOT NULL,
active_to timestamptz,
rule_description text NOT NULL,
rule_config jsonb NOT NULL,
UNIQUE (workflow_code, rule_code, rule_version),
CHECK (active_to IS NULL OR active_to > active_from)
);
When a rule affects a decision, copy the rule version into the decision/event/deadline.
ALTER TABLE case_deadlines
ADD COLUMN calculation_rule_id bigint REFERENCES workflow_rules(rule_id);
This supports reconstruction:
At the time, the system applied rule X version Y.
18. Lifecycle Analytics
A good workflow schema supports analytics without guessing.
18.1 Time in Each Status
WITH ordered AS (
SELECT case_id,
to_status AS status,
changed_at AS entered_at,
LEAD(changed_at) OVER (
PARTITION BY case_id
ORDER BY changed_at, transition_id
) AS exited_at
FROM case_status_transitions
)
SELECT status,
AVG(COALESCE(exited_at, now()) - entered_at) AS avg_time_in_status
FROM ordered
GROUP BY status;
18.2 Cases Stuck in Status
SELECT c.case_id,
c.case_number,
c.current_status,
MAX(t.changed_at) AS entered_status_at,
now() - MAX(t.changed_at) AS time_in_status
FROM cases c
JOIN case_status_transitions t
ON t.case_id = c.case_id
AND t.to_status = c.current_status
WHERE c.current_status NOT IN ('closed', 'rejected')
GROUP BY c.case_id, c.case_number, c.current_status
HAVING now() - MAX(t.changed_at) > interval '7 days';
18.3 Reopened Cases
SELECT case_id, COUNT(*) AS reopen_count
FROM case_status_transitions
WHERE to_status = 'reopened'
GROUP BY case_id
HAVING COUNT(*) > 0;
The schema makes these queries natural.
19. Reconstructing Past Truth
A defensible system must answer:
What did we know at time T?
For workflow, this includes:
- case current status at time T,
- active assignment at time T,
- open deadlines at time T,
- active escalation at time T,
- approval state at time T,
- rule version used at time T.
19.1 Status at Time T
SELECT t.case_id, t.to_status AS status_at_t
FROM case_status_transitions t
WHERE t.case_id = :case_id
AND t.changed_at <= :as_of
ORDER BY t.changed_at DESC, t.transition_id DESC
LIMIT 1;
19.2 Owner at Time T
SELECT ca.case_id, ca.assignee_id
FROM case_assignments ca
WHERE ca.case_id = :case_id
AND ca.assignment_role = 'primary_owner'
AND ca.assigned_at <= :as_of
AND (ca.released_at IS NULL OR ca.released_at > :as_of)
ORDER BY ca.assigned_at DESC
LIMIT 1;
19.3 Open Deadlines at Time T
SELECT deadline_id, deadline_type, due_at
FROM case_deadlines
WHERE case_id = :case_id
AND created_at <= :as_of
AND (satisfied_at IS NULL OR satisfied_at > :as_of)
AND (cancelled_at IS NULL OR cancelled_at > :as_of);
If your schema cannot answer these questions, it may be operationally useful but not defensible.
20. Avoiding Contradictory Lifecycle Columns
Bad:
CREATE TABLE cases_bad (
case_id bigint PRIMARY KEY,
status text NOT NULL,
is_submitted boolean NOT NULL,
is_in_review boolean NOT NULL,
is_approved boolean NOT NULL,
is_rejected boolean NOT NULL,
is_closed boolean NOT NULL,
submitted_at timestamptz,
approved_at timestamptz,
rejected_at timestamptz,
closed_at timestamptz
);
This allows impossible combinations:
status = 'approved'
is_rejected = true
closed_at is null
rejected_at is not null
Better:
- one current status,
- transition history,
- derived milestone timestamps when needed,
- constraints for terminal states.
CREATE TABLE case_milestones (
case_id bigint NOT NULL REFERENCES cases(case_id),
milestone_code text NOT NULL,
achieved_at timestamptz NOT NULL,
transition_id bigint REFERENCES case_status_transitions(transition_id),
PRIMARY KEY (case_id, milestone_code)
);
Milestones can be derived or stored if they are frequently needed.
21. Workflow Projection for Dashboard
Source-of-truth workflow tables are normalized.
Dashboards need fast filtering.
Create a projection:
CREATE TABLE case_work_queue_view (
case_id bigint PRIMARY KEY,
case_number text NOT NULL,
current_status text NOT NULL,
priority text NOT NULL,
primary_owner_user_id bigint,
primary_owner_name text,
active_escalation_level text,
next_deadline_at timestamptz,
breached_deadline_count integer NOT NULL DEFAULT 0,
open_work_item_count integer NOT NULL DEFAULT 0,
last_transition_at timestamptz,
refreshed_at timestamptz NOT NULL
);
This is a read model.
Rules:
- never update it as the source of truth,
- always store
refreshed_at, - make it rebuildable,
- reconcile it against source tables,
- design indexes for dashboard filters.
Example index:
CREATE INDEX ix_case_work_queue_status_owner_deadline
ON case_work_queue_view (current_status, primary_owner_user_id, next_deadline_at);
22. Reconciliation Queries
Every projection needs reconciliation.
22.1 Current Status Drift
WITH last_transition AS (
SELECT DISTINCT ON (case_id)
case_id,
to_status,
changed_at
FROM case_status_transitions
ORDER BY case_id, changed_at DESC, transition_id DESC
)
SELECT c.case_id,
c.current_status,
lt.to_status AS expected_status
FROM cases c
JOIN last_transition lt ON lt.case_id = c.case_id
WHERE c.current_status <> lt.to_status;
For engines without DISTINCT ON, use ROW_NUMBER().
22.2 Dashboard Owner Drift
WITH current_owner AS (
SELECT case_id, assignee_id AS expected_owner_user_id
FROM case_assignments
WHERE assignment_role = 'primary_owner'
AND released_at IS NULL
)
SELECT q.case_id,
q.primary_owner_user_id,
co.expected_owner_user_id
FROM case_work_queue_view q
LEFT JOIN current_owner co ON co.case_id = q.case_id
WHERE q.primary_owner_user_id IS DISTINCT FROM co.expected_owner_user_id;
22.3 Deadline Count Drift
WITH expected AS (
SELECT case_id, COUNT(*) AS breached_count
FROM case_deadlines
WHERE satisfied_at IS NULL
AND cancelled_at IS NULL
AND due_at < now()
GROUP BY case_id
)
SELECT q.case_id,
q.breached_deadline_count,
COALESCE(e.breached_count, 0) AS expected_count
FROM case_work_queue_view q
LEFT JOIN expected e ON e.case_id = q.case_id
WHERE q.breached_deadline_count <> COALESCE(e.breached_count, 0);
23. Workflow Command Pattern
A command changes workflow state.
Treat it as a unit-of-work.
Schema:
CREATE TABLE outbox_messages (
outbox_message_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
request_id text,
UNIQUE (request_id, event_type)
);
This prevents the classic failure:
Database commit succeeded, but message publish failed.
With outbox, message publication becomes retryable after commit.
24. Trigger vs Application Logic
Should workflow invariants be enforced by triggers?
Use a decision table.
| Rule type | Good fit for constraint? | Good fit for trigger? | Good fit for service logic? |
|---|---|---|---|
| Simple row invariant | Yes | Rarely | Maybe |
| Cross-row uniqueness | Index/constraint | Sometimes | Maybe |
| Transition allowed by state catalog | Maybe | Maybe | Yes |
| Authorization by actor role | Rarely | Rarely | Yes |
| External system check | No | No | Yes |
| Audit row insertion | Maybe | Yes | Yes |
| Derived projection refresh | Maybe | Maybe | Async worker often better |
| Deadline calculation | Rarely | Sometimes | Yes |
Triggers can protect invariants from all write paths, but they also hide behaviour.
Use triggers when:
- many clients write to the same tables,
- invariant must be impossible to bypass,
- trigger logic is simple and deterministic,
- operational team can debug it.
Avoid triggers when:
- logic depends on external systems,
- logic is complex workflow orchestration,
- error handling needs domain-specific response,
- performance impact is unpredictable,
- developers cannot see side effects.
25. Workflow Index Design
Workflows usually need these access paths.
25.1 Current Work Queue
CREATE INDEX ix_cases_status_priority_updated
ON cases (current_status, priority, updated_at);
25.2 Case Timeline
CREATE INDEX ix_case_transitions_case_time
ON case_status_transitions (case_id, changed_at, transition_id);
25.3 User Assignments
CREATE INDEX ix_case_assignments_active_assignee
ON case_assignments (assignee_type, assignee_id, assignment_role, case_id)
WHERE released_at IS NULL;
25.4 Deadline Queue
CREATE INDEX ix_case_deadlines_open_due
ON case_deadlines (due_at, case_id)
WHERE satisfied_at IS NULL
AND cancelled_at IS NULL;
25.5 Outbox Publisher
CREATE INDEX ix_outbox_unpublished
ON outbox_messages (created_at, outbox_message_id)
WHERE published_at IS NULL;
Index design follows workflow queries, not table names.
26. Workflow Failure Modes
26.1 Status Updated Without History
Cause:
- manual SQL update,
- migration script,
- application bug,
- missing transaction.
Detection:
SELECT c.case_id
FROM cases c
LEFT JOIN case_status_transitions t
ON t.case_id = c.case_id
AND t.to_status = c.current_status
WHERE t.transition_id IS NULL;
26.2 History Exists Without Current State
Cause:
- insert transition committed but current update failed,
- replay bug,
- partial restore.
Detection: compare current state with last transition.
26.3 Double Active Owner
Cause:
- missing partial unique index,
- race condition,
- soft-release bug.
Detection:
SELECT case_id, COUNT(*)
FROM case_assignments
WHERE assignment_role = 'primary_owner'
AND released_at IS NULL
GROUP BY case_id
HAVING COUNT(*) > 1;
26.4 Deadline Breach Not Escalated
SELECT d.case_id, d.deadline_id
FROM case_deadlines d
LEFT JOIN case_escalations e
ON e.case_id = d.case_id
AND e.escalation_source = 'sla'
AND e.resolved_at IS NULL
WHERE d.satisfied_at IS NULL
AND d.cancelled_at IS NULL
AND d.due_at < now()
AND e.escalation_id IS NULL;
26.5 Projection Drift
Use reconciliation queries from section 22.
27. Defensible Workflow Design
A defensible workflow system can explain itself.
For every consequential decision, capture:
- entity id,
- actor,
- authority/role,
- input facts,
- rule version,
- decision,
- timestamp,
- prior state,
- resulting state,
- reason/comment,
- request id,
- external references.
Example decision table:
CREATE TABLE case_decisions (
decision_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
case_id bigint NOT NULL REFERENCES cases(case_id),
decision_type text NOT NULL,
decision_outcome text NOT NULL,
decided_by_user_id bigint,
decided_at timestamptz NOT NULL DEFAULT now(),
authority_role_code text,
rule_code text,
rule_version text,
evidence_summary text,
reason_code text,
comment text,
request_id text UNIQUE
);
This table is different from transition history.
A decision may cause a transition, but not every transition is a decision.
28. End-to-End Example: Escalate a Case
Business rule:
If a high-priority case remains in triage for more than 48 hours, escalate it to level_1 and create a review work item.
28.1 Find Eligible Cases
WITH triage_entry AS (
SELECT case_id, MAX(changed_at) AS entered_triage_at
FROM case_status_transitions
WHERE to_status = 'triage'
GROUP BY case_id
)
SELECT c.case_id
FROM cases c
JOIN triage_entry te ON te.case_id = c.case_id
LEFT JOIN case_escalations e
ON e.case_id = c.case_id
AND e.escalation_level = 'level_1'
AND e.resolved_at IS NULL
WHERE c.current_status = 'triage'
AND c.priority IN ('high', 'critical')
AND te.entered_triage_at < now() - interval '48 hours'
AND e.escalation_id IS NULL;
28.2 Apply Escalation Transaction
BEGIN;
INSERT INTO case_escalations (
case_id,
escalation_level,
escalation_source,
reason_code,
escalated_at
)
VALUES (
:case_id,
'level_1',
'sla',
'triage_over_48h',
now()
);
INSERT INTO work_items (
case_id,
work_type,
status,
priority,
available_at
)
VALUES (
:case_id,
'escalation_review',
'open',
'high',
now()
);
INSERT INTO case_events (
case_id,
event_type,
occurred_at,
payload
)
VALUES (
:case_id,
'CaseEscalated',
now(),
jsonb_build_object(
'level', 'level_1',
'source', 'sla',
'reasonCode', 'triage_over_48h'
)
);
INSERT INTO outbox_messages (
aggregate_type,
aggregate_id,
event_type,
payload
)
VALUES (
'case',
CAST(:case_id AS text),
'CaseEscalated',
jsonb_build_object('caseId', :case_id, 'level', 'level_1')
);
COMMIT;
28.3 Required Invariant
The unique index on active escalation prevents duplicate escalation if two workers race.
CREATE UNIQUE INDEX ux_case_active_escalation_level
ON case_escalations (case_id, escalation_level)
WHERE resolved_at IS NULL;
The database becomes the final concurrency guard.
29. Production Checklist
For each workflow entity:
- Is current state stored explicitly?
- Is transition history stored append-only?
- Are valid transitions represented?
- Are invalid transitions rejected atomically?
- Is there idempotency for commands?
- Can the system reconstruct status at time T?
- Can it reconstruct assignment at time T?
- Can it explain approvals and decisions?
- Are deadlines stored as facts?
- Are escalations modelled as lifecycle records, not booleans?
- Are work queues claim-safe under concurrency?
- Are read models clearly separated from source-of-truth tables?
- Are projections rebuildable?
- Are reconciliation queries defined?
- Are rule versions captured for consequential decisions?
- Are indexes aligned with operational queues and timelines?
- Can manual data fixes be audited?
30. Drills
Drill 1: Design a Complaint Workflow
States:
received -> validated -> assigned -> investigation -> resolution_proposed -> approved -> closed
received -> rejected -> closed
closed -> reopened -> investigation
Create:
- state table,
- transition table,
- complaint table,
- transition history table,
- approval request table,
- assignment table,
- deadline table.
Drill 2: Write a Safe Transition
Write a transaction that moves a complaint from investigation to resolution_proposed only if:
- current status is
investigation, - actor is assigned primary owner,
- no open critical deadline is breached,
- request id has not been processed.
Drill 3: Detect Drift
Write reconciliation queries for:
- current status vs last transition,
- active owner count,
- open deadline count in dashboard projection,
- approval request status vs decisions.
Drill 4: Reconstruct Past Truth
For a case id and timestamp, return:
- status at time T,
- primary owner at time T,
- active escalations at time T,
- open deadlines at time T,
- last decision before time T.
31. Summary
Workflow data modelling is where SQL becomes enterprise systems engineering.
A robust model separates:
current state
transition history
domain events
audit evidence
assignments
deadlines
approvals
escalations
work items
read projections
The central rule:
Operational state must be fast to read.
Historical truth must be reconstructable.
Consequential decisions must be explainable.
Concurrency must be guarded by the database.
A status column is useful, but it is not a workflow model.
A defensible workflow model can answer both:
What should the system do next?
and:
Why did the system do what it did then?
Part 023 continues this direction with temporal SQL, history, auditability, valid time, transaction time, bitemporal modelling, and reconstructing past truth at a deeper level.
References
- PostgreSQL Documentation, Constraints — https://www.postgresql.org/docs/current/ddl-constraints.html
- PostgreSQL Documentation, Partial Indexes — https://www.postgresql.org/docs/current/indexes-partial.html
- PostgreSQL Documentation, Explicit Locking — https://www.postgresql.org/docs/current/explicit-locking.html
- PostgreSQL Documentation,
SELECTand locking clauses — https://www.postgresql.org/docs/current/sql-select.html - Martin Fowler, Domain Event — https://martinfowler.com/eaaDev/DomainEvent.html
- Martin Fowler, Transaction Script — https://martinfowler.com/eaaCatalog/transactionScript.html
You just completed lesson 22 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.