Bienvenido a la primera entrega de Píldoras de C#, donde explicaremos esos pequeños cambios y características más jugosas de C# con la pasión de un chef explicando su receta secreta.

Hoy vamos a hablar de una feature que vive con nosotros desde C# 12 (que se corresponde con la versión .Net 8) y que, aunque no haga tanto ruido como otras características, está cambiando silenciosamente la forma en que escribimos nuestras clases. Te presento a los constructores primarios: esos pequeños héroes que nos permiten escribir menos código y ser más productivos.

TL;DR (Para los impacientes)

  • Los constructores primarios llegaron con C# 12 para clases y structs
  • Permiten declarar parámetros directamente en la definición del tipo
  • Los parámetros están disponibles en todo el cuerpo de la clase
  • Si no se usan, el compilador los descarta automáticamente
  • Si se usan en métodos, se capturan como campos privados
  • Todos los demás constructores deben llamar al primario con this(...)
  • Son perfectos para inyección de dependencias y inicialización simple

¿Qué son los constructores primarios?

Imagínate que pudieras declarar los parámetros de tu constructor favorito directamente en la cabecera de tu clase, como si fuera la firma de un método. Eso es exactamente lo que hacen los constructores primarios.

Antes tenías que hacer esto:

public class CustomerService
{
    private readonly IRepository _repository;
    private readonly ILogger _logger;

    public CustomerService(IRepository repository, ILogger logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public Customer GetCustomer(int id)
    {
        _logger.LogInformation($"Getting customer {id}");
        return _repository.GetById(id);
    }
}

Ahora puedes simplificarlos así:

public class CustomerService(IRepository repository, ILogger logger)
{
    public Customer GetCustomer(int id)
    {
        logger.LogInformation($"Getting customer {id}");
        return repository.GetById(id);
    }
}

¿Ves la diferencia? El código es más limpio, más conciso y va directo al grano. Los parámetros repository y logger están disponibles en toda la clase como si fueran variables mágicas.

La magia detrás del telón

Cuando el compilador ve que usas un parámetro del constructor primario dentro de un método (como en el ejemplo anterior), automáticamente crea un campo privado para almacenarlo. Es como tener un asistente invisible que se encarga del trabajo sucio por ti.

// Lo que escribes:
public class BankAccount(string accountNumber, decimal initialBalance)
{
    public void Deposit(decimal amount)
    {
        initialBalance += amount; // Usa el parámetro
        Console.WriteLine($"Account {accountNumber}: New balance = {initialBalance}");
    }
}

// Lo que hace el compilador (aproximadamente):
public class BankAccount
{
    private readonly string __accountNumber; // Campo generado
    private decimal __initialBalance;        // Campo generado

    public BankAccount(string accountNumber, decimal initialBalance)
    {
        __accountNumber = accountNumber;
        __initialBalance = initialBalance;
    }

    public void Deposit(decimal amount)
    {
        __initialBalance += amount;
        Console.WriteLine($"Account {__accountNumber}: New balance = {__initialBalance}");
    }
}

Pero si solo usas los parámetros para inicialización, el compilador es lo suficientemente inteligente para no crear campos innecesarios:

public class Circle(double radius)
{
    public double Area { get; } = Math.PI * radius * radius; // Solo inicialización
    public double Circumference { get; } = 2 * Math.PI * radius; // Solo inicialización
}

En este caso, radius no se captura en un campo porque solo se usa durante la inicialización.

Las reglas del juego

Los constructores primarios tienen algunas reglas que debes conocer para usarlos como un maestro:

Regla #1: Todos los caminos llevan al primario

Si tienes un constructor primario, cualquier otro constructor que agregues debe llamarlo usando this(...):

public class Product(string name, decimal price)
{
    public string Name { get; } = name;
    public decimal Price { get; } = price;

    // Constructor adicional que DEBE llamar al primario
    public Product(string name) : this(name, 0.0m) { }

    // Este constructor causaría error de compilación:
    // public Product() { } // ❌ No llama al constructor primario
}

Regla #2: Los parámetros son parámetros, no propiedades

Los parámetros del constructor primario no son miembros de la clase. No puedes acceder a ellos con this.parametro:

public class Person(string name)
{
    public void PrintName()
    {
        Console.WriteLine(name);      // ✅ Correcto
        Console.WriteLine(this.name); // ❌ Error de compilación
    }
}

Regla #3: Scope inteligente

Los parámetros tienen diferentes comportamientos según dónde los uses:

public class SmartClass(int value, string name) : BaseClass(value)
{
    // En inicializadores de campos/propiedades, los parámetros tienen prioridad
    public string Name { get; } = name; // Usa el parámetro 'name'

