Introdução

Se você trabalha com .NET e EF Core em aplicações corporativas, já encontrou cenários onde precisa gravar, atualizar ou remover milhares de registros de uma só vez. O EF Core padrão executa uma operação SQL para cada entidade no ChangeTracker — para 10.000 registros, isso são 10.000 roundtrips ao banco. Em aplicações de médio e grande porte, isso é inaceitável.

A biblioteca EFCore.BulkExtensions resolve esse problema de forma elegante, usando operações nativas de cada banco de dados (como MERGE no SQL Server ou INSERT ... ON CONFLICT no PostgreSQL) para processar milhares de registros em uma única operação.

Neste artigo, você vai conhecer:

  • Quem mantém o projeto e a relação com o ecossistema .NET
  • O erro clássico de tracking que 90% dos desenvolvedores cometem (e culpam o EF Core)
  • Por que o EF Core se comporta assim — e por que está correto
  • Todos os 8 métodos da biblioteca com exemplos reais
  • Integração com Azure Service Bus para processamento em alto volume

Pré-requisitos: Familiaridade com C# e EF Core. Recomenda-se ter lido os artigos sobre Fluent API e mapeamento com EF Core e gargalos de banco de dados com mensageria antes de continuar.

📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.


O Projeto EFCore.BulkExtensions

História e Manutenção

O EFCore.BulkExtensions é mantido por Boris Djurdjevic (@borisdj), um engenheiro de software sênior da Sérvia especializado em acesso a dados e performance de banco de dados. O projeto foi criado em 2017 e rapidamente se tornou a biblioteca de operações bulk mais popular do ecossistema .NET.

Números do projeto (março/2026):

MétricaValor
⭐ Stars no GitHub3.700+
📦 Downloads NuGet30 milhões+
🧑‍💻 Contribuidores70+
📋 Issues resolvidas900+
🔄 Releases100+
📄 LicençaMIT

Relação com o Time do EF Core e .NET Foundation

É importante esclarecer: Boris Djurdjevic não é membro do time do Entity Framework Core da Microsoft, nem o projeto EFCore.BulkExtensions é um projeto oficial da .NET Foundation. Trata-se de um projeto independente da comunidade open-source, licenciado sob MIT.

No entanto, o projeto mantém compatibilidade rigorosa com cada versão do EF Core (3.x, 5.x, 6.x, 7.x, 8.x) e é amplamente reconhecido pela própria Microsoft em discussões sobre performance. Vários contribuidores do repositório possuem experiência profissional com SQL Server, PostgreSQL e Oracle — o que explica a qualidade do suporte multi-provider.

Provedores de Banco Suportados

ProviderEstratégia Bulk
SQL ServerSqlBulkCopy + MERGE (melhor performance)
PostgreSQLCOPY + INSERT ... ON CONFLICT
MySQLLOAD DATA + INSERT ... ON DUPLICATE KEY
SQLiteINSERT OR REPLACE
OracleVia provider adaptado

A biblioteca detecta automaticamente o provider configurado no DbContext e usa a estratégia nativa mais eficiente.


O Erro Clássico: Delete → ReInsert e o ChangeTracker

Diagrama do erro clássico de ChangeTracker: Delete → ReInsert causa InvalidOperationException, solução com ChangeTracker.Clear() ou BulkInsertOrUpdate

O Cenário

Um cenário extremamente comum em aplicações corporativas: você precisa sincronizar dados de uma fonte externa (CSV, API, outro sistema). A abordagem “intuitiva” de muitos desenvolvedores é:

  1. Buscar todos os registros existentes
  2. Remover todos
  3. Inserir os novos
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ❌ ERRADO — O clássico "delete tudo e insere de novo"
public async Task SincronizarRegistrosAsync(
    List<RegistroApp> registrosNovos,
    CancellationToken ct)
{
    // 1. Busca os existentes (ChangeTracker começa a rastrear)
    var existentes = await context.RegistrosApps.ToListAsync(ct);

    // 2. Remove todos
    context.RegistrosApps.RemoveRange(existentes);
    await context.SaveChangesAsync(ct);

    // 3. Insere os novos — 💥 BOOM!
    await context.RegistrosApps.AddRangeAsync(registrosNovos, ct);
    await context.SaveChangesAsync(ct); // InvalidOperationException!
}

