O C# 12 generalizou os Primary Constructors — que já existiam em records desde o C# 9 — para classes e structs comuns. O resultado prático: a injeção de dependência em serviços, handlers e controllers fica muito mais concisa, eliminando o boilerplate de declarar campo, receber via construtor e atribuir.
Mas com a concisão vêm armadilhas. Este artigo cobre a sintaxe, os casos de uso ideais, os riscos reais de captura de parâmetros e como integrar com records e classes imutáveis.
A sintaxe
O Primary Constructor move os parâmetros do construtor para a declaração da classe:
// C# anterior — boilerplate clássico
public class PedidoService
{
private readonly IPedidoRepository _repository;
private readonly ILogger<PedidoService> _logger;
private readonly IEventBus _eventBus;
public PedidoService(
IPedidoRepository repository,
ILogger<PedidoService> logger,
IEventBus eventBus)
{
_repository = repository;
_logger = logger;
_eventBus = eventBus;
}
}
// C# 12 — Primary Constructor
public class PedidoService(
IPedidoRepository repository,
ILogger<PedidoService> logger,
IEventBus eventBus)
{
// repository, logger e eventBus disponíveis em todo o corpo da classe
}
Os parâmetros do primary constructor ficam disponíveis como variáveis em qualquer método, propriedade ou inicializador de campo dentro da classe — sem precisar declará-los como campos.
Uso em métodos
public class PedidoService(
IPedidoRepository repository,
ILogger<PedidoService> logger,
IEventBus eventBus)
{
public async Task<Pedido> CriarAsync(CriarPedidoRequest request, CancellationToken ct)
{
logger.LogInformation("Criando pedido para cliente {Id}", request.ClienteId);
var pedido = new Pedido(request.ClienteId, request.Itens);
await repository.AdicionarAsync(pedido, ct);
await eventBus.PublicarAsync(new PedidoCriadoEvent(pedido.Id), ct);
return pedido;
}
public async Task<Pedido?> ObterAsync(Guid id, CancellationToken ct)
=> await repository.ObterPorIdAsync(id, ct);
}
A armadilha principal: captura de parâmetros
Aqui mora o risco mais importante. Quando você referencia um parâmetro do primary constructor dentro de um método (como fizemos acima), o compilador o captura — cria um campo privado anônimo no tipo gerado. Esse campo não tem nome acessível ao desenvolvedor, não pode ser inspecionado diretamente e não é readonly por padrão.
Isso significa que, por acidente, você pode reatribuir um parâmetro:
public class ServicoProblematico(IRepository repository)
{
public void Configurar()
{
// PERIGO: isso reatribui o campo capturado
// O compilador permite — mas é quase sempre um bug
repository = new OutroRepository(); // ⚠️
}
public async Task<Dado> ObterAsync(int id)
=> await repository.ObterAsync(id); // qual repository?
}
A solução recomendada é declarar campos readonly explicitamente quando precisar de imutabilidade:
public class PedidoService(
IPedidoRepository repository,
ILogger<PedidoService> logger)
{
// Campos readonly — imutáveis e visíveis ao debugger
private readonly IPedidoRepository _repository = repository;
private readonly ILogger<PedidoService> _logger = logger;
public async Task ProcessarAsync(Guid id, CancellationToken ct)
{
// Usa os campos, não os parâmetros capturados
_logger.LogInformation("Processando {Id}", id);
var pedido = await _repository.ObterAsync(id, ct);
// ...
}
}
Nesse padrão híbrido você ainda elimina o construtor explícito, mas garante imutabilidade real e nome visível no debugger. É o padrão recomendado pelo time do C# para DI em classes de serviço.
Quando usar parâmetros diretamente (sem campo)
Parâmetros capturados diretamente — sem campo readonly intermediário — fazem sentido em cenários específicos:
1. Inicializadores de propriedade
public class Configuracao(string connectionString, int maxRetries)
{
// Usa o parâmetro apenas para inicializar a propriedade
public string ConnectionString { get; } = connectionString;
public int MaxRetries { get; } = maxRetries;
public TimeSpan Timeout { get; } = TimeSpan.FromSeconds(maxRetries * 5);
}
Aqui os parâmetros são consumidos apenas nos inicializadores — o compilador não cria campos capturados porque eles não são referenciados em nenhum método.
2. Records com comportamento
// Record — primary constructor gera propriedades init-only automaticamente
public record PedidoDto(Guid Id, string ClienteNome, decimal Total)
{
// Propriedade computada — usa os parâmetros do record
public string Resumo => $"Pedido {Id} — {ClienteNome} — R$ {Total:N2}";
}
3. Validators e helpers leves
public class PaginacaoHelper(int pagina, int tamanhoPagina)
{
public int Skip => (pagina - 1) * tamanhoPagina;
public int Take => tamanhoPagina;
public bool EhValida => pagina > 0 && tamanhoPagina is > 0 and <= 100;
}
Integração com Minimal APIs e handlers
Primary Constructors brilham especialmente em handlers de Minimal APIs e CQRS, onde você tem muitas classes pequenas com poucas dependências:
// Handler CQRS com MediatR
public class CriarPedidoHandler(
IPedidoRepository repository,
ILogger<CriarPedidoHandler> logger,
IEventBus eventBus)
: IRequestHandler<CriarPedidoCommand, PedidoDto>
{
public async Task<PedidoDto> Handle(
CriarPedidoCommand command, CancellationToken ct)
{
var pedido = Pedido.Criar(command.ClienteId, command.Itens);
await repository.AdicionarAsync(pedido, ct);
await eventBus.PublicarAsync(new PedidoCriadoEvent(pedido.Id), ct);
logger.LogInformation("Pedido {Id} criado", pedido.Id);
return PedidoDto.FromDomain(pedido);
}
}
// Endpoint group com dependências
public class PedidoEndpoints(IPedidoRepository repository) : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/pedidos/{id}", async (Guid id, CancellationToken ct) =>
{
var pedido = await repository.ObterAsync(id, ct);
return pedido is null ? Results.NotFound() : Results.Ok(pedido);
});
}
}
Primary Constructor em herança
public abstract class ServicoBase(ILogger logger)
{
protected void LogInfo(string msg) => logger.LogInformation(msg);
protected void LogErro(Exception ex, string msg) => logger.LogError(ex, msg);
}
// Classe derivada passa o logger para a base via primary constructor
public class PedidoService(
IPedidoRepository repository,
ILogger<PedidoService> logger)
: ServicoBase(logger)
{
public async Task<Pedido?> ObterAsync(Guid id, CancellationToken ct)
{
LogInfo($"Buscando pedido {id}");
return await repository.ObterAsync(id, ct);
}
}
Testabilidade
Para testes, a classe com primary constructor funciona exatamente como antes — o construtor ainda existe, apenas sem body explícito:
public class PedidoServiceTests
{
private readonly IPedidoRepository _repositoryMock = Substitute.For<IPedidoRepository>();
private readonly ILogger<PedidoService> _loggerMock = Substitute.For<ILogger<PedidoService>>();
private readonly IEventBus _eventBusMock = Substitute.For<IEventBus>();
private PedidoService CriarSut() => new(_repositoryMock, _loggerMock, _eventBusMock);
[Fact]
public async Task CriarAsync_DevePublicarEvento()
{
var sut = CriarSut();
var request = new CriarPedidoRequest(Guid.NewGuid(), []);
await sut.CriarAsync(request, CancellationToken.None);
await _eventBusMock.Received(1).PublicarAsync(
Arg.Any<PedidoCriadoEvent>(), Arg.Any<CancellationToken>());
}
}
Guia de decisão
| Cenário | Recomendação |
|---|---|
| Service/Handler com 2–4 dependências | Primary Constructor + campos readonly explícitos |
| Value objects, DTOs sem lógica | Primary Constructor direto, sem campos |
| Records com comportamento | Primary Constructor nativo de records |
| Classe com lógica de inicialização complexa | Constructor tradicional (mais explícito) |
| Classe base com muitos filhos | Primary Constructor + herança via base() |
| Validação no construtor (Guard clauses) | Constructor tradicional ou método estático de fábrica |
Conclusão
Primary Constructors reduzem significativamente o boilerplate de DI no .NET — especialmente em projetos com muitas classes de serviço e handlers pequenos. O ganho de legibilidade é real: menos ruído visual significa que o leitor chega mais rápido à lógica de negócio.
A regra prática para código de produção: use primary constructor para receber dependências, mas declare-as como campos readonly explícitos quando forem referenciadas em métodos. Isso combina a concisão da nova sintaxe com a segurança de imutabilidade real. Para value objects e types de dados, deixe o parâmetro ser capturado diretamente — a imutabilidade vem da ausência de mutação, não da anotação readonly.
Precisa resolver isso na prática?
Se você está modernizando uma base de código .NET legada ou quer adotar C# 12 de forma consistente no seu time, podemos ajudar com uma revisão de código e guia de adoção.
Falar com um especialista →