DEV Community

Cover image for Sitecore.Context.Database Is Null: The Root Cause, and the Fix I've Run in Production for Years
Mario Alberto Arce
Mario Alberto Arce

Posted on

Sitecore.Context.Database Is Null: The Root Cause, and the Fix I've Run in Production for Years

Sitecore.Context.Database is null. Sitecore.Context itself is null. If you've hit that exact wall on a mature Sitecore project — not a fresh install, a real one, grown over years by a large team — I already know roughly what your week looked like.

The Situation

We were deploying a mature, large client project — one of several ongoing, sizable engagements — from Development into a lower environment on our own servers, not the client's. The site came up, and then it didn't. It just sat there. Thinking. Not starting.

A deadlock, on startup.

Finding the Root Cause

The investigation took real time, because this class of bug is exactly as strange as it sounds. A colleague put it best, mid-investigation: "I know what it is, but I don't know why it happens." He'd found a spot deep in the code where Sitecore.Context.Database was null.

That "why" fell to me. In runtime, in debug time, digging through a codebase that had done what mature codebases do — grown, mutated, picked up more complexity and more hands, across developers and reviewers with different levels of experience over a long timeline.

Part of what had been introduced along the way was heavier use of async calls. With great power comes great responsibility, and async/await is exactly that kind of power — easy to reach for, easy to misuse without anyone noticing for years. Deeper in, what we found was async calls nested inside other async calls — something like Inception, for anyone who's seen the movie: calls within calls within calls, several layers deep. Combined with that pattern, what I found underneath was Sitecore.Context nullified, and with it, Sitecore.Context.Database.

I built a proof of concept on a fresh Sitecore install — a blank canvas, no legacy code to blame — and worked up from a single async call to multiple, nested, sequential async calls. The result was the same every time: Sitecore.Context null, Sitecore.Context.Database null. That answered my colleague's question.

Why This Happens

Sitecore.Context.Database and HttpContext.Current are ambient statics. In classic ASP.NET on .NET Framework, a request is not guaranteed to run start-to-finish on one physical OS thread — it can hop threads across an await, or across certain pipeline transitions. Code that assumes "the thread I'm on now is the thread that started this request" can silently pick up null, or, worse, another request's state, the moment that assumption breaks.

In plain terms: a thread is the physical worker actually executing code at a given instant. A context, in the sense that matters here, is the bag of ambient state — Sitecore.Context, HttpContext.Current — that code implicitly assumes travels alongside it. Under a fully synchronous request, one thread handles the whole job, so that assumption happens to hold. The moment await enters the picture, the guarantee disappears: execution can resume on a different physical thread, and anything that was only ever attached to "whichever thread is running right now" doesn't come along for the ride.

 Thread A                Thread B                Thread C
┌──────────┐   await   ┌──────────┐   await   ┌──────────┐
│ Context  │ ────────▶ │  null ?  │ ────────▶ │  null ?  │
└──────────┘           └──────────┘           └──────────┘
 request starts          request hops           and hops again —
 here, context           to a new thread        context is gone
 is fine
Enter fullscreen mode Exit fullscreen mode

It's tempting to reach for a plain static field, or [ThreadStatic], as a quick patch. Neither actually fixes this: a plain static is shared across every concurrent request, which is actively wrong, and [ThreadStatic] is pinned to a single physical thread — it loses its value the instant the request hops threads, which is the same failure mode we started with.

The Architecture Decision

Rewriting the async/threading model across a mature application of that size was not a realistic option. The initial estimate was months of work across multiple developers — not something we could bring to the client as a project.

So I went back to the drawing board with a narrower question: how do I keep Sitecore.Context available across the whole request, regardless of how many threads it hops across?

The answer came from three pieces of .NET Framework working together:

  • CallContext, from System.Runtime.Remoting.Messaging, which offers per-call storage — an illogical variant tied to a single physical thread, and a logical variant that flows with the logical thread of execution (the call, and everything it awaits) across physical thread hops.
  • ILogicalThreadAffinative — a marker interface, meaning it has no methods of its own; it exists purely as a signal. Normally, a value stored in CallContext stays tied to whichever physical thread is running right now — the illogical variant. But if the object being stored implements ILogicalThreadAffinative, the CLR promotes it into the logical call context instead, even though the call site still uses the plain (non-Logical-prefixed) SetData/GetData. That one interface is the entire difference between a value that survives a thread hop and one that doesn't.
  • Execution-context scoping around async/await. The logical call context rides on ExecutionContext, which the compiler-generated async state machine captures and restores at every suspension point. That gives two guarantees this whole approach depends on: a value set before an await is still visible after resuming on a different physical thread, and a value set inside a called async method doesn't leak back into the caller once that method suspends — which is what keeps two concurrent, unrelated requests from ever seeing each other's data.

