← todas las entradas

5 de octubre de 2026 · .NET · C# · Inyección de dependencias

Inyección de dependencias en .NET, explicada con un contador

Scoped, transient y singleton, explicados con un contador sencillo — y cómo cambiar una implementación modificando una sola línea.


Hace poco empecé a construir una API en .NET como referencia para alguien, así que pensé que sería buena idea compartirla aquí también. Me da una excusa para explicar algunos conceptos que quizá no todos conocen todavía, y siempre disfruto la oportunidad de enseñar.

Échale un vistazo a mi repositorio de GitHub (en inglés), donde profundizo más y puedes ver la implementación real, no solo fragmentos de código. El código de esta entrada se encuentra en estos lugares:

  • Program.cs, en la región Services, para ver los registros
  • La carpeta Services, para ver los tres tipos de implementación
  • CounterController.cs, en la carpeta Controllers, para ver los usos

Algo importante: el código de esta entrada está simplificado. El repositorio tiene ejemplos completos y funcionales, así que los nombres y algunos detalles son distintos. Por ejemplo, cada ciclo de vida tiene su propia interfaz (IScopedCounterService, ITransientCounterService e ISingletonCounterService) en lugar de una sola ICounterService, y los métodos imprimen en la consola para que puedas ver cómo cambia el conteo al llamar a los endpoints. Los conceptos son exactamente los mismos.

Esta vez decidí adentrarme en los servicios y la inyección de dependencias. Para quienes no lo saben, la inyección de dependencias (DI, por sus siglas en inglés) en .NET consiste en pasar un objeto a una clase y dejar que .NET se encargue automáticamente de crearlo y entregarlo en tiempo de ejecución. Esto significa que no tienes que dejar las dependencias fijas en el código ni reescribir tus clases constantemente después. Si quieres cambiar cómo funciona una clase internamente —por ejemplo, pasar de enviar correos electrónicos a enviar mensajes de texto— puedes hacerlo con solo cambiar una línea de configuración.

Si esto suena un poco complicado, ¡no te preocupes! Lo voy a desglosar ahora mismo.

El problema

Toma esta clase, por ejemplo:

c#
public class CounterServiceA
{
    private int Count;

    public Task<string> GetClassName()
    {
        return Task.FromResult(nameof(CounterServiceA));
    }

    public Task AddToCount()
    {
        Count++;
        return Task.CompletedTask;
    }

    public Task SubtractFromCount()
    {
        Count--;
        return Task.CompletedTask;
    }
}

Es una clase bastante básica: GetClassName() devuelve el nombre de la clase, AddToCount() suma uno al conteo y SubtractFromCount() le resta uno. Si quisiera usarla en cualquier otra clase, solo escribiría:

c#
var counterServiceA = new CounterServiceA();

Algo bastante simple. Pero ¿y si quisiera cambiar la implementación?

c#
public class CounterServiceB
{
    private int Count;

    public Task<string> GetClassName()
    {
        return Task.FromResult(nameof(CounterServiceB));
    }

    public Task AddToCount()
    {
        Count += 2;
        return Task.CompletedTask;
    }
}

Ahora GetClassName() devuelve otro nombre y AddToCount() suma de dos en dos. Para hacer el cambio sin DI, tendría que ir manualmente a cada lugar donde se usa CounterServiceA y cambiarlo por CounterServiceB.

Para este punto quizá ya notaste que tenemos otro problema: ¿y si CounterServiceB no implementa todos los métodos o firmas de CounterServiceA? A CounterServiceB le falta SubtractFromCount(), lo cual es un dolor de cabeza enorme si ya se usa en muchos lugares donde todavía necesitamos esa lógica.

Entra la inyección de dependencias

Aquí es donde entra la DI. Con DI, registramos nuestra clase mediante una interfaz en Program.cs, le decimos a .NET qué implementación concreta inyectar y dejamos que el framework la resuelva en tiempo de ejecución. Así se hace.

Paso 1: crea una interfaz para que las clases la implementen.

c#
public interface ICounterService
{
    Task<string> GetClassName();
    Task AddToCount();
}

