
“In my latest projects working with Hangfire, I keep running into the issue of sharing the same HTTP context data between the API itself and any background jobs. Hangfire runs its own environment separate from HTTP requests, so I needed a strategy to unify these contexts.”
In a previous blog post, I discussed configuring Hangfire’s dashboard, secure access, and job prioritization in a .NET 8 API. But there was another challenge I ran into: how can I share information such as a CategoryId from the HTTP request context and use it within a Hangfire job when there is no HttpContext?
Below, I will walk through the solution that worked for me, step by step. We’ll be looking at how to set up a shared context approach in .NET 8, so our background jobs can pick up necessary data (like a Category to clean up) just as if it were part of the HTTP request context.
The Core Idea
- HTTP Context: When a request comes in, we read the
CategoryIdheader. - Hangfire Context: When Hangfire runs a background job, there is no
HttpContext. Instead, we provide theCategoryIdmanually.
We need a single entry point that any service can call to figure out which Category is in scope, no matter if it’s running in an HTTP request or in a Hangfire job. This is why we have the following interfaces and classes:
IContextAccessor: A simple interface that can “GetCategory()”.IContextDataProvider: Inherits fromIContextAccessor, but also has a methodSetCategoryId(int categoryId)to configure the ID for a job.
We then create two implementations:
ContextAccessor(an HTTP-based approach).ContextDataProvider(used by Hangfire jobs).
Whichever is appropriate gets resolved dynamically when needed.
Step by Step
Below is the entire code we’ll walk through. Each section highlights a different role in the solution.
1. Interfaces
public interface IContextAccessor
{
Category GetCategory();
}
public interface IContextDataProvider : IContextAccessor
{
void SetCategoryId(int categoryId);
}
IContextAccessor: The core interface for retrieving the currentCategory.IContextDataProvider: ExtendsIContextAccessorwith aSetCategoryId()method, which we use in Hangfire jobs to manually set the Category context.
2. The ContextAccessor for HTTP
public class ContextAccessor(IHttpContextAccessor httpContextAccessor, NotesDbContext dbContext)
: IContextAccessor
{
private Category? category;
public Category GetCategory()
{
if (this.category is not null) return this.category;
if (!Int32.TryParse(
(string?)httpContextAccessor.HttpContext?.Request.Headers["CategoryId"],
out var categoryId))
throw new Exception("Category not found");
var category = dbContext.Categories.FirstOrDefault(c => c.Id == categoryId);
this.category = category ?? throw new Exception("Category not found");
return this.category;
}
}
- Uses the actual
HttpContextto readCategoryIdfrom the header. - Loads the corresponding
CategoryfromNotesDbContext. - Caches the result in a private field so multiple calls within the same request do not re-query the database.
3. The ContextDataProvider for Hangfire
public class ContextDataProvider(NotesDbContext dbContext) : IContextDataProvider
{
private int? categoryId;
private Category? category;
public Category GetCategory()
{
if (this.category is not null) return this.category;
if (this.categoryId is null) throw new Exception("Category not found");
var category = dbContext.Categories.FirstOrDefault(c => c.Id == this.categoryId);
this.category = category ?? throw new Exception("Category not found");
return this.category;
}
public void SetCategoryId(int categoryId)
{
this.categoryId = categoryId;
}
}
- In a Hangfire context, we do not have
HttpContextor request headers. - Instead, our Hangfire job explicitly calls
SetCategoryId(). GetCategory()then loads the relevant category from the database using the ID we set.
4. Entity Classes
We have two simple entity classes to store data:
public class Category
{
protected Category()
{
}
private Category(int id, string name, Note[] notes)
{
this.Id = id;
this.Name = name;
this.Notes = notes;
}
public static Category Create(int id, string name, Note[] notes)
{
return new Category(id, name, notes);
}
public int Id { get; private set; }
public string Name { get; private set; }
public IEnumerable<Note> Notes { get; private set; }
}
public class Note
{
protected Note()
{
}
private Note(int id, string name, int categoryId)
{
this.Id = id;
this.Name = name;
this.CategoryId = categoryId;
}
public static Note Create(int id, string name, int categoryId)
{
return new Note(id, name, categoryId);
}
public int Id { get; private set; }
public string Name { get; private set; }
public int CategoryId { get; private set; }
public bool IsCompleted { get; private set; }
}
5. The EF Core NotesDbContext
public class NotesDbContext(DbContextOptions<NotesDbContext> options) : DbContext(options)
{
public DbSet<Note> Notes { get; set; }
public DbSet<Category> Categories { get; set; }
}
- A simple in-memory context that stores
NotesandCategories. - In a real project, you’d point to a proper SQL or other data source.
6. The NotesService (Core App Logic)
public class NotesService(
NotesDbContext dbContext,
IContextAccessor contextAccessor)
{
public List<Note> GetCompletedNotes()
{
return dbContext.Notes
.Where(n => n.CategoryId == contextAccessor.GetCategory().Id && n.IsCompleted).ToList();
}
public void CleanupCompletedNotes()
{
var completedNotes = this.GetCompletedNotes();
dbContext.Notes.RemoveRange(completedNotes);
dbContext.SaveChanges();
}
public Category? GetCategory(int categoryId)
{
return dbContext.Categories.FirstOrDefault(c => c.Id == categoryId);
}
}
- Depends on
IContextAccessorto figure out the current category. GetCompletedNotes()filters notes by that category and returns only completed ones.CleanupCompletedNotes()removes these completed notes from the database.
7. The NotesCleanupService for Hangfire
public class NotesCleanupService(
IContextDataProvider contextDataProvider,
NotesService notesService)
{
[AutomaticRetry(Attempts = 0)]
public void CleanupCompletedNotes(int categoryId)
{
contextDataProvider.SetCategoryId(categoryId);
notesService.CleanupCompletedNotes();
}
}
NotesCleanupServiceis the job class that Hangfire calls.SetCategoryId()is explicitly called. This sets the ID in theContextDataProvider.- Then it invokes
notesService.CleanupCompletedNotes(). ThenotesServicewill rely oncontextDataProvider.GetCategory()to retrieve the category.
8. The NotesController
[ApiController]
[Route("api/[controller]")]
public class NotesController(NotesService notesService) : ControllerBase
{
[HttpGet]
public ActionResult<IEnumerable<Note>> GetNotes([FromHeader] int categoryId)
{
return notesService.GetCompletedNotes();
}
[HttpPost("run-cleanup-task")]
public IActionResult RunCleanupTask([FromHeader] int categoryId)
{
BackgroundJob.Enqueue<NotesCleanupService>(
service => service.CleanupCompletedNotes(categoryId));
return this.Ok("Cleanup task triggered");
}
}
- In
GetNotes, we’re just returning the completed notes for a given category. - In
RunCleanupTask, we create a Hangfire background job to remove completed notes from that category.
Final Setup in Program.cs (or Startup)
In the ConfigureServices method, you must register the services properly so the correct context accessor is resolved depending on whether we are in an HTTP request or Hangfire:
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpContextAccessor()
.AddDbContext<NotesDbContext>(options => options.UseInMemoryDatabase("NotesDb"))
.AddScoped<NotesCleanupService>()
.AddScoped<NotesService>()
.AddScoped<IDataSeedProvider, DataSeedProvider>()
.AddHangfire(config => config.UseInMemoryStorage())
.AddHangfireServer(options => { options.Queues = ["default"]; })
.AddSwaggerGen()
.AddControllers();
services.AddScoped<ContextDataProvider>();
// It's registered because it's used to set the context data in the Hangfire job
services.AddScoped<IContextDataProvider>(
x => x.GetRequiredService<ContextDataProvider>());
// It's required to be resolved when IContextAccessor is requested
services.AddScoped<ContextAccessor>();
services.AddScoped<IContextAccessor>(
sp =>
{
var httpContext = sp.GetService<IHttpContextAccessor>()?.HttpContext;
if (httpContext != null)
return sp.GetRequiredService<ContextAccessor>();
return sp.GetRequiredService<IContextDataProvider>();
});
}
Why This Works
- HTTP Request: When an HTTP request arrives,
IHttpContextAccessoris not null. We getContextAccessor. That reads the headerCategoryIdand loads the category from the database. - Hangfire Job: When Hangfire calls a job, there is no
HttpContext. So the logic picksIContextDataProvider(ContextDataProvider) instead. In the job, we manually pass in the category ID, thenContextDataProviderloads the category.
We avoid circular references (like trying to read NotesService while it also depends on IContextAccessor) by keeping each piece minimal and not injecting classes that directly call the same interface in their constructor.
Common Pitfalls and Lessons Learned
- Circular Dependency: If your
ContextAccessortries to inject a service that already depends onIContextAccessor, you get a never-ending resolution loop. Simplify your classes so theAccessoronly stores/retrieves the basic category info. - Hangfire vs HTTP: Hangfire does not have request headers or a
HttpContext. If you try to readHttpContextin a background job, you’ll always get null. This leads to the approach of explicitly setting theCategoryIdin your job class (e.g.,NotesCleanupService). - Dependency Injection: Use a single place (
Program.csorStartup.cs) to configure these services. That helps keep track of what gets resolved in each scenario.
An Alternative Using IServerFilter in Hangfire
Another route we can explore is leveraging Hangfire’s IServerFilter to signal when the job is being performed. Filters allow you to hook into the Hangfire pipeline, similar to how ASP.NET Core filters work, but specifically for Hangfire’s background processing.
IServerFilter exposes methods such as OnPerforming and OnPerformed, which are triggered before and after a background job executes. In these hooks, you can set a contextual flag indicating you’re in a Hangfire job, then reset it once the job completes.
Here and idea about how we can manage the IServerFilter:
public class HangfireContextFilter : IServerFilter
{
public void OnPerforming(PerformingContext context)
{
HangfireContext.IsHangfireContext = true;
}
public void OnPerformed(PerformedContext context)
{
HangfireContext.IsHangfireContext = false;
}
}
public static class HangfireContext
{
private static readonly AsyncLocal<bool> IsHangfireContextValue = new AsyncLocal<bool>();
public static bool IsHangfireContext
{
get => IsHangfireContextValue.Value;
set => IsHangfireContextValue.Value = value;
}
}
HangfireContext.IsHangfireContext is simply a flag. When a job starts, OnPerforming is called and the property is set to true. Upon completion (OnPerformed), it flips back to false.
Conclusion
Sharing context between HTTP and Hangfire in a .NET 8 API can seem tricky, but with a simple pattern of:
IContextAccessorinterface for reading the context.IContextDataProviderinterface for Hangfire jobs to set their own category ID.- Two small classes (
ContextAccessorandContextDataProvider), each with minimal dependencies. - A carefully registered service that decides which accessor to provide.
…you can elegantly handle any scenario that requires a shared “context.” This approach eliminates fragile code in your controllers, services, or background jobs and keeps everything neatly decoupled.
Here, in the Github repo you can find the complete sample.
Happy coding !