Attributes stacked on a DAC field look declarative and simple until you write your own and discover how much is actually happening under the hood: attribute order affects behavior, some attributes only make sense on a selected (bound) field versus an unbound one, and a badly-written custom attribute can silently break BQL projections it was never tested against. This is a walk through building a real custom attribute and the traps I've hit doing it.
An attribute is a PXEventSubscriberAttribute wiring itself into the cache's event pipeline
Every Acumatica field attribute - PXDBString, PXDefault, PXUIField, all of them - ultimately derives from PXEventSubscriberAttribute, and what it "does" is attach handlers to the cache's event pipeline (FieldDefaulting, FieldVerifying, FieldSelecting, RowPersisting) for the field it decorates. A custom attribute is not magic; it's a reusable bundle of the same event handlers you'd otherwise write by hand in a graph extension, packaged so you can apply it declaratively to many fields:
[PXAttributeFamily]
public class PXPercentRangeAttribute : PXEventSubscriberAttribute, IPXFieldVerifyingSubscriber
{
protected decimal _Min;
protected decimal _Max;
public PXPercentRangeAttribute(double min, double max)
{
_Min = (decimal)min;
_Max = (decimal)max;
}
public virtual void FieldVerifying(PXCache sender, PXFieldVerifyingEventArgs e)
{
if (e.NewValue == null) return;
var value = (decimal)e.NewValue;
if (value < _Min || value > _Max)
{
throw new PXSetPropertyException(
"Value must be between {0}% and {1}%.", PXErrorLevel.Error, _Min, _Max);
}
}
}
Applying it is just decorating the field, same as any built-in attribute: [PXPercentRange(0, 100)] above a decimal property. The point of writing your own instead of a one-off RowPersisting check is reuse - apply the same validation rule to a dozen fields across several DACs without copy-pasting the logic into a dozen event handlers.
Stacking order on a field genuinely changes behavior
Attributes on a property fire in the order they're declared, top to bottom, for events that multiple attributes both subscribe to. This matters concretely: a PXDefault attribute needs to run and set a value before a validation attribute that rejects nulls gets a chance to complain about a still-empty field. I've debugged a "why does this field always show a validation error on a new row" issue that came down to a validation attribute declared above the defaulting attribute instead of below it:
// Wrong order - validation attribute fires FieldVerifying before
// PXDefault has had a chance to populate the field on a new row
[PXPercentRange(0, 100)]
[PXDefault(TypeCode.Decimal, "0.0")]
public decimal? UsrDiscountPct { get; set; }
// Correct - default first, validation second
[PXDefault(TypeCode.Decimal, "0.0")]
[PXPercentRange(0, 100)]
public decimal? UsrDiscountPct { get; set; }
In practice this specific example often still works because FieldVerifying only fires on an actual value change, but the general rule holds across enough attribute combinations that I always declare defaulting and formatting attributes above validation attributes as a habit, not case by case.
Custom attributes on fields that only exist in a PXSelect projection
A field declared directly inside a PXSelect's projection - an aggregate, a calculated column pulled in via a join, not a real DAC property - can still carry attributes, but only the ones that make sense for a read-only, non-persisted value. Applying PXDBDecimal (a storage attribute expecting a real column) to a projected aggregate field is a mistake I've seen cause a schema-sync error or a silent no-op, because there's no actual column behind it for the attribute to bind storage behavior to:
// A projected, read-only aggregate - use unbound attributes only
[PXQuickJoin]
public class OpenBalanceByCustomer : PXBqlTable, IBqlTable
{
[PXDecimal(2)] // unbound decimal formatting - correct here
[PXUIField(DisplayName = "Open Balance", Enabled = false)]
public virtual decimal? OpenBalance { get; set; }
public abstract class openBalance : PX.Data.BQL.BqlDecimal.Field<openBalance> { }
}
PXDecimal (no DB prefix) versus PXDBDecimal is exactly this distinction: the former formats and rounds an in-memory value with no storage semantics, the latter declares an actual mapped column. Mixing them up on a projection field is a small but common mistake for developers who've only ever worked with real table-backed DACs.
A custom attribute needs to survive being selected, not just validated interactively
The trap that catches people writing their first real custom attribute: it works fine when a user types into the field in the UI, and then breaks - or worse, silently does nothing - the moment the field is populated via a bulk BQL update, an import scenario, or a REST API PUT, because those paths don't necessarily fire every UI-oriented event the same way manual entry does. If validation logic genuinely needs to hold regardless of entry path, put the authoritative check in RowPersisting as a backstop, and treat FieldVerifying as the fast, interactive layer on top of it, not the only layer.
Wrapping up
A custom attribute is a reusable bundle of the same cache event handlers you'd write by hand, and its behavior is genuinely sensitive to declaration order relative to other attributes on the same field. Keep storage attributes off projected, unbound fields, use their PXDecimal-style unbound counterparts instead, and never trust FieldVerifying alone to enforce something that also has to hold for bulk BQL updates and API writes - back it with a RowPersisting check when correctness actually matters.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.