(Nota: quité SubtractFromCount() para simplificar y que nuestras dos clases puedan compartir la misma interfaz.)

Esta interfaz actúa como un contrato estricto. Le dice a nuestras clases exactamente qué métodos deben implementarse, incluyendo sus entradas y salidas.

Paso 2: aplica la interfaz a las clases.

c#
public class CounterServiceA : ICounterService // Contrato aplicado aquí
{
    private int Count;

    public Task<string> GetClassName()
    {
        return Task.FromResult(nameof(CounterServiceA));
    }

    public Task AddToCount()
    {
        Count++;
        return Task.CompletedTask;
    }
}

Cuando agregas : ICounterService a tu clase por primera vez, tu IDE mostrará un error de compilación indicando que los métodos requeridos por la interfaz aún no se han implementado. Una vez que implementas los métodos correspondientes, el error desaparece. Después harías exactamente lo mismo con CounterServiceB : ICounterService.

Paso 3: regístrala y cámbiala con una sola línea. En Program.cs le decimos a .NET qué implementación entregar cada vez que algo pida un ICounterService (explicaré AddScoped en un momento):

c#
builder.Services.AddScoped<ICounterService, CounterServiceA>();
Program.cs

¿Quieres CounterServiceB en todos lados? Cambia esa única línea:

c#
builder.Services.AddScoped<ICounterService, CounterServiceB>();
Program.cs

Ahora cada clase que pida un ICounterService recibe CounterServiceB, y nada más tiene que cambiar.

Es realmente así de simple dejar una clase lista para ser inyectada. Pero ¿por qué importa? ¿Por qué querríamos cambiar de clase en una aplicación real?

Un ejemplo del mundo real: pagos

Imaginemos que hemos estado usando la Compañía A de procesamiento de pagos. Al procesar pagos, podemos pensar en dos cosas que absolutamente debemos hacer como mínimo: cobrar un pago y procesar un reembolso. Sin DI, nuestra clase podría verse así:

c#
public class PaymentProcessingCompanyA
{
    public void CapturePayment()
    {
        // Lógica de cobro de pagos de la Compañía A
    }

    public void ProcessRefund()
    {
        // Lógica de reembolsos de la Compañía A
    }
}

Para usarla en un controlador u otro servicio, la instanciarías manualmente:

c#
var paymentProcessingCompanyA = new PaymentProcessingCompanyA();

Ahora bien, ¿qué pasa si tu negocio decide cambiar de la Compañía A a la Compañía B de procesamiento de pagos porque ofrece mejores tarifas por transacción?

Sigues necesitando cobrar pagos y procesar reembolsos. Pero si dejaste new PaymentProcessingCompanyA() fijo en docenas de controladores y servicios a lo largo de todo tu código, ahora tienes que encontrar y reescribir manualmente cada uno de ellos. Peor aún, si el SDK de la Compañía B usa nombres de métodos o firmas completamente distintos, tu código se rompe en todas partes.

En lugar de acoplar tu aplicación a un proveedor específico, creas un contrato mediante una interfaz:

c#
public interface IPaymentService
{
    void CapturePayment();
    void ProcessRefund();
}

El SDK de cada proveedor se envuelve en su propia clase que implementa IPaymentService. Es posible que el SDK de la Compañía B nombre todo distinto, pero esa diferencia se queda dentro de su clase envoltorio en lugar de filtrarse a tus controladores.

Al hacer que tanto PaymentProcessingCompanyA como PaymentProcessingCompanyB implementen IPaymentService, tu aplicación deja de importarle qué compañía maneja el dinero. Solo le importa que el objeto que recibe sepa ejecutar CapturePayment() y ProcessRefund().

Ahora veamos cómo registrarlos y usarlos. Esto depende mucho de lo que necesite hacer tu servicio, porque en .NET existen tres ciclos de vida de servicios distintos: Scoped, Transient y Singleton.

Scoped

c#
builder.Services.AddScoped<ICounterService, CounterServiceA>();
Program.cs