O Erro

1
2
3
4
5
System.InvalidOperationException:
The instance of entity type 'RegistroApp' cannot be tracked because
another instance with the same key value for {'Id'} is already being tracked.
When attaching existing entities, ensure that only one entity instance
with a given key value is attached.

Por Que Acontece

O ChangeTracker do EF Core é um mapa de identidade em memória. Quando você faz ToListAsync(), todas as entidades retornadas são rastreadas com estado Unchanged. Ao chamar RemoveRange(), o estado muda para Deleted. Quando o SaveChangesAsync executa, as entidades são removidas do banco, mas o ChangeTracker ainda mantém referências dessas entidades com o mesmo Id.

Se algum registro novo tiver o mesmo Id de um registro que acabou de ser removido, o EF Core detecta a colisão e lança a exceção. Mesmo que o registro já tenha sido deletado do banco, a identidade em memória ainda existe no contexto.

A Solução com EF Core Puro

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ✅ CORRETO — Limpa o ChangeTracker entre as operações
public async Task SincronizarRegistrosAsync(
    List<RegistroApp> registrosNovos,
    CancellationToken ct)
{
    // 1. Remove os existentes
    var existentes = await context.RegistrosApps.ToListAsync(ct);
    context.RegistrosApps.RemoveRange(existentes);
    await context.SaveChangesAsync(ct);

    // 2. Limpa o mapa de identidade — ESSENCIAL
    context.ChangeTracker.Clear();

    // 3. Agora pode inserir sem conflito
    await context.RegistrosApps.AddRangeAsync(registrosNovos, ct);
    await context.SaveChangesAsync(ct);
}

O ChangeTracker.Clear() descarta todas as referências rastreadas, permitindo que novas entidades com os mesmos Ids sejam adicionadas sem conflito.


EF Core Não Foi Projetado Para Isso

Antes de culpar o EF Core, entenda quem está por trás da ferramenta. O time que mantém o Entity Framework Core não é composto apenas por desenvolvedores — são engenheiros de dados, arquitetos de banco de dados e especialistas com experiência direta em fornecedores como Oracle, SQL Server e PostgreSQL. Nomes como Arthur Vickers, Shay Rojansky (mantenedor do Npgsql) e Brice Lambson contribuíram diretamente para o EF Core com décadas de experiência em mecanismos de banco de dados.

Se o EF Core se comporta de determinada forma por padrão — como manter o tracking ativo e impedir que duas instâncias com o mesmo Id coexistam no mesmo contexto — não é um bug. É uma decisão arquitetural fundamentada em princípios de integridade de dados e concorrência otimista.

O Propósito do EF Core

O EF Core foi projetado para ser um ORM transacional focado em:

  • Unit of Work: agrupa mudanças relacionadas em uma transação atômica
  • Identity Map: garante que cada registro no banco tenha no máximo uma representação em memória
  • Concorrência otimista: detecta conflitos entre operações concorrentes
  • OLTP (processamento transacional): insere, atualiza e remove registros individuais ou em pequenos lotes com segurança transacional

O padrão do EF Core é rastrear entidades porque a maioria dos cenários (APIs REST, CRUD, e-commerce) opera sobre dezenas de registros por requisição, não milhares.

A Abordagem Correta Para Sincronização em Massa

Quando o cenário exige processar milhares de registros — sincronização de inventário, importação de CSV, ingestão de dados de filas — você está fora do escopo do EF Core padrão. É aqui que o EFCore.BulkExtensions entra.

