# Blazor Server + EF Core

> **Scope:** blazor-azure,nextjs-supabase
> **Layer:** 1 (on domain)
> **Keywords:** efcore, entity framework, dbcontext, dbfactory, concurrency
> **Load When:** database work

Patterns for Blazor Server + EF Core concurrency and background operations.

---

## Core Problem: DbContext Concurrency

Blazor Server scoped services = circuit lifecycle (SignalR), NOT HTTP requests.
`Task.Run` or fire-and-forget share the same scoped DbContext → concurrent access violations.

**Rule:** Separate thread from HTTP request = isolated DbContext via `IDbContextFactory`.

| Scenario | Pattern |
|----------|---------|
| Regular component ops | Scoped DbContext/Repository |
| `Task.Run` / fire-and-forget | IDbContextFactory |
| Hangfire / background jobs | IDbContextFactory |
| SignalR hub handlers | IDbContextFactory |
| Hosted services | IDbContextFactory |

---

## Pattern: Repository Factory

```csharp
// Interfaces
public interface IOrderRepositoryBase
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task UpdateAsync(Order order, CancellationToken ct = default);
}

public interface IOrderRepository : IOrderRepositoryBase
{
    Task<List<Order>> GetPendingAsync(CancellationToken ct = default);
}

public interface IScopedOrderRepository : IOrderRepositoryBase, IAsyncDisposable { }

public interface IOrderRepositoryFactory
{
    IScopedOrderRepository CreateScoped();
}

// Implementation
public abstract class OrderRepositoryBase(AppDbContext context)
{
    protected readonly AppDbContext _context = context;

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await _context.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task UpdateAsync(Order order, CancellationToken ct = default)
    {
        _context.Orders.Update(order);
        await _context.SaveChangesAsync(ct);
    }
}

public class ScopedOrderRepository(AppDbContext context) : OrderRepositoryBase(context), IScopedOrderRepository
{
    public async ValueTask DisposeAsync() => await _context.DisposeAsync();
}

public class OrderRepositoryFactory(IDbContextFactory<AppDbContext> contextFactory) : IOrderRepositoryFactory
{
    public IScopedOrderRepository CreateScoped() => new ScopedOrderRepository(contextFactory.CreateDbContext());
}

// DI
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlServer(conn));
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<IOrderRepositoryFactory, OrderRepositoryFactory>();

// Usage
_ = Task.Run(async () =>
{
    await using var repo = _repoFactory.CreateScoped();
    var order = await repo.GetByIdAsync(orderId);
    await repo.UpdateAsync(order);
});
```

---

## Background Operations Decision Matrix

| Option | When | Cost | Complexity | Resilience |
|--------|------|------|------------|------------|
| Task.Run + RepositoryFactory | < 5min, low volume, non-critical | $0 | Low | Low |
| Background Service + Queue | Long, needs retry, medium volume | $0-16/mo | Medium | Medium |
| Hangfire | Scheduled/recurring, needs dashboard | $0 (OSS) | Medium | High |
| Azure Functions Durable | High volume, spikes, orchestration | Pay-per-use | Med-High | High |

### Hangfire Quick Setup

```csharp
builder.Services.AddHangfire(c => c.UseSqlServerStorage(conn));
builder.Services.AddHangfireServer();

public class OrderJob(IOrderRepositoryFactory repoFactory)
{
    [AutomaticRetry(Attempts = 3)]
    public async Task ProcessAsync(Guid id, CancellationToken ct)
    {
        await using var repo = repoFactory.CreateScoped();
        var order = await repo.GetByIdAsync(id, ct);
    }
}

BackgroundJob.Enqueue<OrderJob>(x => x.ProcessAsync(orderId, CancellationToken.None));
RecurringJob.AddOrUpdate<ReportJob>("daily", x => x.GenerateAsync(), Cron.Daily);
```

---

## EF Core Configuration

### Navigation Properties

```csharp
// WRONG - Include() silently fails
builder.HasMany<GeneratedArt>().WithOne(x => x.Order).HasForeignKey(x => x.OrderId);

// CORRECT - Specify navigation property
builder.HasMany(x => x.GeneratedArts).WithOne(x => x.Order).HasForeignKey(x => x.OrderId);
```

**Rule:** `ICollection<T>` in entity → MUST appear in `HasMany(x => x.Property)`.

### Migrations (.NET 10)

```bash
dotnet ef migrations add <Name> --project src/Infrastructure --startup-project src/Web
dotnet ef database update --project src/Infrastructure --startup-project src/Web
```

| Environment | Auto-Migration? | Why |
|-------------|----------------|-----|
| Development | OK | Fast iteration |
| Staging | Caution | Test before prod |
| Production | NO | Use CI/CD pipeline |

---

## Anti-Patterns

| Anti-Pattern | Fix |
|-------------|-----|
| Scoped DbContext in `Task.Run` | Use `IDbContextFactory` |
| Scoped service in singleton | Use factory pattern |
| Missing `await using` | Always use with scoped repo |
| `IScopedRepo` inherits full `IRepo` | Inherit only `IBase` |

---

## Checklist

- [ ] Background ops use `IDbContextFactory` / Repository Factory?
- [ ] Duration/volume assessed against decision matrix?
- [ ] `HasMany` uses navigation property lambda?
- [ ] Migration created before `database update`?
- [ ] `await using` for scoped repositories?
- [ ] `AsNoTracking()` for read-only queries?
- [ ] CancellationToken propagated?

---

*MORPH-SPEC by Polymorphism Tech*

---

## Code Review Checklist

Use this checklist during code review for EF Core in Blazor:

### DbContext and Concurrency

- [ ] **No scoped DbContext used in `Task.Run` or background operations?**
- [ ] **`IDbContextFactory<T>` used for background/parallel operations?**
- [ ] **Factory-created contexts are properly disposed (`await using`)?**
- [ ] **No shared DbContext between concurrent operations?**

### File Upload (IBrowserFile)

- [ ] **`IBrowserFile` is read only ONCE and bytes stored?**
- [ ] **`maxAllowedSize` specified in `OpenReadStream()`?**
- [ ] **File size validated BEFORE opening stream?**
- [ ] **File extension/type validated?**

---

*MORPH-SPEC by Polymorphism Tech*
