Skip to main content

Bulkhead Pattern com .NET | Padrões de Resiliência | Arquitetura

  • Bulkhead Pattern com .NET | Padrões de Resiliência | Arquitetura

O que é Bulkhead Pattern

Bulkhead é um padrão de resiliência usado para isolar recursos dentro de uma aplicação.

A ideia é impedir que uma falha, lentidão ou sobrecarga em uma parte do sistema consuma todos os recursos disponíveis e afete o restante da aplicação.

Em português, pode ser entendido como:

Compartimentalização de recursos para limitar o impacto de uma falha.

O nome vem da engenharia naval. Navios possuem compartimentos internos que impedem que a água invada toda a embarcação caso uma parte seja danificada.

No software, o raciocínio é o mesmo:

Sem Bulkhead:
Uma falha pode afetar tudo.

Com Bulkhead:
A falha fica limitada a um compartimento.

Problema que o Bulkhead resolve

Imagine uma API em .NET que executa três tipos de operação:

  • Consulta fiscal
  • Envio de e-mail
  • Processamento de pagamento

Sem isolamento, todas essas operações podem usar os mesmos recursos:

API
└── Recursos compartilhados
├── Threads
├── Conexões HTTP
├── Conexões com banco
└── Memória

Se a consulta fiscal começar a ficar lenta, ela pode prender muitas threads, abrir muitas conexões e degradar o sistema inteiro.

O Bulkhead evita isso criando limites por área:

API
├── Bulkhead Fiscal: máximo 10 operações simultâneas
├── Bulkhead E-mail: máximo 20 operações simultâneas
└── Bulkhead Pagamento: máximo 50 operações simultâneas

Assim, se a parte fiscal travar, apenas o compartimento fiscal sofre impacto.


Objetivo do Bulkhead

O objetivo principal é:

Impedir que um fluxo problemático consuma recursos que pertencem a outros fluxos.

Ele ajuda a melhorar:

CaracterísticaComo ajuda
ResiliênciaA falha fica isolada
DisponibilidadeOutras partes continuam funcionando
Controle de cargaCada fluxo tem seu próprio limite
PrevisibilidadeO sistema degrada de forma controlada
Proteção contra efeito dominóUma dependência lenta não derruba tudo

Onde aplicar Bulkhead

Bulkhead pode ser aplicado em vários níveis.

NívelExemplo
CódigoSemaphoreSlim, RateLimiter, filas internas
HTTPLimite de conexões por destino
Banco de dadosPools separados por tipo de carga
MensageriaFilas separadas por prioridade
WorkersBackground services separados
InfraestruturaPods, containers ou serviços isolados
GatewayRate limit e limite por rota/cliente

Bulkhead com SemaphoreSlim

Uma forma simples de aplicar Bulkhead no .NET é usando SemaphoreSlim.

Exemplo: limitar chamadas simultâneas para uma API fiscal.

public sealed class FiscalClient
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _bulkhead = new(
initialCount: 10,
maxCount: 10);

public FiscalClient(HttpClient httpClient)
{
_httpClient = httpClient;
}

public async Task<string> ConsultarAsync(CancellationToken ct)
{
bool entrouNoBulkhead = await _bulkhead.WaitAsync(
timeout: TimeSpan.FromSeconds(1),
cancellationToken: ct);

if (!entrouNoBulkhead)
{
throw new InvalidOperationException(
"Bulkhead fiscal cheio. A operação foi rejeitada.");
}

try
{
return await _httpClient.GetStringAsync("/consulta", ct);
}
finally
{
_bulkhead.Release();
}
}
}

Nesse exemplo:

  • No máximo 10 chamadas fiscais rodam ao mesmo tempo.
  • Se o limite estiver cheio, a chamada espera até 1 segundo.
  • Se não conseguir entrar, a operação é rejeitada.
  • O restante da aplicação continua funcionando.

Bulkhead com registro no DI