Em vez de Delete + ReInsert, a abordagem correta é um UPSERT (INSERT or UPDATE):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ CORRETO — UPSERT em massa com BulkExtensions
public async Task SincronizarRegistrosAsync(
    List<RegistroApp> registrosAtuais,
    CancellationToken ct)
{
    var bulkConfig = new BulkConfig
    {
        UpdateByProperties = [
            nameof(RegistroApp.NomeProjeto),
            nameof(RegistroApp.Repositorio)
        ],
        PropertiesToExcludeOnUpdate = [
            nameof(RegistroApp.Id),
            nameof(RegistroApp.CriadoEm)
        ],
        TrackingEntities = false,
        BatchSize = 1000
    };

    // Uma única operação: insere novos, atualiza existentes
    await context.BulkInsertOrUpdateAsync(registrosAtuais, bulkConfig, cancellationToken: ct);
}

Essa abordagem:

  • Não precisa buscar os registros existentes primeiro
  • Não acessa o ChangeTracker
  • Executa um MERGE nativo no SQL Server (ou INSERT ... ON CONFLICT no PostgreSQL)
  • Processa 10.000 registros em ~2 segundos (vs. ~100 segundos com EF Core puro)

Se você precisa além do UPSERT — remover registros que não existem mais na fonte — use BulkInsertOrUpdateOrDeleteAsync (seção 5 abaixo).


Instalação e Configuração

Instalação via NuGet

1
dotnet add package EFCore.BulkExtensions

A biblioteca detecta automaticamente o provider do EF Core configurado. Para SQL Server, nenhuma configuração adicional é necessária. Para PostgreSQL, certifique-se de que o Npgsql está instalado.

1
2
3
<!-- .csproj — Pacotes necessários -->
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.13" />
<PackageReference Include="EFCore.BulkExtensions" Version="8.1.3" />

Compatibilidade

EF CoreBulkExtensions.NET
8.x8.x.NET 8+
7.x7.x.NET 7+
6.x6.x.NET 6+

Dica: Use sempre a mesma major version do EFCore.BulkExtensions que seu EF Core. Exemplo: EF Core 8.0.13 → BulkExtensions 8.1.3.

Namespace

1
using EFCore.BulkExtensions;

Todos os métodos são extension methods do DbContext — não é necessário herdar de nenhuma classe especial.


Todos os Métodos com Exemplos Reais

Comparação de performance: EF Core padrão (100s com 10.000 INSERTs individuais) vs BulkExtensions (2s com BatchSize=1000)

A seguir, os 8 métodos principais da biblioteca com exemplos reais usando as entidades RegistroApp, ClienteApp e ConfiguracaoSegura do repositório blog-zocateli-sample.

1. BulkInsertAsync — INSERT em Massa

Insere milhares de registros usando SqlBulkCopy (SQL Server) ou COPY (PostgreSQL). Zero interação com o ChangeTracker.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public async Task<int> InserirEmMassaAsync(
    List<RegistroApp> registros,
    CancellationToken ct = default)
{
    if (registros.Count == 0)
        return 0;

    var bulkConfig = new BulkConfig
    {
        BatchSize = 1000,
        SetOutputIdentity = true,   // Retorna IDs gerados pelo banco
        PreserveInsertOrder = true  // Mantém a ordem da lista original
    };

    await dbContext.BulkInsertAsync(registros, bulkConfig, cancellationToken: ct);
    return registros.Count;
}

Quando usar: Carga inicial de dados, importação de CSV, dados obtidos em um banco diferente, ingestão de eventos.


2. BulkUpdateAsync — UPDATE em Massa

