Con C# 12, Microsoft ha introdotto i primary constructors per tutte le classi e le struct — non solo per i record come in precedenza. Per chi lavora quotidianamente con ASP.NET Core e il pattern di dependency injection, questa feature merita attenzione: riduce il boilerplate in modo significativo, ma nasconde un'insidia che è bene conoscere prima di adottarla sistematicamente.
Il problema: il boilerplate del costruttore classico
Chi ha scritto servizi ASP.NET Core sa bene come appare il costruttore tradizionale con dependency injection:
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<OrderService> _logger;
private readonly IEventBus _eventBus;
private readonly IValidator<Order> _validator;
public OrderService(
IOrderRepository orderRepository,
ILogger<OrderService> logger,
IEventBus eventBus,
IValidator<Order> validator)
{
_orderRepository = orderRepository;
_logger = logger;
_eventBus = eventBus;
_validator = validator;
}
}
Per ogni dipendenza: una dichiarazione di campo, un parametro nel costruttore, un'assegnazione nel corpo. Con quattro dipendenze, sono già dodici righe di puro scaffolding. In un servizio con sei o sette dipendenze, il costruttore diventa il blocco di codice più lungo della classe — senza contenere logica.
La soluzione con primary constructors
Con i primary constructors di C# 12, lo stesso servizio si scrive così:
public class OrderService(
IOrderRepository orderRepository,
ILogger<OrderService> logger,
IEventBus eventBus,
IValidator<Order> validator)
{
public async Task<Order?> GetOrderAsync(Guid id)
{
logger.LogInformation("Fetching order {OrderId}", id);
return await orderRepository.GetByIdAsync(id);
}
public async Task PlaceOrderAsync(Order order)
{
await validator.ValidateAndThrowAsync(order);
await orderRepository.SaveAsync(order);
await eventBus.PublishAsync(new OrderPlacedEvent(order.Id));
}
}
I parametri del primary constructor diventano direttamente disponibili in tutta la classe senza dichiarazioni esplicite di campo. Il risultato è codice più snello, con meno rumore visivo e il focus immediato sulla logica di business.
L'insidia della mutabilità: perché Milan Jovanović era inizialmente scettico
Il motivo di resistenza di molti sviluppatori esperti è legittimo: i parametri del primary constructor non sono campi readonly. Il compilatore non impedisce la riassegnazione accidentale:
public class OrderService(IOrderRepository orderRepository)
{
public void SomeMethod()
{
orderRepository = null!; // Compila senza warning
}
}
Con i campi privati readonly tradizionali, questo codice causa un errore di compilazione. Con i primary constructors, il compilatore lo accetta silenziosamente. In un servizio DI dove le dipendenze non dovrebbero mai essere rimpiazzate a runtime, questo è un rischio concreto in team di grandi dimensioni.
Mitigazione: assegnazione esplicita a readonly field
Quando la garanzia di immutabilità è critica, si può assegnare esplicitamente il parametro a un campo readonly:
public class CriticalService(IRepository repository)
{
private readonly IRepository _repository = repository;
// Da qui in poi si usa _repository, mai repository direttamente
}
Questo reintroduce parte del boilerplate, ma mantiene la sintassi più compatta per la firma del costruttore e garantisce l'immutabilità a livello compilatore.
Quando usare i primary constructors
La valutazione pragmatica è che i primary constructors offrono il massimo vantaggio nelle classi di servizio DI tipiche di ASP.NET Core, dove i parametri vengono usati ma raramente riassegnati. In questi scenari, il rischio di mutabilità accidentale è basso e i benefici di leggibilità sono immediati.
Vale la pena usarli anche per entity e value object dove i parametri di costruzione diventano proprietà read-only:
public class Order(Guid customerId, Money total)
{
public Guid Id { get; } = Guid.NewGuid();
public Guid CustomerId { get; } = customerId;
public Money Total { get; } = total;
public DateTime CreatedAt { get; } = DateTime.UtcNow;
}
Quando evitarli
Tre scenari in cui i primary constructors non sono la scelta giusta:
- Logica di validazione nel costruttore: se il costruttore deve eseguire guard clause o validazioni prima dell'assegnazione, il corpo esplicito del costruttore tradizionale è necessario.
- Multiple signature di costruttore: i primary constructors supportano una sola firma. Con overload multipli (es. per serializzazione), la sintassi tradizionale è l'unica opzione.
- Cinque o più dipendenze: se una classe richiede molte dipendenze, il problema non è la sintassi del costruttore ma il design della classe. Il segnale che suggerisce un refactoring verso interfacce più coese o il pattern Facade.
Conclusione: adottarli con consapevolezza
I primary constructors di C# 12 non sono una rivoluzione, ma un'evoluzione pragmatica del linguaggio. Per la maggior parte delle classi di servizio in ASP.NET Core, il tradeoff è favorevole: meno boilerplate, codice più leggibile, rischio di mutabilità basso nel contesto DI. L'importante è conoscere il comportamento del compilatore e scegliere consapevolmente quando la garanzia di readonly è effettivamente necessaria.
Come sempre con le feature di C#, il consiglio è adottarle dove migliorano la leggibilità del codice reale, non per seguire una moda sintattica.
Fonte originale: Why I Switched to Primary Constructors for DI in C# di Milan Jovanović.