Exemplo registrando o cliente no Program.cs:

builder.Services.AddHttpClient<FiscalClient>(client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
client.Timeout = TimeSpan.FromSeconds(10);
});

Uso em um endpoint:

app.MapGet("/fiscal/consulta", async (
FiscalClient fiscalClient,
CancellationToken ct) =>
{
try
{
string resultado = await fiscalClient.ConsultarAsync(ct);

return Results.Ok(new
{
sucesso = true,
resultado
});
}
catch (InvalidOperationException ex)
{
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
});

Nesse caso, quando o Bulkhead estiver cheio, o endpoint retorna 429 Too Many Requests.


Bulkhead com RateLimiter do .NET

O .NET possui APIs de rate limiting que também podem ser usadas para controlar concorrência.

Exemplo com ConcurrencyLimiter:

using System.Threading.RateLimiting;

public sealed class FiscalBulkhead
{
private readonly ConcurrencyLimiter _limiter = new(new ConcurrencyLimiterOptions
{
PermitLimit = 10,
QueueLimit = 20,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});

public async Task<IDisposable?> TentarEntrarAsync(CancellationToken ct)
{
RateLimitLease lease = await _limiter.AcquireAsync(
permitCount: 1,
cancellationToken: ct);

if (!lease.IsAcquired)
return null;

return lease;
}
}

Uso:

public sealed class FiscalService
{
private readonly FiscalBulkhead _bulkhead;
private readonly HttpClient _httpClient;

public FiscalService(FiscalBulkhead bulkhead, HttpClient httpClient)
{
_bulkhead = bulkhead;
_httpClient = httpClient;
}

public async Task<string> ConsultarAsync(CancellationToken ct)
{
using IDisposable? lease = await _bulkhead.TentarEntrarAsync(ct);

if (lease is null)
{
throw new InvalidOperationException(
"Bulkhead fiscal cheio.");
}

return await _httpClient.GetStringAsync("/consulta", ct);
}
}

Registro no DI:

builder.Services.AddSingleton<FiscalBulkhead>();

builder.Services.AddHttpClient<FiscalService>(client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
});

Diferença entre SemaphoreSlim e RateLimiter

OpçãoQuando usar
SemaphoreSlimControle simples de concorrência
RateLimiterControle mais estruturado com fila, lease e políticas
Polly BulkheadQuando já usa Polly para resiliência
Filas internasQuando precisa absorver carga e processar em background

Para sistemas simples, SemaphoreSlim resolve bem.

Para aplicações com políticas mais explícitas de limitação, RateLimiter ou Polly tendem a ser mais adequados.


Bulkhead com Channel

Também é possível aplicar Bulkhead usando Channel.

Esse modelo é útil quando você quer controlar a quantidade de itens em memória e processar em background.

Exemplo: fila isolada para publicação no RabbitMQ.

using System.Threading.Channels;

