Benvido á primeira entrega de Pílulas de C#, onde explicaremos eses pequenos cambios e características máis sucosas de C# coa paixón dun chef explicando a súa receita secreta.

Hoxe imos falar dunha característica que vive connosco dende C# 12 (que se corresponde coa versión .Net 8) e que, aínda que non faga tanto ruído como outras características, está cambiando silenciosamente a forma na que escribimos as nosas clases. Preséntoche os construtores primarios: eses pequenos heroes que nos permiten escribir menos código e ser máis produtivos.

TL;DR (Para os impacientes)

  • Os construtores primarios chegaron con C# 12 para clases e structs
  • Permiten declarar parámetros directamente na definición do tipo
  • Os parámetros están dispoñibles en todo o corpo da clase
  • Se non se usan, o compilador descártalos automaticamente
  • Se se usan en métodos, captúranse como campos privados
  • Todos os demais construtores deben chamar ao primario con this(...)
  • Son perfectos para inxección de dependencias e inicialización simple

Que son os construtores primarios?

Imaxina que puideras declarar os parámetros do teu construtor favorito directamente na cabeceira da túa clase, como se fose a sinatura dun método. Iso é exactamente o que fan os construtores primarios.

Antes tiñas que facer isto:

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);
    }
}

Agora podes simplificalo así:

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

Ves a diferenza? O código é máis limpo, máis conciso e vai directo ao gran. Os parámetros repository e logger están dispoñibles en toda a clase como se fosen variables máxicas.

A maxia detrás do telón

Cando o compilador ve que usas un parámetro do construtor primario dentro dun método (como no exemplo anterior), automaticamente crea un campo privado para almacenalo. É como ter un asistente invisible que se encarga do traballo sucio por ti.

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

// O que fai o compilador (aproximadamente):
public class BankAccount
{
    private readonly string __accountNumber; // Campo xerado
    private decimal __initialBalance;        // Campo xerado

    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 se só usas os parámetros para inicialización, o compilador é o suficientemente intelixente para non crear campos innecesarios:

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

Neste caso, radius non se captura nun campo porque só se usa durante a inicialización.

As regras do xogo

Os construtores primarios teñen algunhas regras que debes coñecer para usalos como un mestre:

Regra #1: Todos os camiños levan ao primario

Se tes un construtor primario, calquera outro construtor que engadas debe chamalo usando this(...):

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

    // Construtor adicional que DEBE chamar ao primario
    public Product(string name) : this(name, 0.0m) { }

    // Este construtor causaría erro de compilación:
    // public Product() { } // ❌ Non chama ao construtor primario
}

Regra #2: Os parámetros son parámetros, non propiedades

Os parámetros do construtor primario non son membros da clase. Non podes acceder a eles con this.parametro:

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

Regra #3: Ámbito intelixente

Os parámetros teñen diferentes comportamentos segundo onde os uses:

public class SmartClass(int value, string name) : BaseClass(value)
{
    // En inicializadores de campos/propiedades, os parámetros teñen prioridade
    public string Name { get; } = name; // Usa o parámetro 'name'

    // No corpo de métodos, os membros da clase teñen prioridade
    public void DoSomething()
    {
        Console.WriteLine(Name); // Usa a propiedade 'Name', non o parámetro 'name'
        Console.WriteLine(name); // Usa o parámetro 'name' (captúrase automaticamente)
    }
}

Casos de uso perfectos

Inxección de dependencias

Os construtores primarios brillan especialmente en escenarios de inxección de dependencias:

// Controlador API limpo e 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

Podes engadir validación directamente nos 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 o exemplo
}

Herdanza elegante

Os construtores primarios lévanse moi ben coa herdanza:

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 e Year son propiedades da clase base
        // doors captúrase automaticamente porque se usa aquí
        Console.WriteLine($"Original doors parameter: {doors}");
    }
}

Perigos e advertencias

O perigo do dobre almacenamento

Coidado con capturar parámetros que xa usaches para inicializar propiedades:

public class Person(string name)
{
    public string Name { get; set; } = name; // Almacénase na propiedade
    public override string ToString() => name; // Captúrase TAMÉN como campo!
}

Neste caso, name almacénase dúas veces: unha na propiedade Name e outra nun campo privado xerado. Pero non te preocupes, o compilador advertirache sobre isto.

Records vs Clases normais

Nos records, os parámetros do construtor primario convértense automaticamente en propiedades públicas:

// Nun record, 'Name' convértese nunha propiedade pública
public record PersonRecord(string Name);

// Nunha clase normal, 'name' só é un parámetro
public class PersonClass(string name)
{
    // Necesitas crear a propiedade manualmente se a queres
    public string Name { get; } = name;
}

Atributos en construtores primarios

Podes aplicar atributos ao construtor primario usando o target method:

[method: JsonConstructor] // O atributo vai ao construtor xerado
public class ApiResponse(int statusCode, string message)
{
    public int StatusCode { get; } = statusCode;
    public string Message { get; } = message;
}

Cando NON usar construtores primarios?

Os construtores primarios son xeniais, pero non sempre son a mellor opción:

  • Cando necesitas lóxica complexa de inicialización: Os construtores primarios non permiten código adicional. Se necesitas validacións complexas ou configuración especial, un construtor tradicional podería ser mellor.
  • Cando tes moitos parámetros opcionais: Os construtores primarios non soportan parámetros opcionais de forma elegante.
  • Cando a visibilidade do construtor debe ser diferente á da clase: Non podes facer un construtor primario privado mentres a clase é pública.

Conclusión: A revolución silenciosa

Os construtores primarios non van cambiar o mundo, pero definitivamente van facer que o teu código sexa máis limpo, menos "verboso" e expresivo. Son esa mellora pequena pero significativa que te fai sorrir cada vez que escribes unha nova clase.

Son especialmente útiles para:

  • Servizos con inxección de dependencias
  • DTOs e obxectos de transferencia de datos
  • Clases que principalmente almacenan e expoñen datos
  • Calquera escenario onde queiras menos ceremonia e máis funcionalidade

A próxima vez que vaias crear unha clase nova, pregúntate: "Podería usar un construtor primario aquí?". Probablemente a resposta sexa si, e o teu código agradeceríacho.

Espero que esta pequena pílula che fose de utilidade. Vémolo na próxima pílula de C#, onde seguiremos explorando as características que fan deste linguaxe unha delicia para desenvolver!


Referencias útiles: