Acumatica · Architecture

Acumatica Clean Architecture

There is no Uncle Bob layering inside an Acumatica graph — clean architecture here means keeping business rules out of event handlers and isolating the platform behind seams you can test and upgrade.

John Kihiu12 min read

"Clean architecture" gets thrown around as if you could drop the four-layer diagram onto an Acumatica graph. You cannot. The graph is the framework — it owns the cache, the persistence, the events, and the UI binding, all in one class. Clean architecture inside Acumatica is not about layers on top of that; it is about keeping your business rules out of the framework's event handlers and behind seams you can test and re-apply after an upgrade. The goal is code that reads like your domain, not like a pile of RowSelected handlers.

Where the mud collects

The default failure mode is a graph extension where every rule lives inline in an event. Pricing logic in FieldUpdated, approval logic in RowPersisting, tax rounding in RowSelected. It works, and then two years later the field it read was renamed by a base upgrade, the rule is duplicated across three screens, and no one can test it without spinning up the whole graph. The event handler is a delivery mechanism, not a home for logic. Treat it that way.

Keep business rules in plain classes

The single most useful move is to pull decision logic into ordinary C# classes that take primitives or DACs and return a result — no PXGraph, no PXCache, no static Acumatica state. The event handler becomes a thin adapter: read values off the cache, call the rule, write the outcome back.

C# · DOMAIN RULE + THIN HANDLER
// Pure rule — no framework types, trivially unit-testable
public static class DiscountPolicy
{
    public static decimal Resolve(string customerClass, decimal orderTotal)
    {
        if (customerClass == "WHOLESALE" && orderTotal >= 10000m) return 0.10m;
        if (orderTotal >= 5000m) return 0.05m;
        return 0m;
    }
}

// Handler is just an adapter between the cache and the rule
public class SOOrderEntryExt : PXGraphExtension<SOOrderEntry>
{
    protected void SOOrder_OrderDiscPct_FieldDefaulting(
        PXCache sender, PXFieldDefaultingEventArgs e)
    {
        var order = (SOOrder)e.Row;
        if (order == null) return;
        var cust = Customer.PK.Find(Base, order.CustomerID);
        e.NewValue = DiscountPolicy.Resolve(
            cust?.CustomerClassID, order.OrderTotal ?? 0m) * 100m;
    }
}

DiscountPolicy can be exercised in a plain test project with no Acumatica runtime at all. When the pricing rules change — and they always do — you edit one small class with tests around it, not a 400-line event handler you are afraid to touch.

Isolate the platform behind seams

You still need to read data, and in Acumatica that means PXSelect / BQL against the graph. Do not scatter those queries through your rules. Wrap the reads your logic needs behind a small provider so the rule depends on an intent ("give me the customer's open balance") rather than on a specific BQL statement that a future release can break.

Depend on data, not on queries

A rule that receives an open balance is stable across upgrades. A rule that runs its own PXSelect<ARRegister> inline is coupled to the base schema and has to be re-audited every release. Keep the query at the edge, keep the number in the middle.

Extend, never fork

The most important architectural constraint in Acumatica has nothing to do with SOLID and everything to do with upgrades: always subclass through PXGraphExtension and PXCacheExtension, never copy a base graph to modify it. Custom fields go on cache extensions with the Usr prefix, base behavior is adjusted with [PXOverride], and the original code stays untouched so the next release patches it under you cleanly.

C# · CACHE EXTENSION
public class SOOrderExt : PXCacheExtension<SOOrder>
{
    // Usr prefix keeps this field distinct from any base column
    [PXDBString(15, IsUnicode = true)]
    [PXUIField(DisplayName = "Fulfilment Region")]
    public string UsrFulfilmentRegion { get; set; }
    public abstract class usrFulfilmentRegion
        : PX.Data.BQL.BqlString.Field<usrFulfilmentRegion> { }
}
The Usr prefix is not optional

Custom fields on stock DACs must start with Usr. Acumatica uses that prefix to keep your columns separate from base columns, and the upgrade tooling relies on it. Skip it and a future release can collide with your field name.

Overriding base behavior cleanly

When you do need to change how a base graph behaves, override the base method and decide deliberately whether to call the original. The clean version keeps your added logic small and delegates the heavy lifting back to Base, so a base bug fix still flows through.

C# · PXOVERRIDE
public class SOOrderEntryReleaseExt : PXGraphExtension<SOOrderEntry>
{
    public delegate void ReleaseDelegate(SOOrder order);

    [PXOverride]
    public void Release(SOOrder order, ReleaseDelegate baseMethod)
    {
        if (!ApprovalPolicy.IsReleasable(order))
            throw new PXException("Order is not approved for release.");

        baseMethod(order); // let the platform do the real work
    }
}

Testing the clean parts

The payoff of pushing logic out of handlers is that the interesting code becomes testable without the framework. DiscountPolicy.Resolve and ApprovalPolicy.IsReleasable are plain functions — assert them in a normal test project. What you cannot easily unit-test is the graph itself, and that is fine: the thinner the handler, the less there is to test through the heavyweight path, and the more of your risk sits in code you can cover cheaply.

Wrapping up

Clean architecture in Acumatica is a modest, practical discipline: keep business rules in plain classes, keep platform queries at the edge, extend rather than fork, and override the base deliberately. You are not fighting the framework or hiding it behind an abstraction — you are just making sure the parts that encode your business survive the parts that Acumatica rewrites every release.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.