Atualiza registros existentes por chave primária. Você pode escolher quais propriedades atualizar.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public async Task<int> AtualizarEmMassaAsync(
    List<RegistroApp> registros,
    CancellationToken ct = default)
{
    if (registros.Count == 0)
        return 0;

    var bulkConfig = new BulkConfig
    {
        BatchSize = 1000,
        PropertiesToIncludeOnUpdate =
        [
            nameof(RegistroApp.Linguagem),
            nameof(RegistroApp.VersaoFramework),
            nameof(RegistroApp.Legado),
            nameof(RegistroApp.Desativado),
            nameof(RegistroApp.AtualizadoEm)
        ],
        TrackingEntities = false
    };

    await dbContext.BulkUpdateAsync(registros, bulkConfig, cancellationToken: ct);
    return registros.Count;
}

Quando usar: Atualizar status em lote, flags de processamento, campos calculados.


3. BulkDeleteAsync — DELETE em Massa

Remove registros por chave primária sem carregar as entidades primeiro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public async Task<int> RemoverEmMassaAsync(
    List<RegistroApp> registros,
    CancellationToken ct = default)
{
    if (registros.Count == 0)
        return 0;

    await dbContext.BulkDeleteAsync(registros, cancellationToken: ct);
    return registros.Count;
}

Quando usar: Exclusão periódica de dados expirados, limpeza de registros órfãos, purge de dados antigos.


4. BulkInsertOrUpdateAsync — UPSERT (MERGE)

O método mais poderoso da biblioteca. Executa um MERGE no SQL Server — insere registros novos e atualiza existentes em uma única operação.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public async Task<int> InserirOuAtualizarAsync(
    List<RegistroApp> registros,
    IReadOnlyCollection<string> propriedadesExcluirNoUpdate,
    CancellationToken ct = default)
{
    if (registros.Count == 0)
        return 0;

    var bulkConfig = new BulkConfig
    {
        SetOutputIdentity = true,
        PreserveInsertOrder = true,
        UpdateByProperties =
        [
            nameof(RegistroApp.NomeProjeto),
            nameof(RegistroApp.Repositorio)
        ],
        PropertiesToExcludeOnUpdate = [.. propriedadesExcluirNoUpdate],
        SqlBulkCopyOptions = EFCore.BulkExtensions.SqlBulkCopyOptions.Default,
        TrackingEntities = false,
        BatchSize = 1000
    };

    await dbContext.BulkInsertOrUpdateAsync(registros, bulkConfig, cancellationToken: ct);
    return registros.Count;
}

Quando usar: Sincronização com fonte externa (API, CSV, Azure DevOps, Dados de outro banco), ingestão de mensagens de filas.


5. BulkInsertOrUpdateOrDeleteAsync — Sincronização Completa

Faz tudo que o UPSERT faz, mas também remove registros que existem no banco e não existem na lista fornecida. É a sincronização delta completa.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public async Task<int> SincronizarAsync(
    List<RegistroApp> registrosAtuais,
    CancellationToken ct = default)
{
    if (registrosAtuais.Count == 0)
        return 0;

    var bulkConfig = new BulkConfig
    {
        SetOutputIdentity = true,
        UpdateByProperties =
        [
            nameof(RegistroApp.NomeProjeto),
            nameof(RegistroApp.Repositorio)
        ],
        PropertiesToExcludeOnUpdate =
        [
            nameof(RegistroApp.Id),
            nameof(RegistroApp.CriadoEm)
        ],
        TrackingEntities = false,
        BatchSize = 1000
    };

    await dbContext.BulkInsertOrUpdateOrDeleteAsync(
        registrosAtuais, bulkConfig, cancellationToken: ct);

    return registrosAtuais.Count;
}

Quando usar: Sync total com sistema master, espelhamento de catálogos, mirror de repositórios Azure DevOps.

⚠️ Cuidado: Este método remove do banco tudo que não estiver na lista. Use com sabedoria e sempre em contexto transacional.


6. BulkSaveChangesAsync — Substituto do SaveChangesAsync

