0
Article

Sharing State Between X++ Extensions: Static Traps, Disposable Context and SysGlobalObjectCache

Egel Pjetri • October 22, 2026 • 59 views

The ticket said: "Price validation does not run for some sales orders. Random. Only some users." Random bugs are never random. It took me most of an evening and a trace to find it: an extension had a public static boolean flag. A mass update action set it to true, did its work, and set it back to false on the last line. One day the work in the middle threw an exception, the last line never ran, and from that moment until the user logged off, every order that user posted skipped price validation.

In my Chain of Command article I promised to look at what happens when two extensions need to share state. This is that article. The question sounds small, but the answer decides whether your customisation behaves the same on the hundredth call as on the first.

WHY EXTENSIONS NEED TO TALK AT ALL

With overlayering gone, you cannot add a parameter to a standard method. So the classic situation looks like this: your new action calls a standard service, the standard service calls a method three levels deeper, and your wrapper on that deep method needs to know "was I called from my action, and with which option?"

The signature cannot carry that information. Something else has to. The easy choices are a static field, a global cache, or a table flag. Only some of them are safe, and the safe one is not the shortest to type.

WHAT A STATIC REALLY IS IN X++

Extension classes can declare instance state and static state. Instance state belongs to one object, so it only helps when the code that sets it and the code that reads it hold the same object. Most of the time they do not; that is the whole problem.

Static state belongs to the type, and in X++ its lifetime is tied to the user's session. That is the detail people miss. A static is not "for this call" and not "for this posting"; it lives as long as the session does. Set it once and it stays set for every later operation the same user runs, until someone resets it or the session ends.

It also does not travel. A batch task runs in its own session, so a static set in the dialog is simply not there when the batch job executes. On your development box with the batch run inline, it seems to work. In a sandbox with real batch servers, it does not.

Here is the version that caused my evening:

[ExtensionOf(classStr(OrderPostingService))]
final class OrderPostingService_Price_Extension
{
    public static boolean skipPriceCheck;

    public void postLine(OrderLine _line)
    {
        if (!skipPriceCheck)
        {
            this.validatePrice(_line);
        }
        next postLine(_line);
    }
}

And the caller set skipPriceCheck = true, ran the service, then set it back to false. Read it once and it looks fine. Read it as "what if the middle line throws?" and you see the bug.

THE TRAP, STEP BY STEP

One session, two postings: a static flag leaks after an exception, a disposable context does not

• Posting A sets the flag and starts work.

• Something inside throws: a validation error, an update conflict, a deadlock retry that gives up. The reset line is skipped.

• Posting B, minutes later, in the same session, reads the flag. It is still true. The check is skipped and nobody gets an error.

The cruel part is that the failure and the symptom are far apart. Posting A shows a normal error and the user fixes it. Posting B looks successful. When you debug posting B, the code that set the flag is not even on the call stack. The other trap: two actions that both use the same static will overwrite each other's values, and the static constructor order across extensions is not guaranteed either.

THE RIGHT WAY: A DISPOSABLE CONTEXT

The fix I use everywhere now is a small context class that implements System.IDisposable. It still uses one private static to make itself findable, but its lifetime is controlled by a using block, and dispose() runs when the block ends, whether it ends normally or with an exception.

final class PriceCheckContext implements System.IDisposable
{
    private static PriceCheckContext instance;
    public boolean skipPriceCheck;

    public static PriceCheckContext construct()
    {
        if (instance)
        {
            throw error("PriceCheckContext is already active.");
        }
        instance = new PriceCheckContext();
        return instance;
    }

    public static PriceCheckContext current() { return instance; }

    public void dispose() { instance = null; }
}

The caller opens it, and the wrapper only asks for it:

using (PriceCheckContext ctx = PriceCheckContext::construct())
{
    ctx.skipPriceCheck = true;
    service.run();
}   // dispose() runs here, also after an exception

// inside the wrapper
PriceCheckContext ctx = PriceCheckContext::current();
if (!ctx || !ctx.skipPriceCheck)
{
    this.validatePrice(_line);
}

Compare the two versions honestly. The wrong way is three lines shorter. The right way gives you three guarantees for free: the value exists only inside the block, it is cleared even when the work fails, and a second nested use throws immediately instead of quietly mixing two callers' values. That nested-use error has caught real mistakes for me during testing, long before a user saw them.

The wrapper side matters too. Notice that "no context" means "behave exactly like standard". Your extension should be invisible to every caller that did not ask for it.

WHEN THE WORK MOVES TO BATCH

A context is in-memory and per session, so it does not cross into a batch task either. The rule I follow: anything that must survive the jump into batch goes into the data contract as a DataMemberAttribute parm method, gets serialised with the task, and the service opens the context inside process() on the batch side.

• Dialog: the user ticks "skip price check", the value lands in the contract.

• Batch server: the service reads the contract, opens PriceCheckContext, runs the standard code inside the using block.

• The wrappers never know or care whether they run interactively or in batch.

