C# 12 Primary Constructors - Simplificando clases y records
C# 12 trae Primary Constructors a clases normales, no solo records. Y cambia mucho.
¿Qué son los Primary Constructors?
Los Primary Constructors existían en C# desde la versión 9, pero solo para record types. C# 12 los extiende a class y struct.
El antes y el después 📝
Antes (C# 11)
public class ProductService
{
private readonly ILogger<ProductService> _logger;
private readonly IProductRepository _repository;
private readonly IMapper _mapper;
public ProductService(
ILogger<ProductService> logger,
IProductRepository repository,
IMapper mapper)
{
_logger = logger;
_repository = repository;
_mapper = mapper;
}
public async Task<ProductDto> GetProductAsync(int id)
{
_logger.LogInformation("Fetching product {Id}", id);
var product = await _repository.GetByIdAsync(id);
return _mapper.Map<ProductDto>(product);
}
}
Después (C# 12)
public class ProductService(
ILogger<ProductService> logger,
IProductRepository repository,
IMapper mapper)
{
public async Task<ProductDto> GetProductAsync(int id)
{
logger.LogInformation("Fetching product {Id}", id);
var product = await repository.GetByIdAsync(id);
return mapper.Map<ProductDto>(product);
}
}
13 líneas menos. Y más legible.
Cómo funcionan realmente 🔍
Los parámetros del primary constructor están disponibles en:
- Inicializadores de campos y propiedades
- Cuerpo de la clase
- Métodos y propiedades
public class OrderProcessor(
ILogger<OrderProcessor> logger,
decimal taxRate)
{
// Se pueden usar en inicializadores
private readonly decimal _taxRate = taxRate > 0 ? taxRate : 0.21m;
// Se pueden usar en propiedades
public decimal TaxRate => taxRate;
// Se pueden usar en métodos
public void ProcessOrder(Order order)
{
logger.LogInformation("Processing order {OrderId}", order.Id);
order.TotalAmount *= (1 + taxRate);
}
}
Casos de uso prácticos 💼
1. Servicios con Dependency Injection
Esto es oro para ASP.NET Core:
public class WeatherForecastController(
IWeatherService weatherService,
ILogger<WeatherForecastController> logger) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get(string city)
{
logger.LogInformation("Getting weather for {City}", city);
var forecast = await weatherService.GetForecastAsync(city);
return Ok(forecast);
}
}
2. Configuración con Options pattern
public class EmailService(
IOptions<EmailSettings> options,
ISmtpClient smtpClient)
{
private readonly EmailSettings _settings = options.Value;
public async Task SendAsync(string to, string subject, string body)
{
await smtpClient.SendMailAsync(
_settings.FromAddress,
to,
subject,
body);
}
}
3. Combinación con field keyword (C# 13)
En C# 13 llegará field, que complementa esto perfectamente:
public class Product(string name, decimal price)
{
public string Name { get; set; } = name;
public decimal Price
{
get => field;
set => field = value >= 0 ? value : 0;
} = price;
}
Diferencias con records 🤔
Los primary constructors en class y record no son idénticos:
// Record: genera propiedades automáticas
public record PersonRecord(string Name, int Age);
var person = new PersonRecord("John", 30);
Console.WriteLine(person.Name); // "John"
// Class: NO genera propiedades automáticas
public class PersonClass(string name, int age);
var person = new PersonClass("John", 30);
// Console.WriteLine(person.Name); // ❌ No compila
Para exponer parámetros como propiedades en clases:
public class Person(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
}
Gotchas y consideraciones ⚠️
1. Captura de parámetros
Los parámetros se capturan como campos privados implícitos:
public class Counter(int initialValue)
{
private int _count = initialValue;
// initialValue aún está disponible
public void Reset() => _count = initialValue;
}
Esto tiene implicaciones de memoria si los parámetros son objetos grandes.
2. Constructores adicionales deben delegar
public class DatabaseConnection(string connectionString)
{
// ✅ Correcto
public DatabaseConnection() : this("DefaultConnection")
{
}
// ❌ No compila
// public DatabaseConnection() { }
}
3. Serialización
Algunos serializadores (JSON.NET, System.Text.Json) pueden tener problemas si no defines propiedades explícitas:
// ❌ Puede no serializarse correctamente
public class Product(string name, decimal price);
// ✅ Mejor para serialización
public class Product(string name, decimal price)
{
public string Name { get; } = name;
public decimal Price { get; } = price;
}
Mi opinión personal 💭
Después de usar primary constructors en varios proyectos, mi take:
Pros:
- Código mucho más limpio en servicios con DI
- Reduce boilerplate significativamente
- Mejora legibilidad en clases pequeñas
Contras:
- Puede ser confuso cuando se mezcla con constructores tradicionales
- La captura implícita puede llevar a memory leaks si no se tiene cuidado
- Tooling aún está madurando (refactorings, analyzers)
Mi regla:
- ✅ Úsalos en: Services, Controllers, pequeñas clases de infraestructura
- ❌ Evítalos en: Entities, DTOs que se serializan, clases con lógica compleja de inicialización
Migración desde código existente
Rider y Visual Studio ofrecen refactorings automáticos:
- Click derecho en el constructor
- “Convert to primary constructor”
- Review los cambios (no siempre es trivial)
Para proyectos grandes, hazlo incrementalmente. No hay prisa.
Compatibilidad
- Requiere: C# 12 + .NET 8 (o .NET 7 con langVersion=12)
- Funciona con: Todos los frameworks (.NET Framework 4.8+, .NET Standard 2.0+)
<PropertyGroup>
<LangVersion>12</LangVersion>
</PropertyGroup>
Conclusión
Primary constructors son una adición bienvenida a C#. Reducen ceremonia sin perder expresividad.
No son revolucionarios, pero para quien escribe servicios con DI todo el día (como yo), son un cambio de calidad de vida notable.
¿Ya los estás usando? ¿Qué opinas? Me encantaría saber si has encontrado casos de uso interesantes o problemas que yo no haya mencionado.
¡Feliz coding! 🚀