    // En el cuerpo de métodos, los miembros de la clase tienen prioridad
    public void DoSomething()
    {
        Console.WriteLine(Name); // Usa la propiedad 'Name', no el parámetro 'name'
        Console.WriteLine(name); // Usa el parámetro 'name' (se captura automáticamente)
    }
}

Casos de uso perfectos

Inyección de dependencias

Los constructores primarios brillan especialmente en escenarios de inyección de dependencias:

// Controlador API limpio y conciso
public class WeatherController(IWeatherService weatherService, ILogger<WeatherController> logger) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<Weather>> Get(string city)
    {
        logger.LogInformation($"Getting weather for {city}");
        var weather = await weatherService.GetWeatherAsync(city);
        return Ok(weather);
    }
}

Validación con estilo

Puedes añadir validación directamente en los inicializadores de propiedades:

public class User(string email, int age)
{
    public string Email { get; } = IsValidEmail(email) 
        ? email 
        : throw new ArgumentException("Invalid email", nameof(email));

    public int Age { get; } = age >= 0 
        ? age 
        : throw new ArgumentException("Age cannot be negative", nameof(age));

    private static bool IsValidEmail(string email) 
        => email.Contains("@"); // Validación simple para el ejemplo
}

Herencia elegante

Los constructores primarios se llevan muy bien con la herencia:

public class Vehicle(string brand, int year)
{
    public string Brand { get; } = brand;
    public int Year { get; } = year;
}

public class Car(string brand, int year, int doors) : Vehicle(brand, year)
{
    public int Doors { get; } = doors;

    public void ShowInfo()
    {
        Console.WriteLine($"Car: {Brand} {Year} with {Doors} doors");
        // Nota: Brand y Year son propiedades de la clase base
        // doors se captura automáticamente porque se usa aquí
        Console.WriteLine($"Original doors parameter: {doors}");
    }
}

Peligros y advertencias

El peligro del doble almacenamiento

Cuidado con capturar parámetros que ya has usado para inicializar propiedades:

public class Person(string name)
{
    public string Name { get; set; } = name; // Se almacena en la propiedad
    public override string ToString() => name; // ¡Se captura TAMBIÉN como campo!
}

En este caso, name se almacena dos veces: una en la propiedad Name y otra en un campo privado generado. Pero no te preocupes, el compilador te advertirá sobre esto.

Records vs Clases normales

En records, los parámetros del constructor primario se convierten automáticamente en propiedades públicas:

// En un record, 'Name' se convierte en una propiedad pública
public record PersonRecord(string Name);

// En una clase normal, 'name' solo es un parámetro
public class PersonClass(string name)
{
    // Necesitas crear la propiedad manualmente si la quieres
    public string Name { get; } = name;
}

Atributos en constructores primarios

Puedes aplicar atributos al constructor primario usando el target method:

[method: JsonConstructor] // El atributo va al constructor generado
public class ApiResponse(int statusCode, string message)
{
    public int StatusCode { get; } = statusCode;
    public string Message { get; } = message;
}

¿Cuándo NO usar constructores primarios?

Los constructores primarios son geniales, pero no siempre son la mejor opción:

  • Cuando necesitas lógica compleja de inicialización: Los constructores primarios no permiten código adicional. Si necesitas validaciones complejas o configuración especial, un constructor tradicional podría ser mejor.
  • Cuando tienes muchos parámetros opcionales: Los constructores primarios no soportan parámetros opcionales de forma elegante.
  • Cuando la visibilidad del constructor debe ser diferente a la de la clase: No puedes hacer un constructor primario privado mientras la clase es pública.

Conclusión: La revolución silenciosa

Los constructores primarios no van a cambiar el mundo, pero definitivamente van a hacer que tu código sea más limpio, menos "verboso" y expresivo. Son esa mejora pequeña pero significativa que te hace sonreír cada vez que escribes una nueva clase.

Son especialmente útiles para:

  • Servicios con inyección de dependencias
  • DTOs y objetos de transferencia de datos
  • Clases que principalmente almacenan y exponen datos
  • Cualquier escenario donde quieras menos ceremonia y más funcionalidad

La próxima vez que vayas a crear una clase nueva, pregúntate: "¿Podría usar un constructor primario aquí?". Probablemente la respuesta sea sí, y tu código te lo agradecerá.

Espero que esta pequeña pildora te haya sido de utilidad. ¡Nos vemos en la próxima píldora de C#, donde seguiremos explorando las características que hacen de este lenguaje una delicia para desarrollar!


Referencias útiles: