.NET C# 12 Arquitetura Boas Práticas

Primary Constructors no C# 12: DI mais limpa sem boilerplate de campos

Entenda como Primary Constructors do C# 12 simplificam a injeção de dependência em services, handlers e controllers, os riscos de captura de parâmetros.

N
Neryx Digital Architects
21 de janeiro de 2026
11 min de leitura
200 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

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 →

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.