Acumatica Note Handler — Programmatic Patterns is the kind of topic that separates a working Acumatica customisation from a great one. Most developers learn the basic graph-and-DAC pattern in their first month and never revisit it. The ones who do — who read the framework source, run profilers, and pay attention to what happens under load — are the ones whose code is still running five years later. This guide is for them.
Why this matters in production
The default Acumatica patterns get you to "working". The patterns I describe below get you to "working at scale". I have seen every one of these decisions turn a 5-second screen render into a 200ms one, and turn a 200ms one into a 4-second one. They are not theoretical; they are the difference between a tenant that the user trusts and one they blame for being slow.
If you are reading this before you have built anything, bookmark it for later. If you are reading it after you have built something, open the relevant screen in your tenant right now and time the difference. Numbers beat words every time.
80% of Acumatica customisation performance problems come from 20% of the patterns. Almost every slow tenant I have ever looked at was slow because of one of: heavy logic in RowSelected, a missing index on a join column, or a report that fetches every row and filters in the layout. Master the 20% and you are ahead of 90% of customisation code in the wild.
The pattern in detail
Let me walk through the canonical version of the pattern, then the variations you will see in real client code. The canonical version is the one you should commit to your snippet library — it is the version that ages well, that works on the first Acumatica version your code ships against and still works after the third upgrade.
public class AcumaticaNoteHandlerProgrammaticExt : PXGraphExtension<ProdDetail>
{
public static bool IsActive() => true;
[PXOverride]
public IEnumerable<AMProdItem> GetRecords()
{
var result = Base.GetRecords();
foreach (var record in result) yield return record;
}
protected void _(Events.RowSelected<AMProdItem> e)
{
// UI-level logic only — no DB calls here
var row = e.Row;
if (row == null) return;
}
}
There are three things in this snippet that I want you to take away, because they are the difference between code that works once and code that works forever:
- The
IsActive()method is your kill switch. Returnfalseto disable the entire extension without unpublishing it. Use this when you need to turn a customisation off in production for one tenant but keep it for others. Never ship a customisation without this method. [PXOverride]overprotected void _(Events.X e)when you need to replace base behaviour. Override when you want to change what happens; events when you want to add to what happens. Mixing them up is a common cause of "the base logic ran anyway" bugs.- Yield-return on a generator method when you are transforming a base query. This is more efficient than
.ToList()on a 100k record set because the framework can stream the results instead of materialising them in memory.
Putting heavy DB calls in RowSelected is the single most common performance bug I see. RowSelected fires for every row on every render. A single web call on a 5,000-row grid fires it 5,000 times. Move the heavy work to RowPersisting or to a graph action triggered by a button.
Real-world variations
The canonical version is the textbook case. In the wild, you will see variations. Some are valid; some are bugs. The difference is in whether the variation still preserves the intent of the pattern.
| Variation | Valid? | When |
|---|---|---|
| Override + custom view | Yes | When you need the override to apply to a custom view, not the base view |
| Override inside another extension | No | Stacking overrides breaks the order guarantee; refactor instead |
| Override without calling base | Sometimes | Only when you genuinely mean to replace the entire behaviour |
| Event + manual state tracking | Yes | When the override signature does not match your needs |
For deeper coverage of the underlying graph and DAC mechanics, see the Acumatica Customization Definitive Guide and the graph extension patterns article. For performance specifically, the performance tuning guide is the broader playbook.
Data model implications
Every behavioural pattern in Acumatica eventually touches the data model. If your customisation is going to be reading and writing records, you need to think about:
- Index coverage. Every field you filter on in a BQL
Where<>clause needs an index. Check the SQL Server execution plan before you ship. The missing index is the most common cause of "the screen is fast on test data and slow on production". - Locking. Two transactions trying to update the same row wait on each other. If your customisation is a long-running operation, use
FOR UPDATE NOWAITsemantics via a try/catch on the persistence layer. - Audit trail. If the field is audit-sensitive, declare it as such on the DAC. The framework will populate the change log automatically — and you will not have to remember to do it in your handler.
- Cross-tenant portability. If you are an ISV, no hard-coded tenant or company IDs. Use
AccessInfo.BranchID, not the literal value. This is the #1 reason an ISV customisation breaks on a second customer.
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- Run your report / GI; inspect the messages tab.
-- A missing index recommendation appears at the bottom of the plan.
For more on the data side, see the SQL Server indexing guide and the DAC fundamentals article.
Testing the pattern
If you are not testing your customisation with the Acumatica Unit Test Framework, you are running blind. The framework is shipped with every installation, costs nothing, and pays for itself the first time an upgrade changes a method signature on you. The minimum you should cover:
- Every graph action that has business logic — happy path + the most common error path.
- Every DAC field that has a defaulting or validation rule.
- Every workflow transition — that the right state is reached from the right source state.
- Every import scenario with a sample CSV that exercises the validation rules.
[TestFixture]
public class AcumaticaNoteHandlerProgrammaticTests
{
[Test]
public void CustomLogic_HappyPath_ProducesExpectedResult()
{
var graph = PXGraph.CreateInstance<ProdDetail>();
var ext = graph.GetExtension<ProdDetailExt>();
var record = new AMProdItem { /* ... */ };
graph.Caches[typeof(AMProdItem)].Insert(record);
var result = ext.GetRecords().ToList();
Assert.IsNotNull(result);
}
}
For the full test framework walkthrough, see the unit test framework guide.
Common pitfalls
I have shipped this pattern to enough tenants to have a list of pitfalls that recur. Read them before you write the code; the time you save is your own.
The fastest way to debug a customisation that is mysteriously slow is to add timing logs in every event handler, run the screen, and look at the totals. Almost always one event handler is taking 90% of the time. The other nine are fine.
The pitfalls in order of how often I hit them:
- Caching the wrong thing. Acumatica caches DACs per graph instance. If you put a record in the cache and another graph instance tries to read it, you get stale data. Use
cache.Clear()orcache.ClearQueryCache()deliberately when crossing graph boundaries. - Forgetting to call base. An override that does not call the base method is a complete replacement. Sometimes that is what you want; usually it is a bug. Always explicitly call
Base.MethodName()unless you have a comment explaining why not. - Async/await mixing. Acumatica's graph execution context is synchronous. Calling
awaitinside a graph event will release the request thread, which is fine for I/O, but the data context will be in an undefined state when the continuation runs. Wrap async work in a separate component and feed the result back via a graph action. - Hard-coded values in production. Feature flags, threshold values, retry counts — all of these belong in a configuration table or a DAC, not in the code. A value you cannot change without a redeploy is a value you will have to redeploy to change.
- Missing graph extension on a child graph. If your customisation extends
ARInvoiceEntry, the same extension does not automatically apply toARInvoiceEntryExtfrom another package. Audit your dependencies before each upgrade.
For related issues specific to upgrades, see the upgrade survival guide and the cache attach deep-dive.
Scaling considerations
What works on a 50-user tenant breaks on a 500-user tenant. The pattern above is correct; what changes is the load on it. The scaling considerations fall into three buckets:
| Scale | Concern | Mitigation |
|---|---|---|
| Up to 50 users | None — the default is fine | — |
| 50–200 users | Event handler latency | Move heavy logic out of RowSelected |
| 200–1000 users | Database contention | Index everything you filter on; use read replicas if needed |
| 1000+ users | Application pool saturation | Multiple app servers behind a load balancer; see the scale article |
For deep coverage of scale, see the scaling for 1000 users guide and the Azure VM sizing guide.
Wrapping up
You now have the canonical version of the pattern, the variations you will see in the wild, the data model implications, the testing strategy, and the pitfalls. If you take one thing away: the pattern is not the code, it is the intent. The code changes with every Acumatica version. The intent — extensibility, performance, testability, upgrade safety — does not.
Apply the pattern to your next customisation, and time the screen before and after. Send me the before/after if it is dramatic — I collect these for a talk I give at the Acumatica user group in Nairobi.