ITLab.Template.DevPack.Infrastructure.EfCore
Holds EF Core-oriented infrastructure shared across products using the DevPack.
Nota de navegação: os interceptors propriamente ditos vivem em
Interceptors/README.md. Este README documenta o fluent builder de registro adicionado em 10.2.0.
Componentes
| Tipo | Nome | Responsabilidade |
|---|---|---|
| Namespace | Interceptors | Save pipeline hooks for auditing, soft-delete, multi-tenancy and domain event dispatch |
| Sealed Class | DevPackEfCoreInterceptorBuilder | Fluent builder for registering interceptors as singletons |
| Static Class | DevPackEfCoreServiceCollectionExtensions | Entry point AddDevPackEfCoreInterceptors() |
| Static Class | MultiTenantModelBuilderExtensions | ApplyMultiTenantQueryFilters(this ModelBuilder, IMultiTenantDbContext) — tenant + soft-delete global query filter |
| Interface | IMultiTenantDbContext | DbContext-level source of the current tenant for the query filter (int? CurrentTenantId) |
Como usar
Forma recomendada (10.2.0+) — fluent builder
using ITLab.Template.DevPack.Infrastructure.EfCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
services.AddDevPackEfCoreInterceptors()
.WithAuditableEntityInterceptor()
.WithSoftDeleteInterceptor()
.WithDispatchDomainEventsInterceptor()
.WithInterceptor<MyOutboxInterceptor>(); // optional, consumer-defined
services.AddDbContext<AppDbContext>((provider, options) =>
{
options.UseSqlServer(connectionString);
options.AddInterceptors(provider.GetServices<ISaveChangesInterceptor>());
});
A ordem dos With* determina a ordem de execução. Mantenha
WithAuditableEntityInterceptor() antes de WithDispatchDomainEventsInterceptor()
para que o dispatch dos domain events veja a auditoria já populada.
AddDbContext usa provider.GetServices<ISaveChangesInterceptor>() para
encaminhar todos os interceptors registrados — DevPack + customizados — sem
precisar referenciar tipos individuais.
Forma legada (continua suportada)
services.AddSingleton<AuditableEntityInterceptor>();
services.AddSingleton<SoftDeleteInterceptor>();
services.AddSingleton<DispatchDomainEventsInterceptor>();
services.AddDbContext<AppDbContext>((provider, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(
provider.GetRequiredService<AuditableEntityInterceptor>(),
provider.GetRequiredService<SoftDeleteInterceptor>(),
provider.GetRequiredService<DispatchDomainEventsInterceptor>());
});
Não foi removida em 10.2.0 — só não é mais necessária.
Registro duplo: por tipo concreto E por interface
Cada With* registra o interceptor sob dois identificadores:
services.AddSingleton<AuditableEntityInterceptor>();
services.AddSingleton<ISaveChangesInterceptor>(
sp => sp.GetRequiredService<AuditableEntityInterceptor>());
- Por tipo concreto — testes que precisam exercitar membros específicos
resolvem com
provider.GetRequiredService<AuditableEntityInterceptor>(). - Por
ISaveChangesInterceptor—AddDbContextitera comprovider.GetServices<ISaveChangesInterceptor>()para registrar todos.
A mesma instância é exposta nos dois canais (singleton).
Interceptors customizados
public sealed class OutboxInterceptor(IServiceProvider serviceProvider) : SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(...)
{
using var scope = serviceProvider.CreateScope();
var outbox = scope.ServiceProvider.GetRequiredService<IOutboxQueue>();
// ...
}
}
services.AddDevPackEfCoreInterceptors()
.WithAuditableEntityInterceptor()
.WithInterceptor<OutboxInterceptor>();
Como o builder registra o interceptor como singleton, qualquer dependência
scoped (banco, current user, etc.) deve ser resolvida via IServiceProvider +
CreateScope — mesmo padrão usado por AuditableEntityInterceptor e
SoftDeleteInterceptor. Capturar scoped diretamente vaza o escopo.
O que não fazer
- Do not register interceptors that conflict in entity state transitions without clear ordering.
- Do not capture scoped services directly in a custom interceptor — use
IServiceProvider+CreateScope. - Do not call
With*for the same interceptor twice unless you want two registrations of the same singleton instance.
Multi-tenant global query filter (10.5.0)
ApplyMultiTenantQueryFilters aplica, por reflexão sobre o model, o filtro de isolamento por tenant — combinado com soft-delete numa única lambda (o EF Core admite só um filtro não-nomeado por entidade, e cada HasQueryFilter sobrescreve o anterior):
IMultiTenant | ISoftDeletable | Filtro |
|---|---|---|
| ✓ | ✓ | e.TenantId == ctx.CurrentTenantId && !e.IsDeleted |
| ✓ | ✗ | e.TenantId == ctx.CurrentTenantId |
| ✗ | ✓ | !e.IsDeleted |
public sealed class AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUser currentUser)
: DbContext(options), IMultiTenantDbContext
{
// Source of the current tenant. The filter MUST reference a DbContext instance member.
public int? CurrentTenantId => currentUser.TenantId;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
modelBuilder.ApplyMultiTenantQueryFilters(this); // pass the context, not a delegate
}
}
Por que IMultiTenantDbContext e não Func<int?>: o EF Core só re-avalia um query filter por query quando o predicado referencia a instância do DbContext. Um valor capturado atrás de um delegate/objeto é funcletizado para uma constante assada no modelo compilado — e como o modelo é cacheado por tipo de contexto e reusado entre instâncias, o filtro congelaria no tenant corrente no momento em que o modelo foi construído. Referenciar ctx.CurrentTenantId mantém o filtro dinâmico por request (o getter tipicamente delega ao ICurrentUser scoped, que lê o AsyncLocalCurrentUserAccessor).
Não clobbera filtros pré-existentes: entidades que já têm filtro declarado (ex.: o soft-delete que ApplyDevPackIdentityConventions aplica ao usuário) são puladas. Isso também é o escape hatch: declarar um filtro explícito numa entidade IMultiTenant a remove do sweep de tenant — necessário para tabelas host/global como roles compartilhadas (BaseIdentityRole é IMultiTenant; um canon com roles globais deve declarar um filtro explícito de opt-out).
Combine com o write-side WithMultiTenantInterceptor() (ver Interceptors/README.md). Ver ADR-006.
EF migrations CLI
O comando dotnet ef migrations add ... precisa de dois parâmetros explícitos quando o DbContext e o entry-point estão em projetos separados (caso típico do template):
--projectaponta para o projeto onde os arquivos de migration serão gerados (tipicamenteInfrastructure, junto aoDbContext).--startup-projectaponta para o projeto que tem a composition root do DI (tipicamenteApiouWorker).
Exemplo:
dotnet ef migrations add InitialCreate \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
Para a receita completa alinhada com o layout do ITLab.Template.Next, ver CLAUDE.md § EF Core commands do template.
Extensão pelo produto
- Products can add additional interceptors (outbox, telemetry, custom audit) using
WithInterceptor<T>(). - Products can also keep the legacy registration pattern; the builder is opt-in.