Se você já usa o padrão Add/Update/Remove com o ChangeTracker, pode simplesmente trocar SaveChangesAsync() por BulkSaveChangesAsync() para ganhar performance sem mudar a lógica.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public async Task SalvarAlteracoesBulkAsync(CancellationToken ct = default)
{
    var bulkConfig = new BulkConfig
    {
        BatchSize = 500,
        TrackingEntities = false
    };

    // Analisa o ChangeTracker e aplica operações bulk
    // para Add, Update e Delete automaticamente
    await dbContext.BulkSaveChangesAsync(bulkConfig, cancellationToken: ct);
}

Quando usar: Migração gradual de código existente — troque SaveChangesAsync por BulkSaveChangesAsync sem refatoração.


7. BulkReadAsync — Leitura em Lote por Chave

Carrega registros em massa por chave primária ou chave composta. Evita N+1 queries quando você precisa buscar entidades antes de um update em lote.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public async Task<List<RegistroApp>> LerEmMassaAsync(
    List<RegistroApp> registrosComChave,
    CancellationToken ct = default)
{
    if (registrosComChave.Count == 0)
        return [];

    var bulkConfig = new BulkConfig
    {
        UpdateByProperties =
        [
            nameof(RegistroApp.NomeProjeto),
            nameof(RegistroApp.Repositorio)
        ],
        TrackingEntities = false
    };

    // Preenche os registros com os dados do banco
    // usando as propriedades definidas como chave de busca
    await dbContext.BulkReadAsync(registrosComChave, bulkConfig, cancellationToken: ct);
    return registrosComChave;
}

Quando usar: Pré-carregar entidades para comparação antes de um update seletivo, validar existência em lote.


8. TruncateAsync — TRUNCATE TABLE

Executa TRUNCATE TABLE nativo. Diferente do DELETE sem WHERE, o truncate:

  • Não gera log de transação por registro (minimal logging)
  • Redefine identity/sequence para o valor inicial
  • É significativamente mais rápido que delete em tabelas grandes
1
2
3
4
public async Task TruncarTabelaAsync(CancellationToken ct = default)
{
    await dbContext.TruncateAsync<RegistroApp>(ct);
}

Quando usar: Limpeza de tabelas de staging, reset de dados de teste, recarga completa de cache em banco.

⚠️ Cuidado: TRUNCATE não pode ser filtrado e não dispara triggers. Use apenas em tabelas que podem ser limpas completamente.


BulkConfig: O Coração da Biblioteca

O BulkConfig é a classe de configuração que controla o comportamento de todas as operações bulk. Aqui estão as propriedades mais importantes:

PropriedadeTipoDescrição
BatchSizeintRegistros por lote SQL. Padrão: 2000. Recomendado: 500-2000.
SetOutputIdentityboolRetorna IDs gerados pelo banco (identity/sequence) nos objetos da lista.
PreserveInsertOrderboolGarante que a ordem da lista original é respeitada no INSERT.
UpdateByPropertiesList<string>Propriedades usadas como chave para Match no MERGE/UPSERT.
PropertiesToIncludeOnUpdateList<string>Whitelist: apenas estas propriedades serão atualizadas.
PropertiesToExcludeOnUpdateList<string>Blacklist: estas propriedades não serão atualizadas.
TrackingEntitiesboolSe false, não atualiza o ChangeTracker após a operação. Sempre use false para performance.
UseTempDBboolSQL Server: usa tempdb para staging. Útil quando há restrições de permissão.
SqlBulkCopyOptionsSqlBulkCopyOptionsOpções nativas do SqlBulkCopy (ex: KeepIdentity, CheckConstraints).

Exemplo de BulkConfig Completo (Cenário Real)