public sealed class FilaPublicacao
{
private readonly Channel<MensagemPublicacao> _channel;

public FilaPublicacao()
{
_channel = Channel.CreateBounded<MensagemPublicacao>(
new BoundedChannelOptions(capacity: 1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
}

public async ValueTask EnfileirarAsync(
MensagemPublicacao mensagem,
CancellationToken ct)
{
await _channel.Writer.WriteAsync(mensagem, ct);
}

public IAsyncEnumerable<MensagemPublicacao> LerAsync(CancellationToken ct)
{
return _channel.Reader.ReadAllAsync(ct);
}
}

public sealed record MensagemPublicacao(
Guid RequestId,
string Cnpj,
string Payload);

Worker consumidor:

public sealed class PublicacaoWorker : BackgroundService
{
private readonly FilaPublicacao _fila;
private readonly ILogger<PublicacaoWorker> _logger;

public PublicacaoWorker(
FilaPublicacao fila,
ILogger<PublicacaoWorker> logger)
{
_fila = fila;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (MensagemPublicacao mensagem in _fila.LerAsync(stoppingToken))
{
try
{
await PublicarAsync(mensagem, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Erro ao publicar mensagem {RequestId}",
mensagem.RequestId);
}
}
}

private static Task PublicarAsync(
MensagemPublicacao mensagem,
CancellationToken ct)
{
// Publicação no RabbitMQ, Kafka, HTTP, etc.
return Task.CompletedTask;
}
}

Registro:

builder.Services.AddSingleton<FilaPublicacao>();
builder.Services.AddHostedService<PublicacaoWorker>();

Nesse modelo, a fila tem capacidade limitada. Isso impede crescimento infinito de memória.


Bulkhead por cliente

Em alguns cenários, o problema não é uma dependência externa, mas um cliente específico gerando carga excessiva.

Exemplo: limitar processamento por CNPJ.

using System.Collections.Concurrent;
using System.Threading.RateLimiting;

public sealed class BulkheadPorCliente
{
private readonly ConcurrentDictionary<string, ConcurrencyLimiter> _limiters = new();

public async ValueTask<RateLimitLease> EntrarAsync(
string cnpj,
CancellationToken ct)
{
ConcurrencyLimiter limiter = _limiters.GetOrAdd(cnpj, _ =>
new ConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 5,
QueueLimit = 20,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));

return await limiter.AcquireAsync(1, ct);
}
}

Uso em endpoint:

app.MapPost("/envio-comando/{cnpj}", async (
string cnpj,
BulkheadPorCliente bulkhead,
CancellationToken ct) =>
{
using RateLimitLease lease = await bulkhead.EntrarAsync(cnpj, ct);

if (!lease.IsAcquired)
{
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}

// Processa comando para esse CNPJ.
Guid requestId = Guid.NewGuid();

return Results.Accepted(value: new
{
requestId
});
});

Esse modelo impede que um único cliente monopolize a aplicação.


Bulkhead por tipo de operação

Também é possível separar limites por tipo de operação.

public sealed class BulkheadsOperacionais
{
public SemaphoreSlim EnvioComando { get; } = new(100, 100);

public SemaphoreSlim RetornoCliente { get; } = new(200, 200);

public SemaphoreSlim PublicacaoRabbitMq { get; } = new(50, 50);

public SemaphoreSlim ConsultaExterna { get; } = new(10, 10);
}

Registro:

builder.Services.AddSingleton<BulkheadsOperacionais>();

Uso:

public sealed class RetornoService
{
private readonly BulkheadsOperacionais _bulkheads;

public RetornoService(BulkheadsOperacionais bulkheads)
{
_bulkheads = bulkheads;
}

public async Task ProcessarRetornoAsync(CancellationToken ct)
{
bool entrou = await _bulkheads.RetornoCliente.WaitAsync(
TimeSpan.FromSeconds(1),
ct);

if (!entrou)
{
throw new InvalidOperationException(
"Bulkhead de retorno cheio.");
}

try
{
// Processa retorno.
}
finally
{
_bulkheads.RetornoCliente.Release();
}
}
}

Bulkhead em HttpClient

Além do controle no código, também é possível limitar conexões HTTP por destino.

builder.Services.AddHttpClient("Fiscal", client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
client.Timeout = TimeSpan.FromSeconds(10);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
MaxConnectionsPerServer = 20
});

Esse limite impede que sua aplicação abra conexões demais contra o mesmo servidor.

Uso:

public sealed class FiscalHttpClient
{
private readonly IHttpClientFactory _httpClientFactory;

public FiscalHttpClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}

public async Task<string> ConsultarAsync(CancellationToken ct)
{
HttpClient client = _httpClientFactory.CreateClient("Fiscal");

return await client.GetStringAsync("/consulta", ct);
}
}

Esse tipo de limite é muito importante para dependências externas lentas.


Bulkhead com Polly