One more piece worth naming, even though it plays a smaller role today: the internal store also extends MarshalByRefObject — a base class originally meant for objects that need to be called across application-domain boundaries, an older .NET Framework isolation model. Nothing here actually crosses an AppDomain boundary. It's kept for fidelity to the pattern this mechanism is ported from, and it costs nothing to leave in.

I designed a thread-safe, per-request store on top of this — internally, at the time, I called it ApiContext — that captures Sitecore.Context.Database (plus a few related objects) once per request and makes it available anywhere in the call graph, independent of how many threads that call graph touches. I also captured HttpContext.Current.Request the same way, since it degrades under the exact same conditions.

 Thread A                Thread B                Thread C
┌──────────┐   await   ┌──────────┐   await   ┌──────────┐
│ Context  │ ────────▶ │  null ?  │ ────────▶ │  null ?  │
└──────────┘           └──────────┘           └──────────┘
      │                      │                      │
      └──────────────────────┼──────────────────────┘
                              ▼
              ┌───────────────────────────────────────┐
              │ ApiContext / Axlis.Sitecore.Context    │
              │ transversal — follows the request,     │
              │ not the thread                         │
              └───────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

One deliberate design constraint: since this meant touching a large number of existing call sites, the replacement had to be close to mechanical. Sitecore.Context.Database became ApiContext.Database — same shape, minimal-invasive refactor, no redesign of the calling code required.

Note what's not in this design: no global lock. The reference pattern this mechanism descends from guarded its accessor with one. That lock protects nothing here — the data is already isolated per logical call by construction, so two unrelated requests never contend for the same slot. Adding a lock would only serialize otherwise-independent work, and a shared global lock is itself a deadlock precursor — exactly the failure category this exists to remove, not reintroduce.

The Result

The original deadlock was gone. So were the other latent thread-and-context bugs it had been quietly enabling. What we shipped was .NET-ready, thread-safe, and per-request — and it's been running that way in production, across multiple real client projects of different sizes, ever since.

Axlis.Sitecore.Context

That pattern is the direct ancestor of Axlis.Sitecore.Context, now generalized, redesigned under Axlis's own architecture, and open-sourced for the Sitecore community.

The package family:

  • Axlis.Sitecore.Context.AbstractionsAmbientContextStore<T>, the Sitecore-free, per-logical-call propagation mechanism described above. No dependency on Sitecore or System.Web.
  • Axlis.Sitecore.Context.Sitecore102 — the consumer-facing surface, compiled against Sitecore 10.2.x: Axlis.Sitecore.Context.Database, .Request, .HttpContext, plus SitecoreContextHttpModule, which captures these on BeginRequest and clears them on EndRequest.

The adoption path is intentionally the same shape as the original: replace Sitecore.Context.Database with Axlis.Sitecore.Context.Database. Replace HttpContext.Current with Axlis.Sitecore.Context.HttpContext, and Sitecore.Context.Request with Axlis.Sitecore.Context.Request. All three fall back to the real Sitecore/HttpContext.Current statics when nothing was captured — during application start-up, or on a background thread — and return null only if that fallback is also unavailable. No exceptions for the "nothing available" case.

This is a Sitecore-version-gated package by design, the same pattern already used in Axlis.Customizations. The first release targets Sitecore 10.2.x. Sitecore 10.3/10.4 lines will follow as sibling packages — join the GitHub Discussions if you want a say in what comes next.

Reflection

I'm being direct about where this comes from: I designed the original version of this years ago, for a real problem on a real client project, and it has stayed in production since — across projects of meaningfully different sizes. This isn't a pattern I'm proposing untested. It's one I'm finally allowed to hand over.

If you've hit Sitecore.Context.Database going null on your own project, I'd like to hear about it. And if this saves you the investigation I went through, that's the whole point of open-sourcing it.

Axlis - Axlis.ORM - GraphQL ORM<br>
for Sitecore

Top comments (0)