Este é o padrão usado em produção para sincronizar registros de inventário de aplicações — baseado em um projeto real de gestão de inventário corporativo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var bulkConfig = new BulkConfig
{
    // Performance
    BatchSize = 1000,
    TrackingEntities = false,

    // Identidade e ordem
    SetOutputIdentity = true,
    PreserveInsertOrder = true,

    // Chave composta para MERGE (qual campo identifica o registro?)
    UpdateByProperties =
    [
        nameof(RegistroApp.NomeProjeto),
        nameof(RegistroApp.Repositorio)
    ],

    // Protege campos que não devem ser sobrescritos no UPDATE
    PropertiesToExcludeOnUpdate =
    [
        nameof(RegistroApp.Id),       // Preserva o Id original
        nameof(RegistroApp.CriadoEm)  // Preserva a data de criação
    ],

    // SQL Server: opções nativas de BulkCopy
    SqlBulkCopyOptions = EFCore.BulkExtensions.SqlBulkCopyOptions.Default
};

Exemplo Real: Inventário de Aplicações

Para demonstrar o poder da biblioteca em cenário enterprise, vamos ver como um sistema de inventário corporativo usa BulkInsertOrUpdateAsync para sincronizar dados de centenas de repositórios Azure DevOps.

O Cenário

Uma empresa precisa manter um inventário atualizado de todas as suas aplicações: projetos, repositórios, linguagens, versões de framework e secrets do Azure Key Vault. Um Worker Service é executado periodicamente para:

  1. Consultar a API do Azure DevOps e listar todos os repositórios
  2. Para cada repositório, extrair metadados (linguagem, framework, etc.)
  3. Sincronizar no banco via BulkInsertOrUpdateAsync

O Repositório

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class RegistroAppRepository(
    BulkDbContext dbContext,
    ILogger<RegistroAppRepository> logger)
{
    public async Task<int> InserirOuAtualizarAsync(
        List<RegistroApp> registros,
        IReadOnlyCollection<string> propriedadesExcluirNoUpdate,
        CancellationToken ct = default)
    {
        if (registros.Count == 0)
            return 0;

        var bulkConfig = new BulkConfig
        {
            SetOutputIdentity = true,
            PreserveInsertOrder = true,
            UpdateByProperties =
            [
                nameof(RegistroApp.NomeProjeto),
                nameof(RegistroApp.Repositorio)
            ],
            PropertiesToExcludeOnUpdate = [.. propriedadesExcluirNoUpdate],
            SqlBulkCopyOptions = EFCore.BulkExtensions.SqlBulkCopyOptions.Default,
            TrackingEntities = false,
            BatchSize = 1000
        };

        await dbContext.BulkInsertOrUpdateAsync(
            registros, bulkConfig, cancellationToken: ct);

        logger.LogInformation(
            "BulkInsertOrUpdate concluído: {Count} registros processados",
            registros.Count);

        return registros.Count;
    }
}

O Mesmo Padrão para Secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ConfiguracaoSeguraRepository(
    BulkDbContext dbContext,
    ILogger<ConfiguracaoSeguraRepository> logger)
{
    public async Task<int> SincronizarSecretsAsync(
        List<ConfiguracaoSegura> secrets,
        CancellationToken ct = default)
    {
        if (secrets.Count == 0)
            return 0;

        var bulkConfig = new BulkConfig
        {
            SetOutputIdentity = true,
            PreserveInsertOrder = true,
            UpdateByProperties =
            [
                nameof(ConfiguracaoSegura.Nome),
                nameof(ConfiguracaoSegura.NomeVault)
            ],
            PropertiesToExcludeOnUpdate =
            [
                nameof(ConfiguracaoSegura.Id),
                nameof(ConfiguracaoSegura.CriadoEm)
            ],
            TrackingEntities = false,
            BatchSize = 1000
        };

        await dbContext.BulkInsertOrUpdateAsync(
            secrets, bulkConfig, cancellationToken: ct);

        logger.LogInformation(
            "Secrets sincronizados: {Count} registros", secrets.Count);

        return secrets.Count;
    }
}

Observe que o padrão é idêntico para diferentes entidades — o que muda são as propriedades de UpdateByProperties (chave composta para o MERGE) e PropertiesToExcludeOnUpdate (campos protegidos).


Integração com Azure Service Bus para Alto Volume