Polly é uma biblioteca bastante usada para resiliência em .NET.

Um exemplo comum é combinar:

  • Timeout
  • Retry
  • Circuit Breaker
  • Bulkhead

Exemplo conceitual com Polly:

builder.Services.AddHttpClient("Fiscal", client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
})
.AddStandardResilienceHandler();

Em versões recentes do .NET, o pacote de resiliência com Microsoft.Extensions.Http.Resilience facilita a composição dessas políticas.

Exemplo com pipeline customizado:

builder.Services.AddHttpClient("Fiscal", client =>
{
client.BaseAddress = new Uri("https://api-fiscal.exemplo.com");
})
.AddResilienceHandler("fiscal-pipeline", builder =>
{
builder.AddTimeout(TimeSpan.FromSeconds(5));

builder.AddConcurrencyLimiter(new HttpRateLimiterStrategyOptions
{
PermitLimit = 10,
QueueLimit = 20
});
});

Esse exemplo cria um limite de concorrência para as chamadas HTTP feitas por esse client.


Bulkhead, timeout e circuit breaker

Bulkhead não substitui timeout nem circuit breaker.

Eles resolvem problemas diferentes.

PadrãoResponsabilidade
TimeoutEvita esperar indefinidamente
RetryTenta novamente quando faz sentido
Circuit BreakerPara de chamar uma dependência ruim
BulkheadLimita quanto recurso uma parte pode consumir
Rate LimitControla volume de entrada

Uma configuração saudável geralmente combina os padrões:

Requisição

Rate Limit

Bulkhead

Timeout

Retry controlado

Circuit Breaker

Dependência externa

Exemplo aplicado em uma API de comandos

Contexto:

  • Integrações enviam comandos.
  • API retorna um requestId.
  • Cliente recebe o comando.
  • Cliente envia o retorno para uma API de retorno.
  • API de retorno publica o resultado em uma fila.

Sem Bulkhead:

API de Retorno
└── Todos os retornos usam os mesmos recursos
├── Cliente A
├── Cliente B
├── Cliente C
└── Publicação RabbitMQ

Se o Cliente A enviar carga demais, ele pode afetar os outros clientes.

Com Bulkhead:

API de Retorno
├── Bulkhead por cliente
│ ├── Cliente A: máximo 5 simultâneos
│ ├── Cliente B: máximo 5 simultâneos
│ └── Cliente C: máximo 5 simultâneos

├── Fila bounded para publicação
│ └── Capacidade máxima: 1000 mensagens

└── Worker RabbitMQ
└── Máximo 50 publicações simultâneas

Esse desenho protege:

  • A API
  • A memória
  • O RabbitMQ
  • Os demais clientes
  • O tempo de resposta da rota

Exemplo completo de endpoint com Bulkhead por CNPJ

using System.Threading.RateLimiting;

app.MapPost("/retornos/{cnpj}", async (
string cnpj,
RetornoDto retorno,
BulkheadPorCliente bulkhead,
FilaPublicacao fila,
CancellationToken ct) =>
{
using RateLimitLease lease = await bulkhead.EntrarAsync(cnpj, ct);

if (!lease.IsAcquired)
{
return Results.Problem(
title: "Limite de processamento atingido",
detail: "O cliente excedeu o limite de operações simultâneas.",
statusCode: StatusCodes.Status429TooManyRequests);
}

var mensagem = new MensagemPublicacao(
RequestId: retorno.RequestId,
Cnpj: cnpj,
Payload: retorno.Payload);

await fila.EnfileirarAsync(mensagem, ct);

return Results.Accepted(value: new
{
retorno.RequestId
});
});

public sealed record RetornoDto(
Guid RequestId,
string Payload);

Cuidados ao usar Bulkhead

Bulkhead mal configurado também pode causar problema.

