Pular para o conteúdo principal
Versão: main (dev) 🚧

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

TipoNomeResponsabilidade
NamespaceInterceptorsSave pipeline hooks for auditing, soft-delete, multi-tenancy and domain event dispatch
Sealed ClassDevPackEfCoreInterceptorBuilderFluent builder for registering interceptors as singletons
Static ClassDevPackEfCoreServiceCollectionExtensionsEntry point AddDevPackEfCoreInterceptors()
Static ClassMultiTenantModelBuilderExtensionsApplyMultiTenantQueryFilters(this ModelBuilder, IMultiTenantDbContext) — tenant + soft-delete global query filter
InterfaceIMultiTenantDbContextDbContext-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 ISaveChangesInterceptorAddDbContext itera com provider.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):

IMultiTenantISoftDeletableFiltro
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):

  • --project aponta para o projeto onde os arquivos de migration serão gerados (tipicamente Infrastructure, junto ao DbContext).
  • --startup-project aponta para o projeto que tem a composition root do DI (tipicamente Api ou Worker).

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.