A client needed a custom document type to move through an approval lifecycle: Draft, Pending Review, Approved, Rejected, Closed - with specific rules about which transitions were legal from which state, and specific actions enabled or disabled depending on where the document currently sat. The instinct on a newer Acumatica version is to reach straight for the modern Workflow Engine's screen-based configuration, and often that's correct. But plenty of real customizations either predate Workflow Engine adoption on that instance, need transition logic too custom for declarative configuration, or are extending a base graph where introducing a new workflow definition isn't practical. For those, hand-rolling a state machine inside a graph extension is still a completely normal, well-understood pattern - and getting the pieces right matters.
A status field, a transition table, and RowPersisting as the enforcement point
The shape I use every time: a status field on the DAC (typically a short char code with a matching selector attribute), a transition table expressed as plain C# describing which states may move to which other states, and enforcement in RowPersisting - the same event that already owns cross-field validation, because "is this a legal transition" is exactly that kind of whole-row check.
public class CustomDocStatus
{
public const string Draft = "D";
public const string PendingReview = "P";
public const string Approved = "A";
public const string Rejected = "R";
public const string Closed = "C";
public class draft : PX.Data.BQL.BqlString.Constant<draft>
{ public draft() : base(Draft) { } }
// ... one BQL constant class per status, same pattern as base Acumatica DACs
}
// Legal transitions, expressed once, checked everywhere
private static readonly Dictionary<string, string[]> LegalTransitions = new()
{
[CustomDocStatus.Draft] = new[] { CustomDocStatus.PendingReview },
[CustomDocStatus.PendingReview] = new[] { CustomDocStatus.Approved, CustomDocStatus.Rejected },
[CustomDocStatus.Approved] = new[] { CustomDocStatus.Closed },
[CustomDocStatus.Rejected] = new[] { CustomDocStatus.Draft }, // rejected can be reworked
[CustomDocStatus.Closed] = Array.Empty<string>(), // terminal
};
Detecting a transition requires the field's original value, not just its current one
The part that's easy to get wrong on a first attempt: RowPersisting only sees the row's current, in-memory state - to know whether a transition actually happened (and what it transitioned from), you need the cache's original value for that field, which cache.GetValueOriginal gives you without an extra query.
protected virtual void _(Events.RowPersisting<CustomDoc> e)
{
CustomDoc row = (CustomDoc)e.Row;
if (row == null || e.Operation == PXDBOperation.Delete) return;
string oldStatus = (string)e.Cache.GetValueOriginal<CustomDoc.status>(row);
string newStatus = row.Status;
if (oldStatus == newStatus) return; // no transition, nothing to check
if (oldStatus == null) return; // brand new row - any initial status is fine
if (!LegalTransitions.TryGetValue(oldStatus, out var allowed) || !allowed.Contains(newStatus))
{
e.Cache.RaiseExceptionHandling<CustomDoc.status>(row, newStatus,
new PXSetPropertyException($"Cannot move from {oldStatus} to {newStatus} directly."));
e.Cancel = true;
}
}
It's tempting to enforce the state machine only inside the action buttons that trigger transitions ("Approve," "Reject") and assume that's the only way status ever changes. It isn't - direct cache edits from other graph extensions, import scenarios, and API-driven updates through the contract-based REST API can all set the status field without going through your button at all. RowPersisting is the one place every path to a saved row passes through, which is exactly why cross-field validation belongs there generally, and why a state machine's legality check is a specific case of that same rule.
Enabling and disabling actions based on current state belongs in RowSelected
The complementary half of a hand-rolled state machine is UI reactivity - the Approve button should only be enabled in Pending Review, Reject only from Pending Review, and so on. That's ordinary RowSelected work, run on every paint, cheap and idempotent exactly the way RowSelected should be:
protected virtual void _(Events.RowSelected<CustomDoc> e)
{
CustomDoc row = (CustomDoc)e.Row;
if (row == null) return;
bool pending = row.Status == CustomDocStatus.PendingReview;
Approve.SetEnabled(pending);
Reject.SetEnabled(pending);
SubmitForReview.SetEnabled(row.Status == CustomDocStatus.Draft);
}
Transition actions are thin - they set status and let RowPersisting validate
The button actions themselves stay deliberately simple: set the new status, let the row's normal save path (and the RowPersisting gate above) do the actual legality enforcement, rather than duplicating the transition rules inside every button. Duplicating the check in both places is how the two definitions quietly drift apart after a few sprints of "quick" changes to one but not the other.
protected virtual IEnumerable approve(PXAdapter adapter)
{
CustomDoc row = Base.Document.Current;
if (row == null) return adapter.Get();
row.Status = CustomDocStatus.Approved;
Base.Document.Update(row);
Base.Save.Press(); // RowPersisting enforces the transition is actually legal
return adapter.Get();
}
When to stop hand-rolling and adopt Workflow Engine instead
This pattern earns its keep for a small number of states with clear linear or near-linear transitions, especially inside an extension of an existing graph where introducing a full workflow definition is disproportionate. Once the state count grows, transitions start depending on user role or conditional business rules per transition, or the client wants to see and edit the flow visually, that's the signal to migrate to Workflow Engine rather than keep extending a hand-rolled dictionary - the declarative engine is built for exactly that complexity, and a hand-rolled version re-implements an increasing fraction of it badly as requirements grow.
Wrapping up
A hand-rolled state machine in a graph extension is a legitimate, well-worn pattern: a status field, a transitions map, enforcement in RowPersisting using the cache's original value to detect the actual transition, and RowSelected purely for reflecting current state in the UI. Keep the legality rule defined in exactly one place and let every path to a save route through it - and recognize the point where the requirements have outgrown a dictionary and genuinely call for Workflow Engine instead.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.