Everything in Acumatica flows through DACs — Data Access Classes. Before you can write a useful graph extension, debug a field that won't save, or design an endpoint, you need to genuinely understand what a DAC is: not an ORM entity in the Entity Framework sense, but a metadata-rich contract that drives the database mapping, the UI, validation, defaulting, and security all at once. This post is the foundation I walk every developer through when they join one of my projects.
The shape of a DAC
A DAC is a C# class, usually mapping to one table, where every field is a pair: a property holding the value and an abstract class used to reference the field in BQL with compile-time safety.
[PXCacheName("Freight Quote")]
public class FreightQuote : PXBqlTable, IBqlTable
{
#region QuoteNbr
[PXDBString(15, IsKey = true, IsUnicode = true)]
[PXDefault]
[PXUIField(DisplayName = "Quote Nbr.")]
public virtual string QuoteNbr { get; set; }
public abstract class quoteNbr : PX.Data.BQL.BqlString.Field<quoteNbr> { }
#endregion
#region Amount
[PXDBDecimal(2)]
[PXDefault(TypeCode.Decimal, "0.0")]
[PXUIField(DisplayName = "Amount")]
public virtual decimal? Amount { get; set; }
public abstract class amount : PX.Data.BQL.BqlDecimal.Field<amount> { }
#endregion
}
Three things to notice. Every property is nullable (decimal?, not decimal) — the framework uses null to mean "not yet defaulted," and non-nullable value types break the insert pipeline. The abstract classes (quoteNbr, amount) are what you write in BQL: Where<FreightQuote.amount, Greater<Zero>>. And the attributes are not decoration — they are the behavior.
Attributes are the behavior
Each attribute contributes handlers into the event pipeline for that field:
PXDBString,PXDBDecimal,PXDBInt,PXDBDate— database-bound types; they map the column, enforce length/precision, and handle persistence. The non-DB variants (PXString,PXDecimal...) declare unbound, calculated fields.PXDefault— supplies the default and, importantly, enforces presence at persist time.PXDefault(PersistingCheck = PXPersistingCheck.Nothing)is how you say "default it, but don't require it."PXUIField— display name, visibility, enabled state, permission category.PXSelector— turns the field into a lookup with a defining query, and validates entered values against it.PXDBIdentity, key fields viaIsKey = true— identity and logical key definitions the cache uses to track records.
When a field "mysteriously" refuses to save, the answer is almost always in this list: a PXDefault without PersistingCheck.Nothing on a field the user never sees, or a selector rejecting a value inserted by code.
DACs live in caches
At runtime you never work with bare DAC rows; they live in a PXCache inside a graph. The cache tracks each row's status — Inserted, Updated, Deleted, Notchanged — and is why Acumatica can show unsaved changes, run validation before persist, and generate minimal SQL on save. The golden rule that follows: mutate rows through the cache (cache.Update(row), or assign inside an event handler where the row is already cache-managed), never by setting properties on a row you pulled from somewhere and expecting the framework to notice.
Referencing DACs in BQL and FBQL
The abstract field classes make queries type-safe. Modern fluent BQL reads close to SQL:
var openQuotes = SelectFrom<FreightQuote>
.Where<FreightQuote.amount.IsGreater<Zero>
.And<FreightQuote.status.IsEqual<FreightQuoteStatus.open>>>
.OrderBy<Desc<FreightQuote.amount>>
.View.Select(this);
Because the query is built from field classes, renaming a field is a compile error everywhere it's used — one of the framework's quiet strengths compared to string-based query builders.
Keys, timestamps, and audit fields
Every persistent DAC needs a key — either IsKey = true fields forming a logical key, or a PXDBIdentity plus a human-facing key. Standard system columns come from well-known attributes: PXDBTimestamp for optimistic concurrency (skip it and last-write-wins silently), PXDBCreatedByID/PXDBLastModifiedDateTime and friends for audit, and NoteID via PXNote if the record should support notes, attachments, and tasks — which also makes it addressable by the REST API's file endpoints. When I create a custom table I add all of these by default; retrofitting concurrency after users have overwritten each other's data is a bad week.
A DAC doesn't have to map one table. PXProjection DACs declare a BQL query (joins included) as their source, giving you a read-optimized, joinable "view" that still behaves as a DAC — handy for search screens and integration reads.
Wrapping up
A DAC is a contract, not a POCO: nullable properties plus abstract field classes plus attributes that collectively define storage, defaults, UI, and validation. Internalize that the attributes are executable behavior, that rows live in caches with tracked statuses, and that keys/timestamps/note IDs are non-optional plumbing on real tables — and the rest of the framework (events, extensions, BQL) starts making sense instead of feeling like folklore.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.