Explicit parameter passing is boring, and boring is exactly what you want when the job runs at 3 a.m. on a server you cannot debug.

SYSGLOBALOBJECTCACHE: SHARED, BUT FOR DATA, NOT DECISIONS

Where extension state lives: static field, SysGlobalObjectCache, disposable context

The other tool people reach for is SysGlobalObjectCache. It is a key and value cache that lives on the server instance and is shared by all sessions there. That makes it great for one thing: read-mostly data that is expensive to compute and the same for everyone, such as a resolved parameter set or a mapping table you read on every line.

SysGlobalObjectCache cache = classFactory.globalObjectCache();
str       scope = classStr(PriceRuleCache);
container key   = [_itemGroup];
container hit   = cache.find(scope, key);

if (hit == conNull())
{
    hit = [PriceRuleCache::loadRule(_itemGroup)];
    cache.insert(scope, key, hit);
}
return conPeek(hit, 1);

Never put a per-operation flag in it. A flag in a shared cache is a static that leaks to every user on the server instead of just one. And every cache you add is an invalidation job you now own: when the underlying record changes, clear your scope, or users will see old values until the next restart. Also remember that other server instances keep their own copy.

WHAT GOES WRONG

• Sticky flags. Behaviour changes "after a while" for one user and fixes itself after logoff. Search the model for static fields in extension classes first; it is usually there.

• Works on the dev box, not in batch. A value set in the dialog is missing when the task runs. Check the batch job history and the task's contract values; if the value is not in the contract, it never left the client session.

• Nested contexts. Your action calls a standard process that triggers your action again. The context throws "already active". That is a feature; decide whether nesting is valid and design for it explicitly instead of removing the check.

• Stale cache. A parameter change takes hours to show up, or shows on one server instance and not another. Clear your cache scope from the update method of the source record, and log the cache hit rate in a trace if you are not sure it is even helping.

• Slow posting after adding a context. Usually the context is fine but the wrapper now calls current() and a database lookup per line. Trace parser will show it; move the lookup into the context once, before the loop.

WHAT I DO TOMORROW MORNING

Before merging any extension that needs to know who called it, I ask one question: how long must this value live? One operation means a disposable context. Survives into batch means the data contract. Same for every user and expensive to compute means SysGlobalObjectCache with a clear invalidation path. A bare static field is almost never the answer, and when a review finds one, I ask what resets it when an exception hits.

Next time I will look at the SysOperation framework from the developer side: designing data contracts, controllers and services so batch jobs report errors properly instead of silently stopping, and how to test them without waiting for the batch server.

The ticket said: "Price validation does not run for some sales orders. Random. Only some users." Random bugs are never random. It took me most of an evening and a trace to find it: an extension had a public static boolean flag. A mass update action set it to true, did its work, and set it back to false on the last line. One day the work in the middle threw an exception, the last line never ran, and from that moment until the user logged off, every order that user posted skipped price validation.

In my Chain of Command article I promised to look at what happens when two extensions need to share state. This is that article. The question sounds small, but the answer decides whether your customisation behaves the same on the hundredth call as on the first.

WHY EXTENSIONS NEED TO TALK AT ALL

With overlayering gone, you cannot add a parameter to a standard method. So the classic situation looks like this: your new action calls a standard service, the standard service calls a method three levels deeper, and your wrapper on that deep method needs to know "was I called from my action, and with which option?"

The signature cannot carry that information. Something else has to. The easy choices are a static field, a global cache, or a table flag. Only some of them are safe, and the safe one is not the shortest to type.

WHAT A STATIC REALLY IS IN X++

Extension classes can declare instance state and static state. Instance state belongs to one object, so it only helps when the code that sets it and the code that reads it hold the same object. Most of the time they do not; that is the whole problem.

Static state belongs to the type, and in X++ its lifetime is tied to the user's session. That is the detail people miss. A static is not "for this call" and not "for this posting"; it lives as long as the session does. Set it once and it stays set for every later operation the same user runs, until someone resets it or the session ends.

It also does not travel. A batch task runs in its own session, so a static set in the dialog is simply not there when the batch job executes. On your development box with the batch run inline, it seems to work. In a sandbox with real batch servers, it does not.

Here is the version that caused my evening:

[ExtensionOf(classStr(OrderPostingService))]
final class OrderPostingService_Price_Extension
{
    public static boolean skipPriceCheck;

    public void postLine(OrderLine _line)
    {
        if (!skipPriceCheck)
        {
            this.validatePrice(_line);
        }
        next postLine(_line);
    }
}

And the caller set skipPriceCheck = true, ran the service, then set it back to false. Read it once and it looks fine. Read it as "what if the middle line throws?" and you see the bug.

THE TRAP, STEP BY STEP

One session, two postings: a static flag leaks after an exception, a disposable context does not

• Posting A sets the flag and starts work.

• Something inside throws: a validation error, an update conflict, a deadlock retry that gives up. The reset line is skipped.

• Posting B, minutes later, in the same session, reads the flag. It is still true. The check is skipped and nobody gets an error.

