Sharing HTTP and Hangfire Context in a .NET 8 API


“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

  1. HTTP Context: When a request comes in, we read the CategoryId header.
  2. Hangfire Context: When Hangfire runs a background job, there is no HttpContext. Instead, we provide the CategoryId manually.

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 from IContextAccessor, but also has a method SetCategoryId(int categoryId) to configure the ID for a job.

We then create two implementations:

  1. ContextAccessor (an HTTP-based approach).
  2. 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 current Category.
  • IContextDataProvider: Extends IContextAccessor with a SetCategoryId() 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 HttpContext to read CategoryId from the header.
  • Loads the corresponding Category from NotesDbContext.
  • 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 HttpContext or 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 Notes and Categories.
  • 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 IContextAccessor to 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();
}
}
  • NotesCleanupService is the job class that Hangfire calls.
  • SetCategoryId() is explicitly called. This sets the ID in the ContextDataProvider.
  • Then it invokes notesService.CleanupCompletedNotes(). The notesService will rely on contextDataProvider.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, IHttpContextAccessor is not null. We get ContextAccessor. That reads the header CategoryId and loads the category from the database.
  • Hangfire Job: When Hangfire calls a job, there is no HttpContext. So the logic picks IContextDataProvider (ContextDataProvider) instead. In the job, we manually pass in the category ID, then ContextDataProvider loads 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

  1. Circular Dependency: If your ContextAccessor tries to inject a service that already depends on IContextAccessor, you get a never-ending resolution loop. Simplify your classes so the Accessor only stores/retrieves the basic category info.
  2. Hangfire vs HTTP: Hangfire does not have request headers or a HttpContext. If you try to read HttpContext in a background job, you’ll always get null. This leads to the approach of explicitly setting the CategoryId in your job class (e.g., NotesCleanupService).
  3. Dependency Injection: Use a single place (Program.cs or Startup.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:

  1. IContextAccessor interface for reading the context.
  2. IContextDataProvider interface for Hangfire jobs to set their own category ID.
  3. Two small classes (ContextAccessor and ContextDataProvider), each with minimal dependencies.
  4. 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 !

Deja un comentario

Este sitio utiliza Akismet para reducir el spam. Conoce cómo se procesan los datos de tus comentarios.