Acumatica · Customization

PXSetup Record Usage in Acumatica Customizations

Nearly every module in Acumatica ships a setup screen - AR Preferences, AP Preferences, Sales Orders Preferences - and behind each one is a DAC decorated with PXCacheName but.

John Kihiu12 min read

Nearly every module in Acumatica ships a setup screen - AR Preferences, AP Preferences, Sales Orders Preferences - and behind each one is a DAC decorated with PXCacheName but backed by a table that, by convention, holds exactly one row. When a customization needs its own module-level settings (a default markup percentage, an integration endpoint, a feature toggle), reaching for that same pattern is the right instinct. Getting the single-row guarantee actually right, though, is where I've seen this go wrong more than once.

A setup DAC is a one-row table, and PXSetup enforces that

PXSetup<TSetup> is the data view type built specifically for this: it guarantees exactly one row exists, auto-creating it with defaults on first access if the table is empty, and every graph reading setup data gets the same singleton-per-tenant row rather than having to worry about "what if there are zero rows" or "what if there are somehow two."

C# · A minimal setup DAC
[Serializable]
[PXCacheName("Custom Module Preferences")]
public class CSCustomSetup : IBqlTable
{
    #region CompanyID
    [PXDBInt]
    [PXDBDefault(typeof(AccessInfo.companyID))]
    public virtual int? CompanyID { get; set; }
    public abstract class companyID : PX.Data.BQL.BqlInt.Field<companyID> { }
    #endregion

    #region DefaultMarkupPct
    [PXDBDecimal(2)]
    [PXDefault(TypeCode.Decimal, "0.0")]
    [PXUIField(DisplayName = "Default Markup %")]
    public virtual decimal? DefaultMarkupPct { get; set; }
    public abstract class defaultMarkupPct : PX.Data.BQL.BqlDecimal.Field<defaultMarkupPct> { }
    #endregion

    #region IntegrationEndpoint
    [PXDBString(255, IsUnicode = true)]
    [PXUIField(DisplayName = "Integration Endpoint URL")]
    public virtual string IntegrationEndpoint { get; set; }
    public abstract class integrationEndpoint : PX.Data.BQL.BqlString.Field<integrationEndpoint> { }
    #endregion
}

public PXSetup<CSCustomSetup> Setup;

CompanyID is what actually makes this safe on multi-tenant instances

The detail that's easy to skip and expensive to discover later: on a multi-tenant Acumatica instance (multiple companies sharing the same database), a setup table without a proper CompanyID field scoped by tenant isolation doesn't give you one row per company, it gives you one row for the entire database, silently shared across every tenant. PXDBDefault(typeof(AccessInfo.companyID)) combined with the platform's standard tenant-isolation behavior on the field is what makes "exactly one row" actually mean "exactly one row per company" rather than "exactly one row, period." I've seen a client's custom markup setting bleed across two of their own subsidiary companies because this attribute was missed on the initial build - nobody noticed until Company B's invoices started showing Company A's markup percentage.

Always verify with two real companies, not just Company A alone

A setup DAC without a working CompanyID column tests as completely correct on a single-company sandbox, because there's nothing to isolate it from. The bug only shows up once a second company exists and both save different values into what turns out to be the same physical row. If your instance is multi-company at all, or ever might be, test setup screens against two companies before calling the feature done.

Reading setup values from anywhere in a graph

Because PXSetup guarantees the row exists, reading a setting is a direct property access on Setup.Current without the null-checking dance you'd otherwise need - though I still guard it defensively in code that might run before the graph's views have initialized, such as static or early-lifecycle code paths:

C# · Reading setup from a FieldUpdated handler
protected virtual void _(Events.FieldUpdated<SOLine.curyUnitCost> e)
{
    SOLine row = (SOLine)e.Row;
    if (row == null) return;

    decimal markupPct = Setup.Current?.DefaultMarkupPct ?? 0m;
    row.CuryUnitPrice = decimal.Round(
        (row.CuryUnitCost ?? 0m) * (1 + markupPct / 100m), 2, MidpointRounding.AwayFromZero);
}

The setup screen itself is just PXSetup bound to a form, no grid

The screen definition for a setup page is deliberately simple compared to a transactional screen - a single form bound to the one-row view, no grid, because there's exactly one record to ever show. The graph backing it needs nothing beyond the PXSetup view itself unless you're adding cross-field validation (confirming an integration URL is well-formed, for instance) via the same RowPersisting pattern used everywhere else in the framework.

Setup values are re-read per graph instance, not cached process-wide

A common performance assumption that's wrong: developers sometimes assume setup values, being "basically constants," are cached at the application level and free to read anywhere. They're not automatically - each new PXSetup view instance in each new graph instance triggers its own read (itself normally cache-fast via the platform's standard row caching, but not free of graph-instantiation overhead). For a value read constantly in a hot path - inside a loop processing thousands of rows, for example - read it once into a local variable at the top of the loop rather than dereferencing Setup.Current repeatedly inside it.

Wrapping up

PXSetup<T> is the correct pattern for module-level configuration precisely because it guarantees a single row exists without your code having to defend against zero or many. The detail that actually matters on real instances is CompanyID-based tenant isolation - get that wrong and "one row" quietly becomes "one row shared by every company on the database," a bug that stays invisible until a second company exists to expose it.

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.