Objects, States, and Stewards: Introducing Generic Workflows to an Existing Project
· 7 min read
architecture workflows fsm elixir programming design
The urge to automate business processes rarely strikes on day one of a greenfield project. On day one, you have two database tables, a signup form, and the blissful delusion that business logic will always fit inside three clean functions.
The urge arrives two years later.
By then, you have customer accounts, billing runs, support tickets, inventory shipments, and legal agreements. You also have half a dozen ad-hoc “processes” living as comments inside controller actions, stray background workers checking for boolean flags in cron loops, and angry messages in Slack asking why an invoice was refunded before anyone signed off on the return.
Management has a simple request: “We need workflows.”
At this junction, engineering teams usually make one of two catastrophic mistakes:
-
The Ad-Hoc Infection:
They bolt a bespoke
workflow_statuscolumn and a handful of conditionalcasestatements onto every existing model. Within three sprints, five separate teams have implemented five incompatible ways to move records around, send emails, and check permissions. - The Heavyweight Invader: They purchase or deploy an enterprise BPMN orchestrator or microservice engine that demands every domain object be serialized into generic JSON payloads and piped through a foreign external runtime.
There is a third way: introducing a lean, generic, and agnostic workflow machinery directly into your existing project. It respects your existing domain models, enforces strict state transitions, classifies access through stewardship, notifies the right people without hardcoding recipient lists, and uses templates so that adding your twentieth workflow is as pleasant and predictable as adding your first.
Here is the blueprint.
The Four Pillars of Agnostic Machinery
A generic workflow engine does not need to know what an invoice, a pull request, or a customer refund actually is. It only needs to understand four orthogonal concepts:
flowchart LR
Subject["Attached Object(s)\n(Domain Reality)"] --- Engine["Workflow Machinery\n(Agnostic Core)"]
Topology["States & Transitions\n(Discrete Topology)"] --- Engine
Stewardship["Stewardship\n(Classified Access)"] --- Engine
TWIMC["TWIMC Notifications\n(Decoupled Broadcast)"] --- Engine
① Attached Objects: Holding the Leash, Not the Data
The biggest architectural blunder is letting the workflow engine consume or duplicate the domain model. An
Invoice
belongs in your billing context. A
Contract
belongs in your legal context.
The workflow instance merely attaches to the domain object via a polymorphic handle:
schema "workflow_instances" do
field :template, :string
field :current_state, :string
field :subject_type, :string
field :subject_id, :string
field :context, :map, default: %{}
field :lock_version, :integer, default: 1
timestamps()
end
The domain object is the subject . The workflow instance is the governor .
The workflow holds the leash: it knows
where
the subject is in its lifecycle, but it does not dictate its database schema. If a process coordinates multiple objects—say, an
Order
and three
Shipment
records—the primary subject remains the anchor, while related entity IDs travel inside the workflow’s transient
context
payload.
② States in Between: Discrete Coordinates
A workflow is not a nebulous continuum; it is a finite graph. Between the moment a request is born and the moment it terminates, it occupies exactly one discrete state at any given second.
Transitions between states must be:
- Explicit: Only declared edges in the graph can be traversed.
- Atomic: The state change and any accompanying subject mutations succeed or fail together inside a single database transaction.
- Validated: The engine rejects any event that does not match the current coordinate.
③ Stewardship: Contextual Authorization
Coarse-grained permissions (such as
user.role == :admin
) collapse the moment real human workflows enter the picture. Who is allowed to approve an expense report? The author cannot approve their own report. The department head can approve up to $5,000. Legal must sign off if it touches international compliance.
We call this Stewardship : access classified and regulated by the actor’s relationship to the subject and the specific transition being attempted.
Under stewardship, an action is not authorized by a static global role, but by evaluating three variables:
- The Actor: Who is knocking at the door?
- The Transition: Which edge are they trying to cross?
- The Subject Context: What is the actor’s mandate relative to this specific object in this specific state?
A transition declares its required stewardship policy:
:author
,
:designated_reviewer
,
:finance_steward
, or
:system_automation
. If the actor fails the stewardship check, the transition is rejected at the gate before any business code runs.
④ Notifications TWIMC: To Whom It May Concern
In dirty codebases, notification logic is smeared across the application layer:
# The road to madness:
def approve_invoice(invoice, user) do
Invoice.mark_approved!(invoice)
Mailer.send_to_vendor(invoice)
Mailer.send_to_accounting(invoice)
Slack.notify("#finance", "Approved!")
end
Every new requirement adds another line of side-effect spaghetti. If one notification fails, does the transaction roll back? Who knows.
In generic workflow machinery, transitions emit a standardized event:
%TransitionEvent{
workflow_id: "wf_9812",
subject: %Invoice{id: 42},
from_state: :pending_review,
to_state: :approved,
event: :approve,
actor: %User{id: 7, role: :manager},
timestamp: ~U[2026-10-21 14:00:00Z]
}
A dedicated TWIMC Dispatcher catches this event asynchronously. It looks up the notification rules declared for that transition, resolves the recipient list dynamically (e.g., the subject’s author, the current assignees, or an external webhook), and dispatches the alerts.
The transition logic remains pristine and focused entirely on state validation.
Workflow Templates: Guarding Against Chaos
If every developer is permitted to write ad-hoc state-machine plumbing, your system quickly degrades into an unmaintainable thicket of conflicting patterns.
The antidote is Workflow Templates : declarative specifications that define the entire contract of a process in one place.
A template specifies:
- Target Subject: What type of domain object this workflow attaches to.
- States & Transitions: The allowed lifecycle topology.
- Stewardship Rules: Which actors can trigger each transition.
- TWIMC Hooks: Who gets notified when a transition occurs.
- Side-Effect Actions: Automated callbacks executed on successful transitions.
Here is what a declarative template looks like in practice:
defmodule MyApp.Workflows.Templates.ExpenseReport do
use MyApp.Workflow.Template,
subject: MyApp.Expenses.Report,
initial: :draft
# State topology
transition :submit, from: :draft, to: :pending_review do
stewardship :author_only
notify :stewards, role: :department_manager
end
transition :approve, from: :pending_review, to: :approved do
stewardship MyApp.Expenses.Stewardship.ReviewerPolicy
action MyApp.Expenses.Actions.SchedulePayment
notify :author
notify :channel, target: "#finance-approvals"
end
transition :reject, from: :pending_review, to: :rejected do
stewardship MyApp.Expenses.Stewardship.ReviewerPolicy
notify :author, template: "expense_rejected"
end
transition :payout, from: :approved, to: :settled do
stewardship :system_automation
notify :author
end
end
Notice what just happened:
- No database migrations were needed to invent a new process.
- The states, transitions, security rules, and communication policies are visible at a single glance.
- The business logic for paying out or reviewing is cleanly isolated in modular action and policy modules.
The Execution Cycle
When an actor attempts to move a process forward, the generic engine executes a disciplined sequence:
sequenceDiagram
autonumber
actor User as Actor
participant Engine as Workflow Engine
participant Policy as Stewardship Policy
participant Subject as Attached Object (DB)
participant Bus as TWIMC Bus
User->>Engine: advance(instance_id, :approve, actor, params)
Engine->>Engine: Verify state allows event (:pending_review -> :approved)
Engine->>Policy: check_stewardship(actor, transition, subject)
alt Unauthorized
Policy-->>Engine: {:error, :unauthorized}
Engine-->>User: {:error, :forbidden}
else Authorized
Policy-->>Engine: :ok
rect rgb(240, 245, 255)
Note over Engine,Subject: Atomic Transaction
Engine->>Subject: Apply transition side-effects / validations
Engine->>Engine: Update current_state = :approved
end
Engine->>Bus: publish_transition(transition_payload)
Bus-->>User: (Async) Notify Author, Stewards, Slack
Engine-->>User: {:ok, updated_instance}
end
Every transition follows the exact same pipeline:
- Topology Check: Is the requested event permitted from the current state?
- Stewardship Gate: Does the actor have the legitimate mandate to trigger this transition for this specific subject?
- Atomic Execution: In a single transaction, execute the subject hook and update the workflow instance record (with optimistic locking to prevent race conditions).
- TWIMC Broadcast: Asynchronously publish the transition event to notify anyone who needs to know.
Bringing It into an Existing Codebase
You don’t need to rebuild your application to introduce this machinery. The migration path is non-invasive:
-
Add Two Tables:
You need
workflow_instances(to track state, template name, and polymorphic subject IDs) and an optionalworkflow_logstable for audit trails. Your existing business tables don’t need to be redesigned. -
Build the Registry:
Create a registry that maps template identifiers (e.g.
"expense_report") to their compiled template modules. - Wrap, Don’t Rewrite: Pick one painful existing process—such as order returns or invoice approvals. Write a template for it. When an invoice is created, attach a new workflow instance to it.
-
Rely on the Harness:
Delegate all state progression, permission verification, and notifications to
WorkflowEngine.advance/4.
Once the machinery is in place, creating a new workflow ceases to be a week-long ordeal of bespoke schema migrations and spaghetti event handlers. You define a template, attach your object, and let the machinery do the heavy lifting.
Clear borders, classified stewardship, and automated communication. That is how you turn chaotic business requirements into predictable, pleasant engineering.