CuidadoMotivo
Limite baixo demaisPode rejeitar tráfego saudável
Limite alto demaisNão protege recurso nenhum
Fila grande demaisPode aumentar memória e latência
Sem timeoutOperações podem ficar presas
Sem métricaVocê não sabe quando o bulkhead está saturando
Sem fallbackO cliente recebe erro sem alternativa

Métricas importantes

Ao usar Bulkhead, monitore:

MétricaO que indica
Operações aceitasVolume que entrou no bulkhead
Operações rejeitadasSaturação do limite
Tempo aguardando permissãoPressão no compartimento
Tamanho da filaBackpressure interno
Tempo de processamentoLentidão no recurso protegido
Erros por dependênciaFalhas externas
Uso de memóriaCrescimento causado por filas

Exemplo simples de métrica:

public sealed class BulkheadMetricas
{
private long _aceitos;
private long _rejeitados;

public void RegistrarAceito()
{
Interlocked.Increment(ref _aceitos);
}

public void RegistrarRejeitado()
{
Interlocked.Increment(ref _rejeitados);
}

public long Aceitos => Interlocked.Read(ref _aceitos);

public long Rejeitados => Interlocked.Read(ref _rejeitados);
}

Quando usar Bulkhead

Use Bulkhead quando:

  • Uma integração externa pode ficar lenta.
  • Um cliente pode enviar carga excessiva.
  • Um fluxo não crítico pode afetar um fluxo crítico.
  • Existe concorrência alta.
  • Existem filas internas em memória.
  • Existe risco de esgotar threads, conexões ou memória.
  • O sistema precisa degradar parcialmente sem cair inteiro.

Quando não usar Bulkhead

Evite aplicar Bulkhead quando:

  • O sistema é pequeno e não possui concorrência relevante.
  • O custo operacional do controle é maior que o risco.
  • Não há recurso compartilhado crítico.
  • Você ainda não tem métricas básicas.
  • O limite seria arbitrário e sem validação.

Bulkhead não deve ser usado como “enfeite arquitetural”. Ele deve proteger um recurso real.


Diferença entre Bulkhead e fila

Bulkhead e fila não são a mesma coisa.

ConceitoFunção
BulkheadIsola capacidade
FilaArmazena trabalho para processar depois

Eles podem ser usados juntos:

Bulkhead:
Limita quantas operações entram.

Fila:
Controla quanto trabalho fica pendente.

Worker:
Processa em ritmo controlado.

Exemplo:

Request HTTP

Bulkhead por cliente

Channel bounded

Worker

RabbitMQ

Diferença entre Bulkhead e Rate Limit

ConceitoPergunta que responde
Rate LimitQuantas requisições podem entrar por período?
BulkheadQuantas operações podem consumir esse recurso ao mesmo tempo?

Exemplo:

Rate Limit:
Cliente pode fazer 100 requisições por minuto.

Bulkhead:
Cliente pode ter no máximo 5 operações simultâneas.

Os dois podem coexistir.


Recomendação prática

Para aplicações .NET, uma estratégia prática seria:

CenárioSolução recomendada
Limitar concorrência simplesSemaphoreSlim
Limitar concorrência com filaRateLimiter
Processar em backgroundChannel bounded
Chamar APIs externasHttpClientFactory + limite + timeout
Resiliência completaPolly / Microsoft.Extensions.Http.Resilience
Isolar clientesBulkhead por chave, como CNPJ
Isolar operaçõesBulkhead por tipo de fluxo

Resumo rápido

Bulkhead é um padrão para isolar recursos.

Ele evita que:

  • Uma dependência lenta derrube a aplicação.
  • Um cliente pesado afete os demais.
  • Uma fila cresça sem controle.
  • Uma operação não crítica consuma recursos críticos.
  • O sistema inteiro caia por causa de uma parte.

A regra mental é:

Se uma parte do sistema tem motivo diferente para falhar, escalar ou consumir recurso, ela provavelmente merece um limite isolado.