Scoped es el que probablemente más vas a usar en una API web. Un ciclo de vida scoped significa que cada solicitud HTTP recibe su propia instancia nueva de la implementación. Esa instancia persiste durante toda la duración de esa solicitud HTTP específica. No importa en cuántos lugares o niveles de la pila se inyecte durante esa solicitud, todos compartirán exactamente la misma instancia.

Veamos qué significa esto en la práctica usando un constructor primario en un controlador. (Los constructores primarios son una característica de C# 12, así que necesitas .NET 8 o posterior. En versiones anteriores, lo mismo funciona con un constructor normal.) Supongamos que el conteo inicial es cero y que AddToCount() aumenta el conteo en uno:

c#
[Route("api/[controller]/[action]")]
[ApiController]
public class CounterController(
    ICounterService counterService1,
    ICounterService counterService2
) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> AddToCount()
    {
        await counterService1.AddToCount(); // El conteo pasa a 1
        await counterService2.AddToCount(); // El conteo pasa a 2
        return Ok();
    }
}

Como lo registramos como Scoped, counterService1 y counterService2 apuntan exactamente al mismo objeto en memoria durante esta solicitud HTTP. Cuando counterService1 incrementa el conteo en uno, counterService2 ve ese valor actualizado y lo incrementa una vez más.

Sin embargo, como es una instancia nueva por solicitud HTTP, el aislamiento entre usuarios está garantizado. Si John y Jane llaman a este endpoint exactamente al mismo tiempo, .NET crea dos ámbitos de solicitud HTTP completamente separados. Las acciones de John nunca se mezclarán ni alterarán los datos del contador de Jane.

Transient

c#
builder.Services.AddTransient<ICounterService, CounterServiceA>();
Program.cs

El registro Transient funciona casi completamente al revés que Scoped, aunque sigue manteniendo el aislamiento entre usuarios distintos. En un ciclo de vida transient, .NET crea una instancia completamente nueva cada vez que se solicita el servicio, incluso si se inyectan lado a lado dentro de la misma solicitud HTTP.

Veamos exactamente el mismo controlador, suponiendo que el conteo empieza en cero y cada llamada suma uno:

c#
[Route("api/[controller]/[action]")]
[ApiController]
public class CounterController(
    ICounterService counterService1,
    ICounterService counterService2
) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> AddToCount()
    {
        await counterService1.AddToCount(); // el conteo de counterService1 pasa a 1
        await counterService2.AddToCount(); // el conteo de counterService2 pasa a 1
        return Ok();
    }
}

Dado que el registro es transient, los dos servicios están completamente aislados entre sí dentro del constructor. Cuando counterService1 incrementa el conteo, está modificando su propia instancia única. Cuando counterService2 se ejecuta después, modifica una instancia completamente distinta. Como resultado, el conteo interno de cada uno solo llega a 1.

Singleton

c#
builder.Services.AddSingleton<ICounterService, CounterServiceA>();
Program.cs

El registro Singleton crea una única instancia para toda la API, y cada solicitud HTTP recibe exactamente esa misma instancia sin importar quién la llame ni en qué parte de la pila esté. Volvamos al ejemplo del controlador una vez más, pero esta vez con dos métodos HTTP distintos. Supongamos de nuevo que el conteo empieza en cero y cada llamada suma uno. Esta vez, John llama a JohnAddToCount(), seguido inmediatamente por Jane llamando a JaneAddToCount():

c#
[Route("api/[controller]/[action]")]
[ApiController]
public class CounterController(
    ICounterService counterService1,
    ICounterService counterService2
) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> JohnAddToCount()
    {
        await counterService1.AddToCount(); // el conteo pasa a 1
        await counterService2.AddToCount(); // el conteo pasa a 2
        return Ok();
    }

    [HttpGet]
    public async Task<IActionResult> JaneAddToCount()
    {
        await counterService1.AddToCount(); // el conteo pasa a 3
        await counterService2.AddToCount(); // el conteo pasa a 4
        return Ok();
    }
}

Notarás que el conteo subió en orden secuencial. ¿Por qué? Como es un servicio singleton, cuando John llamó a JohnAddToCount() interactuó con la única instancia compartida que .NET resolvió, aunque se haya inyectado en dos variables. Luego Jane llamó a JaneAddToCount() e interactuó exactamente con esa misma instancia compartida. En esencia, un Singleton NO está aislado entre llamadas HTTP ni entre usuarios distintos.

El problema de la seguridad entre hilos

Esto presenta un nuevo problema. Para que AddToCount() funcione, tiene que leer el valor de Count, sumarle y volver a escribirlo. ¿Qué pasa cuando John y Jane llaman a sus endpoints casi al mismo tiempo? Ambas solicitudes pueden leer el mismo valor inicial, ambas escriben el mismo resultado y uno de los incrementos se pierde. Eso es una condición de carrera, y es a lo que nos referimos cuando decimos que algo no es «thread-safe» (seguro para hilos).

Creo que haré una entrada aparte sobre cómo manejar la seguridad entre hilos en distintos escenarios, pero para este contador en particular podemos usar una operación atómica (Interlocked) para que sea completamente seguro entre varios hilos:

c#
public Task AddToCount()
{
    // Incrementa el valor en memoria de forma atómica, de manera segura entre todos los hilos
    Interlocked.Increment(ref Count);

    // Si necesitas incrementar en otro número, como 2, usa:
    // Interlocked.Add(ref Count, 2);

    return Task.CompletedTask;
}

Dónde encaja cada ciclo de vida

Ahora que hemos cubierto los tres ciclos de vida básicos de los servicios, veamos muy rápidamente cómo se usan y por qué. Ten en cuenta que la forma de usarlos no es ley, y puedes usarlos como mejor sirva a las necesidades de tu aplicación.

Entity Framework Core (EF Core) es un buen ejemplo de Scoped. Cuando registras tu DbContext con AddDbContext, es Scoped de forma predeterminada. ¿Por qué? Cuando ejecutas consultas y actualizaciones a la base de datos durante una llamada a la API, quieres que todos tus cambios se rastreen bajo una única instancia aislada, asegurando que tus acciones en la base de datos no interfieran con otros usuarios. Por debajo, EF Core rastrea todo lo que haces. Cuando consultas un modelo y actualizas un campo, lo único que tienes que hacer es guardar los cambios al final de esa solicitud específica.

Además, como es Scoped, .NET desecha automáticamente el contexto de la base de datos cuando termina la solicitud HTTP, y la conexión a la base de datos regresa al pool de conexiones. (¡Profundizaré mucho más en EF Core en una entrada posterior!)

Transient se usa para implementaciones ligeras y sin estado. Por ejemplo, validadores y depuradores de datos que se pueden llamar bajo demanda y desechar de inmediato sin que nada más en tu aplicación se vea afectado. No guardan ningún estado persistente ni datos locales, lo que significa que se crean, hacen su trabajo rápido y se desechan sin ninguna consecuencia.

Por último, un Singleton se usa para datos que necesitan compartirse globalmente en toda la aplicación. Por ejemplo, puedes usar un Singleton como caché en memoria: en lugar de consultar tu base de datos miles de veces para los mismos registros estáticos, puedes mantener esos datos en memoria mediante un Singleton y leerlos al instante desde ahí, liberando a tu base de datos de lecturas excesivas. Además, puedes usarlo para guardar configuraciones globales de la aplicación o configuraciones de API cargadas al inicio. Incluso puedes usar una cola Singleton para alimentar a un proceso en segundo plano, como un servicio que procesa y envía SMS o correos electrónicos: cualquier solicitud puede dejar una tarea en la cola, y un servicio en segundo plano hospedado las toma y las procesa.

Una trampa de la que hay que cuidarse: un Singleton no debería depender de un servicio Scoped como un DbContext. Un Singleton vive durante toda la vida de la aplicación, así que retendría la instancia scoped de una sola solicitud mucho después de que esa solicitud haya terminado. En Development, .NET valida esto y lanza un error para avisarte.

La conclusión

Eso es la inyección de dependencias en pocas palabras: programa contra una interfaz, registra la implementación en un solo lugar y elige el ciclo de vida que se ajuste a lo que hace el servicio. Cambiar de implementación después se vuelve un cambio de una sola línea en lugar de un buscar y reemplazar en todo tu código.


Contáctame