Quando o volume de dados é realmente alto — dezenas de milhares de registros chegando em rajada — a melhor prática é combinar mensageria com operações bulk. Conforme explorei em detalhes no artigo Gargalo em Banco de Dados: Mensageria e Paginação, a mensageria (Azure Service Bus ou RabbitMQ) serve como buffer entre o produtor de dados e o banco, permitindo que o Worker consuma em lotes otimizados.

O Padrão: Service Bus + BulkInsertOrUpdate

O fluxo é:

1
[Fonte de Dados]  →  [Azure Service Bus (fila)]  →  [Worker Service]  →  [BulkInsertOrUpdate]  →  [SQL Server]
  1. O produtor envia mensagens para a fila (uma por registro)
  2. O Worker consome até N mensagens por vez (ReceiveMessagesAsync)
  3. Converte as mensagens em entidades de domínio
  4. Persiste em massa via BulkInsertOrUpdateAsync
  5. Confirma as mensagens processadas com CompleteMessageAsync

Implementação

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public class RegistroAppBulkProcessor(
    ServiceBusClient serviceBusClient,
    IServiceScopeFactory scopeFactory,
    ILogger<RegistroAppBulkProcessor> logger) : BackgroundService
{
    private const string QueueName = "registro-app-sync";
    private const int TamanhoLote = 1000;
    private const int TimeoutSegundos = 30;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var receiver = serviceBusClient.CreateReceiver(QueueName,
            new ServiceBusReceiverOptions
            {
                ReceiveMode = ServiceBusReceiveMode.PeekLock,
                PrefetchCount = TamanhoLote
            });

        while (!ct.IsCancellationRequested)
        {
            try
            {
                await ProcessarLoteAsync(receiver, ct);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex,
                    "Erro no ciclo de processamento. Retry em 5s");
                await Task.Delay(TimeSpan.FromSeconds(5), ct);
            }
        }

        await receiver.DisposeAsync();
    }

    private async Task ProcessarLoteAsync(
        ServiceBusReceiver receiver, CancellationToken ct)
    {
        // 1. Recebe até TamanhoLote mensagens
        var mensagens = await receiver.ReceiveMessagesAsync(
            TamanhoLote,
            TimeSpan.FromSeconds(TimeoutSegundos), ct);

        if (mensagens.Count == 0)
            return;

        // 2. Converte mensagens → entidades de domínio
        var registros = new List<RegistroApp>(mensagens.Count);
        var mensagensValidas = new List<ServiceBusReceivedMessage>();

        foreach (var msg in mensagens)
        {
            var dto = JsonSerializer.Deserialize<RegistroAppMessage>(msg.Body);
            if (dto is null)
            {
                await receiver.DeadLetterMessageAsync(msg,
                    "InvalidBody",
                    "Corpo da mensagem inválido", ct);
                continue;
            }

            registros.Add(new RegistroApp
            {
                NomeProjeto     = dto.NomeProjeto,
                Repositorio     = dto.Repositorio,
                Linguagem       = dto.Linguagem,
                Plataforma      = dto.Plataforma,
                TipoAplicacao   = dto.TipoAplicacao,
                VersaoFramework = dto.VersaoFramework,
                Legado          = dto.Legado
            });

            mensagensValidas.Add(msg);
        }

        // 3. Persiste em massa via BulkInsertOrUpdate
        using var scope = scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider
                            .GetRequiredService<BulkDbContext>();

        var bulkConfig = new BulkConfig
        {
            SetOutputIdentity = true,
            PreserveInsertOrder = true,
            UpdateByProperties =
            [
                nameof(RegistroApp.NomeProjeto),
                nameof(RegistroApp.Repositorio)
            ],
            PropertiesToExcludeOnUpdate =
            [
                nameof(RegistroApp.Id),
                nameof(RegistroApp.CriadoEm)
            ],
            TrackingEntities = false,
            BatchSize = 1000
        };

        await dbContext.BulkInsertOrUpdateAsync(
            registros, bulkConfig, cancellationToken: ct);

        // 4. Confirma todas as mensagens processadas
        var completeTasks = mensagensValidas
            .Select(msg => receiver.CompleteMessageAsync(msg, ct));
        await Task.WhenAll(completeTasks);

        logger.LogInformation(
            "Lote processado: {Count} registros persistidos", registros.Count);
    }
}

Por Que Funciona em Alto Volume

Sem BulkExtensionsCom BulkExtensions
10.000 INSERT individuais1 BulkInsertOrUpdate com BatchSize = 1000
~100s (10ms × 10.000)~2-3s
10.000 transações10 transações (10 batches de 1.000)
ChangeTracker com 10.000 entidadesZero ChangeTracker

Para se aprofundar em Workers e Background Services para alto volume, leia o artigo .NET Worker e Background Service: Alto Volume.


Quando Usar (e Quando Não Usar) BulkExtensions

✅ Use Quando

  • Carga inicial de dados (seed, migração, ETL)
  • Sincronização periódica com API externa (Azure DevOps, SAP, Outros Bancos, etc.)
  • Processamento de filas (Azure Service Bus, RabbitMQ) com alto volume
  • Importação de CSV/Excel com milhares de linhas
  • Atualização de status em lote (flags, campos calculados)
  • Limpeza periódica de dados expirados

❌ Não Use Quando

  • CRUD simples de poucos registros por requisição (o EF Core padrão é suficiente)
  • Operações que precisam de validação individual por registro com regras de negócio complexas
  • Cenários onde o ChangeTracker é necessário para auditoria ou eventos de domínio
  • Tabelas com triggers complexos que precisam ser disparados por registro

Alternativas Nativas do EF Core 8+

O EF Core 8 introduziu ExecuteUpdate e ExecuteDelete que executam diretamente no banco sem passar pelo ChangeTracker:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// EF Core 8+ nativo — atualiza sem carregar entidades
await context.RegistrosApps
    .Where(r => r.Legado)
    .ExecuteUpdateAsync(s => s
        .SetProperty(r => r.Desativado, true)
        .SetProperty(r => r.AtualizadoEm, DateTime.UtcNow));

// EF Core 8+ nativo — deleta sem carregar entidades
await context.RegistrosApps
    .Where(r => r.Desativado && r.AtualizadoEm < limiteData)
    .ExecuteDeleteAsync();

Essas alternativas são boas para updates/deletes condicionais, mas não substituem BulkInsertAsync, BulkInsertOrUpdateAsync nem BulkReadAsync — operações que o EF Core nativo não oferece.


Conclusão

O EFCore.BulkExtensions é, sem dúvida, uma das bibliotecas mais importantes do ecossistema .NET. Com mais de 30 milhões de downloads e uma API consistente, ela preenche uma lacuna real do EF Core: operações em massa com performance nativa de banco.

Antes de culpar o EF Core por erros de tracking ou performance, entenda o propósito da ferramenta — ela foi construída por engenheiros de dados que conhecem profundamente SQL Server, PostgreSQL e Oracle. Se o comportamento padrão parece “errado”, provavelmente é o cenário que está fora do escopo.

Pontos-chave:

  1. O erro de tracking (delete + reinsert) é do desenvolvedor, não do EF Core — use ChangeTracker.Clear() ou, melhor, BulkInsertOrUpdateAsync
  2. TrackingEntities = false deve ser o padrão em operações bulk
  3. UpdateByProperties define a chave lógica do MERGE — não precisa ser a PK
  4. PropertiesToExcludeOnUpdate protege campos como Id e CriadoEm de serem sobrescritos
  5. Combinado com Azure Service Bus, o padrão de consumo em lote + persist bulk é imbatível para alto volume

📦 Código-fonte: blog-zocateli-sample — DataAccess/BulkOperations