The cruel part is that the failure and the symptom are far apart. Posting A shows a normal error and the user fixes it. Posting B looks successful. When you debug posting B, the code that set the flag is not even on the call stack. The other trap: two actions that both use the same static will overwrite each other's values, and the static constructor order across extensions is not guaranteed either.

THE RIGHT WAY: A DISPOSABLE CONTEXT

The fix I use everywhere now is a small context class that implements System.IDisposable. It still uses one private static to make itself findable, but its lifetime is controlled by a using block, and dispose() runs when the block ends, whether it ends normally or with an exception.

final class PriceCheckContext implements System.IDisposable
{
    private static PriceCheckContext instance;
    public boolean skipPriceCheck;

    public static PriceCheckContext construct()
    {
        if (instance)
        {
            throw error("PriceCheckContext is already active.");
        }
        instance = new PriceCheckContext();
        return instance;
    }

    public static PriceCheckContext current() { return instance; }

    public void dispose() { instance = null; }
}

The caller opens it, and the wrapper only asks for it:

using (PriceCheckContext ctx = PriceCheckContext::construct())
{
    ctx.skipPriceCheck = true;
    service.run();
}   // dispose() runs here, also after an exception

// inside the wrapper
PriceCheckContext ctx = PriceCheckContext::current();
if (!ctx || !ctx.skipPriceCheck)
{
    this.validatePrice(_line);
}

Compare the two versions honestly. The wrong way is three lines shorter. The right way gives you three guarantees for free: the value exists only inside the block, it is cleared even when the work fails, and a second nested use throws immediately instead of quietly mixing two callers' values. That nested-use error has caught real mistakes for me during testing, long before a user saw them.

The wrapper side matters too. Notice that "no context" means "behave exactly like standard". Your extension should be invisible to every caller that did not ask for it.

WHEN THE WORK MOVES TO BATCH

A context is in-memory and per session, so it does not cross into a batch task either. The rule I follow: anything that must survive the jump into batch goes into the data contract as a DataMemberAttribute parm method, gets serialised with the task, and the service opens the context inside process() on the batch side.

• Dialog: the user ticks "skip price check", the value lands in the contract.

• Batch server: the service reads the contract, opens PriceCheckContext, runs the standard code inside the using block.

• The wrappers never know or care whether they run interactively or in batch.

Explicit parameter passing is boring, and boring is exactly what you want when the job runs at 3 a.m. on a server you cannot debug.

SYSGLOBALOBJECTCACHE: SHARED, BUT FOR DATA, NOT DECISIONS

Where extension state lives: static field, SysGlobalObjectCache, disposable context

The other tool people reach for is SysGlobalObjectCache. It is a key and value cache that lives on the server instance and is shared by all sessions there. That makes it great for one thing: read-mostly data that is expensive to compute and the same for everyone, such as a resolved parameter set or a mapping table you read on every line.

SysGlobalObjectCache cache = classFactory.globalObjectCache();
str       scope = classStr(PriceRuleCache);
container key   = [_itemGroup];
container hit   = cache.find(scope, key);

if (hit == conNull())
{
    hit = [PriceRuleCache::loadRule(_itemGroup)];
    cache.insert(scope, key, hit);
}
return conPeek(hit, 1);

Never put a per-operation flag in it. A flag in a shared cache is a static that leaks to every user on the server instead of just one. And every cache you add is an invalidation job you now own: when the underlying record changes, clear your scope, or users will see old values until the next restart. Also remember that other server instances keep their own copy.

WHAT GOES WRONG

• Sticky flags. Behaviour changes "after a while" for one user and fixes itself after logoff. Search the model for static fields in extension classes first; it is usually there.

• Works on the dev box, not in batch. A value set in the dialog is missing when the task runs. Check the batch job history and the task's contract values; if the value is not in the contract, it never left the client session.

• Nested contexts. Your action calls a standard process that triggers your action again. The context throws "already active". That is a feature; decide whether nesting is valid and design for it explicitly instead of removing the check.

• Stale cache. A parameter change takes hours to show up, or shows on one server instance and not another. Clear your cache scope from the update method of the source record, and log the cache hit rate in a trace if you are not sure it is even helping.

• Slow posting after adding a context. Usually the context is fine but the wrapper now calls current() and a database lookup per line. Trace parser will show it; move the lookup into the context once, before the loop.

WHAT I DO TOMORROW MORNING

Before merging any extension that needs to know who called it, I ask one question: how long must this value live? One operation means a disposable context. Survives into batch means the data contract. Same for every user and expensive to compute means SysGlobalObjectCache with a clear invalidation path. A bare static field is almost never the answer, and when a review finds one, I ask what resets it when an exception hits.

Next time I will look at the SysOperation framework from the developer side: designing data contracts, controllers and services so batch jobs report errors properly instead of silently stopping, and how to test them without waiting for the batch server.

D365FO X++ Chain of Command Extensions Performance
0 Comments

No comments yet. Be the first to comment!

Log in to comment on this topic.
Published by
Egel Pjetri

admin

View Profile
Share Topic