BackDev Patterns & Practices
Solid Principles//

Principio de Inversión de Dependencias

Explica la Inversión de Dependencias con ejemplos de inyección y abstracciones para desacoplar módulos, haciendo el código más fácil de testear y evolucionar.

Introducción

El Principio de Inversión de Dependencias (DIP) establece dos cosas: los módulos de alto nivel no deben depender de módulos de bajo nivel, ambos deben depender de abstracciones. Y: las abstracciones no deben depender de detalles, los detalles deben depender de abstracciones.

En la práctica, DIP significa que tu lógica de negocio (comandos, handlers, servicios de dominio) nunca debe referenciar directamente detalles de infraestructura (conexiones a bases de datos, SDKs de nube, clientes HTTP). En su lugar, referencian interfaces. Las implementaciones concretas se inyectan en tiempo de ejecución a través de un contenedor de inyección de dependencias.

Este principio es la piedra angular de SOLID. SRP asegura que cada clase tiene una responsabilidad. OCP permite extensión sin modificación. LSP garantiza que los subtipos son seguros. ISP mantiene las interfaces enfocadas. DIP los une a todos asegurando que la dirección de las dependencias fluya desde los detalles concretos hacia las abstracciones estables.

En este artículo, exploramos DIP a través de un escenario real: construir un sistema de gestión de Comunidad de Práctica. Verás cómo la arquitectura de Gathering invierte dependencias a través de cada capa, y qué sucede cuando te saltas este paso.

El Problema: Código de Alto Nivel Encadenado a Detalles de Bajo Nivel

Un Ejemplo Simple: Servicio de Notificaciones

Antes de sumergirnos en el código de Gathering, veamos una violación simple de DIP: un servicio de notificaciones que depende directamente de una librería de email. Esto ilustra el principio central sin necesitar contexto de aplicación.

Sin DIP: Dependencia Directa de la Implementación

1public class OrderService
2{
3    // Dependencia directa de una librería concreta de email
4    private readonly SmtpClient _smtpClient;
5    private readonly SqlConnection _database;
6
7    public OrderService()
8    {
9        // Crea sus propias dependencias - fuertemente acoplado
10        _smtpClient = new SmtpClient("smtp.company.com", 587);
11        _database = new SqlConnection(
12            "Server=prod-db;Database=Orders;...");
13    }
14
15    public void PlaceOrder(Order order)
16    {
17        // Lógica de negocio mezclada con infraestructura
18        _database.Open();
19        var cmd = new SqlCommand(
20            "INSERT INTO Orders ...", _database);
21        cmd.ExecuteNonQuery();
22        _database.Close();
23
24        // Directamente acoplado a implementación SMTP
25        var message = new MailMessage(
26            "noreply@company.com",
27            order.CustomerEmail,
28            "Orden Confirmada",
29            $"Tu orden {order.Id} ha sido realizada.");
30        _smtpClient.Send(message);
31    }
32}

El problema: OrderService crea directamente SmtpClient y SqlConnection. No puedes probar sin un servidor SMTP real y una base de datos. No puedes cambiar de SMTP a SendGrid, o de SQL Server a PostgreSQL, sin reescribir OrderService. La política de alto nivel (realizar pedidos) está encadenada a detalles de bajo nivel (SMTP, SQL).

Con DIP: Depender de Abstracciones

1// Abstracciones definidas por el módulo de alto nivel
2public interface IOrderRepository
3{
4    Task SaveOrderAsync(Order order, CancellationToken ct = default);
5}
6
7public interface INotificationService
8{
9    Task SendOrderConfirmationAsync(Order order, CancellationToken ct = default);
10}
11
12// El módulo de alto nivel depende de abstracciones
13public class OrderService
14{
15    private readonly IOrderRepository _repository;
16    private readonly INotificationService _notificationService;
17
18    // Las dependencias son INYECTADAS, no creadas
19    public OrderService(
20        IOrderRepository repository,
21        INotificationService notificationService)
22    {
23        _repository = repository;
24        _notificationService = notificationService;
25    }
26
27    public async Task PlaceOrderAsync(Order order, CancellationToken ct)
28    {
29        await _repository.SaveOrderAsync(order, ct);
30        await _notificationService.SendOrderConfirmationAsync(order, ct);
31    }
32}
33
34// Los módulos de bajo nivel implementan las abstracciones
35public class SqlOrderRepository : IOrderRepository
36{
37    private readonly DbContext _dbContext;
38
39    public SqlOrderRepository(DbContext dbContext)
40    {
41        _dbContext = dbContext;
42    }
43
44    public async Task SaveOrderAsync(Order order, CancellationToken ct)
45    {
46        _dbContext.Orders.Add(order);
47        await _dbContext.SaveChangesAsync(ct);
48    }
49}
50
51public class SmtpNotificationService : INotificationService
52{
53    private readonly SmtpClient _smtpClient;
54
55    public SmtpNotificationService(SmtpClient smtpClient)
56    {
57        _smtpClient = smtpClient;
58    }
59
60    public async Task SendOrderConfirmationAsync(
61        Order order, CancellationToken ct)
62    {
63        var message = new MailMessage(
64            "noreply@company.com",
65            order.CustomerEmail,
66            "Orden Confirmada",
67            $"Tu orden {order.Id} ha sido realizada.");
68        await _smtpClient.SendMailAsync(message, ct);
69    }
70}

Beneficios de DIP Aquí:

  • ✓ Testeable: Inyecta implementaciones mock. No se necesita un servidor real de base de datos o SMTP.
  • ✓ Intercambiable: Cambia de SMTP a SendGrid creando una nueva implementación de INotificationService. OrderService nunca cambia.
  • ✓ Desacoplado: OrderService no sabe nada sobre SQL, SMTP ni ninguna infraestructura. Solo habla en abstracciones.

Este patrón simple (inyectar abstracciones en lugar de crear dependencias concretas) es la base de DIP. Ahora apliquemoslo a un sistema más grande y del mundo real.

Caso de Estudio: Arquitectura de Dependencias en Gathering

La arquitectura de Gathering es un ejemplo de libro de texto de DIP aplicado a nivel arquitectónico. El proyecto tiene cuatro capas, y el flujo de dependencias está deliberadamente invertido:

Flujo de Dependencias por Capas:

  • Gathering.SharedKernel: Tipos base (Entity, Result, Error). No depende de nada.
  • Gathering.Domain: Entidades e interfaces de repositorio. Depende solo de SharedKernel.
  • Gathering.Application: Handlers de comandos, handlers de consultas, abstracciones. Depende de Domain y SharedKernel.
  • Gathering.Infrastructure: EF Core, Azure Blob Storage, implementaciones. Depende de Application y Domain,implementa sus abstracciones.
  • Gathering.Api: Endpoints y raíz de composición de DI. Conecta todo.

La idea crítica: Infrastructure depende de Domain y Application, no al revés. La lógica de negocio de alto nivel define las interfaces; la infraestructura de bajo nivel las implementa.

La Violación: Handler Acoplado al SDK de Azure

Imagina que un desarrollador se salta la abstracción y referencia Azure Blob Storage directamente en el handler de comando:

1// ✗ VIOLACIÓN: Handler de alto nivel depende directamente del SDK de Azure
2using Azure.Storage.Blobs;
3using Azure.Storage.Blobs.Models;
4
5public sealed class CreateCommunityCommandHandler
6{
7    private readonly ApplicationDbContext _dbContext;
8    private readonly BlobServiceClient _blobServiceClient;
9
10    public CreateCommunityCommandHandler(
11        ApplicationDbContext dbContext,
12        BlobServiceClient blobServiceClient)  // ¡Dependencia directa del SDK de Azure!
13    {
14        _dbContext = dbContext;
15        _blobServiceClient = blobServiceClient;
16    }
17
18    public async Task<Result<Guid>> HandleAsync(
19        CreateCommunityCommand command, CancellationToken ct)
20    {
21        var result = Community.Create(command.Name, command.Description);
22        if (result.IsFailure) return Result.Failure<Guid>(result.Error);
23
24        // Lógica de negocio contaminada con código específico de Azure
25        if (command.ImageStream is not null)
26        {
27            var containerClient = _blobServiceClient
28                .GetBlobContainerClient("images");
29            await containerClient.CreateIfNotExistsAsync(
30                cancellationToken: ct);
31
32            var extension = Path.GetExtension(command.ImageFileName).ToLower();
33            var blobName = $"communities/{Guid.NewGuid()}{extension}";
34            var blobClient = containerClient.GetBlobClient(blobName);
35
36            command.ImageStream.Seek(0, SeekOrigin.Begin);
37            var uploadOptions = new BlobUploadOptions
38            {
39                HttpHeaders = new BlobHttpHeaders 
40                { 
41                    ContentType = command.ImageContentType 
42                }
43            };
44
45            await blobClient.UploadAsync(
46                command.ImageStream, uploadOptions, ct);
47
48            // Construcción de URI específica de Azure
49            result.Value.Update(
50                command.Name, command.Description, 
51                blobClient.Uri.AbsoluteUri);
52        }
53
54        _dbContext.Communities.Add(result.Value);
55        await _dbContext.SaveChangesAsync(ct);
56
57        return Result.Success(result.Value.Id);
58    }
59}

Problemas con Dependencias Directas:

  • No testeable: Las pruebas requieren una cuenta real de Azure Blob Storage. No puedes hacer pruebas unitarias de la lógica de negocio sin infraestructura en la nube.
  • Atado a Azure: Cambiar a AWS S3 o almacenamiento local significa reescribir el handler. La lógica de negocio y la infraestructura son inseparables.
  • La capa Application referencia Infrastructure: El handler (capa Application) importa Azure.Storage.Blobs (detalle de Infrastructure). La flecha de dependencia apunta en la dirección equivocada.
  • Abstracciones con fugas: BlobServiceClient, BlobContainerClient, BlobUploadOptions, todos tipos específicos de Azure se filtran en la lógica de negocio.
  • Responsabilidades mezcladas: El handler sabe cómo Azure genera nombres de blob, cómo establecer tipos de contenido y cómo funcionan las URIs. Ese no es su trabajo.

El Enfoque Correcto: Abstracciones Propiedad de los Módulos de Alto Nivel

Gathering invierte esta dependencia. La capa Application define la interfaz; la capa Infrastructure la implementa. El handler nunca ve Azure.

Paso 1: Abstracción en la Capa Application

1// De: Gathering.Application/Abstractions/IImageStorageService.cs
2// DEFINIDA en Application - el módulo de alto nivel ES DUEÑO de la interfaz
3public interface IImageStorageService
4{
5    Task<Result<string>> UploadImageAsync(
6        Stream imageStream,
7        string fileName,
8        string contentType,
9        string entityType,
10        CancellationToken cancellationToken = default);
11
12    Task<Result> DeleteImageAsync(
13        string imageUrl, 
14        CancellationToken cancellationToken = default);
15}

Observa dónde vive esta interfaz: Gathering.Application. No en Infrastructure. El módulo de alto nivel define lo que necesita. El módulo de bajo nivel se adapta para servirlo.

Paso 2: Implementación en la Capa Infrastructure

1// De: Gathering.Infrastructure/Storage/AzureBlobStorageService.cs
2// IMPLEMENTA la interfaz definida por la capa Application
3internal sealed class AzureBlobStorageService(
4    BlobServiceClient blobServiceClient) : IImageStorageService
5{
6    private const string ContainerName = "images";
7
8    public async Task<Result<string>> UploadImageAsync(
9        Stream imageStream, string fileName, string contentType,
10        string entityType, CancellationToken cancellationToken = default)
11    {
12        try
13        {
14            var containerClient = blobServiceClient
15                .GetBlobContainerClient(ContainerName);
16            await containerClient.CreateIfNotExistsAsync(
17                cancellationToken: cancellationToken);
18
19            var extension = Path.GetExtension(fileName).ToLower();
20            var blobName = $"{entityType}/{Guid.NewGuid()}{extension}";
21            var blobClient = containerClient.GetBlobClient(blobName);
22
23            imageStream.Seek(0, SeekOrigin.Begin);
24
25            var uploadOptions = new BlobUploadOptions
26            {
27                HttpHeaders = new BlobHttpHeaders 
28                { 
29                    ContentType = contentType 
30                }
31            };
32
33            await blobClient.UploadAsync(
34                imageStream, uploadOptions, cancellationToken);
35
36            return Result.Success(blobClient.Uri.AbsoluteUri);
37        }
38        catch (Exception ex)
39        {
40            return Result.Failure<string>(
41                ImageStorageError.UploadFailed(ex.Message));
42        }
43    }
44
45    public async Task<Result> DeleteImageAsync(
46        string imageUrl, CancellationToken cancellationToken = default)
47    {
48        try
49        {
50            var uri = new Uri(imageUrl);
51            var blobClient = new BlobClient(uri);
52            await blobClient.DeleteIfExistsAsync(
53                cancellationToken: cancellationToken);
54
55            return Result.Success();
56        }
57        catch (Exception ex)
58        {
59            return Result.Failure(
60                ImageStorageError.DeleteFailed(ex.Message));
61        }
62    }
63}

Todo el código específico de Azure está aislado aquí. La clase es internal sealed, nada fuera de Infrastructure siquiera sabe que existe. Lo único visible es la interfaz IImageStorageService.

Paso 3: El Handler Depende Solo de Abstracciones

1// De: Gathering.Application/Communities/Create/CreateCommunityCommandHandler.cs
2public sealed class CreateCommunityCommandHandler 
3    : ICommandHandler<CreateCommunityCommand, Guid>
4{
5    private readonly ICommunityRepository _communityRepository;
6    private readonly IUnitOfWork _unitOfWork;
7    private readonly IValidator<CreateCommunityCommand> _validator;
8    private readonly IImageStorageService _imageStorageService;
9
10    // Cada dependencia es una ABSTRACCIÓN
11    public CreateCommunityCommandHandler(
12        ICommunityRepository communityRepository,    // Abstracción
13        IUnitOfWork unitOfWork,                       // Abstracción
14        IValidator<CreateCommunityCommand> validator,  // Abstracción
15        IImageStorageService imageStorageService)      // Abstracción
16    {
17        _communityRepository = communityRepository;
18        _unitOfWork = unitOfWork;
19        _validator = validator;
20        _imageStorageService = imageStorageService;
21    }
22
23    public async Task<Result<Guid>> HandleAsync(
24        CreateCommunityCommand command, 
25        CancellationToken cancellationToken = default)
26    {
27        var validationResult = await _validator
28            .ValidateAsync(command, cancellationToken);
29        if (!validationResult.IsValid)
30        {
31            var errors = string.Join("; ", 
32                validationResult.Errors.Select(e => e.ErrorMessage));
33            return Result.Failure<Guid>(
34                Error.Validation("CreateCommunity.ValidationFailed", errors));
35        }
36
37        string? imageUrl = null;
38        if (command.ImageStream is not null 
39            && command.ImageFileName is not null 
40            && command.ImageContentType is not null)
41        {
42            // Usa la ABSTRACCIÓN - no hay código de Azure aquí
43            var uploadResult = await _imageStorageService.UploadImageAsync(
44                command.ImageStream,
45                command.ImageFileName,
46                command.ImageContentType,
47                "communities",
48                cancellationToken);
49
50            if (uploadResult.IsFailure)
51                return Result.Failure<Guid>(uploadResult.Error);
52
53            imageUrl = uploadResult.Value;
54        }
55
56        var result = Community.Create(
57            command.Name, command.Description, imageUrl);
58        if (result.IsFailure)
59        {
60            if (imageUrl is not null)
61                await _imageStorageService
62                    .DeleteImageAsync(imageUrl, cancellationToken);
63            return Result.Failure<Guid>(result.Error);
64        }
65
66        _communityRepository.Add(result.Value);
67        await _unitOfWork.SaveChangesAsync(cancellationToken);
68
69        return Result.Success(result.Value.Id);
70    }
71}

DIP en Acción: Mira los Imports:

  • El handler importa: Gathering.Application.Abstractions, Gathering.Domain.Abstractions, Gathering.Domain.Communities, Gathering.SharedKernel
  • El handler NO importa: Azure.Storage.Blobs, Microsoft.EntityFrameworkCore, System.Data.SqlClient, nada de Infrastructure
  • Resultado: El handler puede ser compilado, probado y ejecutado sin ningún ensamblado de Infrastructure

La Raíz de Composición: Conectando Todo

Si los handlers dependen de abstracciones y la infraestructura las implementa, ¿dónde ocurre la conexión? En la raíz de composición, la configuración de inicio donde el contenedor de DI mapea interfaces a implementaciones.

Registro de DI de Infrastructure

1// De: Gathering.Infrastructure/DependencyInjection.cs
2public static class DependencyInjection
3{
4    public static IServiceCollection AddInfrastructureServices(
5        this IServiceCollection services, IConfiguration configuration)
6    {
7        // Base de datos - mapea IUnitOfWork a ApplicationDbContext
8        services.AddDbContext<ApplicationDbContext>(options =>
9        {
10            options.UseSqlServer(
11                configuration.GetConnectionString("DefaultConnection"),
12                sqlOptions =>
13                {
14                    sqlOptions.EnableRetryOnFailure(
15                        maxRetryCount: 5,
16                        maxRetryDelay: TimeSpan.FromSeconds(30),
17                        errorNumbersToAdd: null);
18                });
19        });
20
21        // Abstracción de tiempo
22        services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
23
24        // Persistencia - mapea abstracciones a implementaciones
25        services.AddScoped<IUnitOfWork>(
26            provider => provider.GetRequiredService<ApplicationDbContext>());
27        services.AddScoped<ICommunityRepository, CommunityRepository>();
28        services.AddScoped<ISessionRepository, SessionRepository>();
29
30        // Almacenamiento - mapea IImageStorageService a implementación de Azure
31        var connectionString = configuration
32            .GetSection("AzureStorage:ConnectionString").Value
33            ?? throw new InvalidOperationException(
34                "AzureStorage:ConnectionString no está configurado.");
35        services.AddSingleton(new BlobServiceClient(connectionString));
36        services.AddScoped<IImageStorageService, AzureBlobStorageService>();
37
38        return services;
39    }
40}

Registro de DI de Application

1// De: Gathering.Application/DependencyInjection.cs
2public static class DependencyInjection
3{
4    public static IServiceCollection AddApplicationServices(
5        this IServiceCollection services)
6    {
7        // Mediator - mapea ISender a Sender
8        services.AddScoped<ISender, Sender>();
9
10        // Auto-registrar todos los handlers de comandos y consultas
11        var assemblies = new[] { typeof(DependencyInjection).Assembly };
12        services.RegisterHandlers(assemblies);
13
14        // Auto-registrar todos los validadores de FluentValidation
15        services.AddValidatorsFromAssembly(
16            typeof(DependencyInjection).Assembly);
17
18        return services;
19    }
20}

El Patrón DIP en Cada Nivel:

  • ICommunityRepository → CommunityRepository: Domain define la interfaz. Infrastructure proporciona la implementación EF Core.
  • IUnitOfWork → ApplicationDbContext: Domain define el contrato de persistencia. Infrastructure lo mapea al DbContext.
  • IImageStorageService → AzureBlobStorageService: Application define el contrato de almacenamiento. Infrastructure proporciona la implementación de Azure.
  • IDateTimeProvider → DateTimeProvider: Incluso el tiempo está abstraído. Las pruebas pueden inyectar un proveedor de tiempo fijo para comportamiento determinístico.
  • ISender → Sender: El patrón mediator mismo sigue DIP, los handlers no saben cómo se despachan los mensajes.

Por Qué las Abstracciones Viven en Domain y Application

Un detalle crítico de DIP en Gathering: las interfaces están definidas donde se necesitan, no donde se implementan. Esta es la inversión.

1// ✗ INCORRECTO: Interfaz definida junto a su implementación
2// Archivo: Gathering.Infrastructure/Repositories/ISessionRepository.cs
3// Esto haría que Domain dependa de Infrastructure
4
5// ✓ CORRECTO: Interfaz definida donde se consume
6// Archivo: Gathering.Domain/Sessions/ISessionRepository.cs
7// Infrastructure depende de Domain para implementar esta interfaz
8
9// Las flechas de dependencia:
10// 
11// Gathering.Domain (define ISessionRepository, IUnitOfWork)
12//     ↑
13// Gathering.Application (define IImageStorageService, usa interfaces de Domain)
14//     ↑
15// Gathering.Infrastructure (implementa TODAS las interfaces)
16//     ↑
17// Gathering.Api (raíz de composición, conecta interfaces a implementaciones)

Esta es la inversión en Inversión de Dependencias. Sin DIP, el flujo natural de dependencias sería:

1// Sin DIP (flujo de dependencia tradicional):
2// Domain → Infrastructure (Domain usa SqlConnection directamente)
3// Application → Infrastructure (Handler usa BlobServiceClient directamente)
4// Esto significa que la lógica de negocio DEPENDE DE detalles de infraestructura
5
6// Con DIP (flujo de dependencia invertido):
7// Infrastructure → Domain (Infrastructure implementa IRepository)
8// Infrastructure → Application (Infrastructure implementa IImageStorageService)
9// Esto significa que los detalles de infraestructura DEPENDEN DE abstracciones de negocio

Testing: Donde DIP Da sus Frutos

El beneficio más tangible de DIP es la testeabilidad. Porque los handlers dependen de abstracciones, puedes sustituir cualquier implementación, incluyendo dobles de prueba.

1// Prueba unitaria de CreateCommunityCommandHandler
2// NO se necesita cuenta de Azure. NO se necesita base de datos.
3
4public class CreateCommunityCommandHandlerTests
5{
6    [Test]
7    public async Task Handle_WithValidCommand_CreatesCommunity()
8    {
9        // Arrange - todas las dependencias son dobles de prueba
10        var mockRepository = new MockCommunityRepository();
11        var mockUnitOfWork = new MockUnitOfWork();
12        var mockValidator = new AlwaysValidValidator();
13        var mockImageStorage = new InMemoryImageStorage();
14
15        var handler = new CreateCommunityCommandHandler(
16            mockRepository,
17            mockUnitOfWork,
18            mockValidator,
19            mockImageStorage);
20
21        var command = new CreateCommunityCommand(
22            "Comunidad de Código Limpio",
23            "Una comunidad enfocada en prácticas de código limpio");
24
25        // Act
26        var result = await handler.HandleAsync(
27            command, CancellationToken.None);
28
29        // Assert
30        Assert.That(result.IsSuccess, Is.True);
31        Assert.That(mockRepository.Added.Count, Is.EqualTo(1));
32        Assert.That(
33            mockRepository.Added[0].Name, 
34            Is.EqualTo("Comunidad de Código Limpio"));
35        Assert.That(mockUnitOfWork.SavedChanges, Is.True);
36    }
37
38    [Test]
39    public async Task Handle_WithImage_UploadsToStorage()
40    {
41        var mockImageStorage = new InMemoryImageStorage();
42        var handler = new CreateCommunityCommandHandler(
43            new MockCommunityRepository(),
44            new MockUnitOfWork(),
45            new AlwaysValidValidator(),
46            mockImageStorage);
47
48        var imageStream = new MemoryStream(new byte[] { 1, 2, 3 });
49        var command = new CreateCommunityCommand(
50            "Comunidad", "Descripción")
51        {
52            ImageStream = imageStream,
53            ImageFileName = "logo.png",
54            ImageContentType = "image/png"
55        };
56
57        await handler.HandleAsync(command, CancellationToken.None);
58
59        // Verificar que la imagen fue subida a través de la abstracción
60        Assert.That(mockImageStorage.UploadedFiles.Count, Is.EqualTo(1));
61        Assert.That(
62            mockImageStorage.UploadedFiles[0].EntityType, 
63            Is.EqualTo("communities"));
64    }
65}
66
67// Doble de prueba - trivial de implementar porque la interfaz es pequeña
68public class InMemoryImageStorage : IImageStorageService
69{
70    public List<(Stream Stream, string FileName, string EntityType)> 
71        UploadedFiles = new();
72
73    public Task<Result<string>> UploadImageAsync(
74        Stream imageStream, string fileName, string contentType,
75        string entityType, CancellationToken ct = default)
76    {
77        UploadedFiles.Add((imageStream, fileName, entityType));
78        return Task.FromResult(
79            Result.Success($"https://test.blob.core/{entityType}/{fileName}"));
80    }
81
82    public Task<Result> DeleteImageAsync(
83        string imageUrl, CancellationToken ct = default)
84    {
85        return Task.FromResult(Result.Success());
86    }
87}

DIP en TypeScript

TypeScript logra DIP a través de interfaces e inyección por constructor. Aquí está el mismo patrón en un contexto Node.js/Express:

1// Abstracciones - definidas por el módulo de alto nivel
2interface ICommunityRepository {
3  getById(id: string): Promise<Community | null>;
4  getAll(): Promise<ReadonlyArray<Community>>;
5  add(community: Community): void;
6}
7
8interface IUnitOfWork {
9  saveChanges(): Promise<number>;
10}
11
12interface IImageStorageService {
13  uploadImage(
14    stream: NodeJS.ReadableStream,
15    fileName: string,
16    contentType: string,
17    entityType: string
18  ): Promise<Result<string>>;
19
20  deleteImage(imageUrl: string): Promise<Result<void>>;
21}
22
23// Módulo de alto nivel - depende SOLO de abstracciones
24class CreateCommunityHandler {
25  constructor(
26    private repository: ICommunityRepository,
27    private unitOfWork: IUnitOfWork,
28    private imageStorage: IImageStorageService
29  ) {}
30
31  async handle(command: CreateCommunityCommand): Promise<Result<string>> {
32    const result = Community.create(command.name, command.description);
33    if (!result.isSuccess) return failure(result.error);
34
35    if (command.imageStream) {
36      const uploadResult = await this.imageStorage.uploadImage(
37        command.imageStream,
38        command.imageFileName,
39        command.imageContentType,
40        "communities"
41      );
42
43      if (!uploadResult.isSuccess) {
44        return failure(uploadResult.error);
45      }
46    }
47
48    this.repository.add(result.value);
49    await this.unitOfWork.saveChanges();
50    return success(result.value.id);
51  }
52}
53
54// Módulo de bajo nivel - implementación de Azure
55class AzureBlobImageStorage implements IImageStorageService {
56  constructor(private blobServiceClient: BlobServiceClient) {}
57
58  async uploadImage(
59    stream: NodeJS.ReadableStream,
60    fileName: string,
61    contentType: string,
62    entityType: string
63  ): Promise<Result<string>> {
64    const containerClient = this.blobServiceClient
65      .getContainerClient("images");
66    const ext = fileName.slice(fileName.lastIndexOf("."));
67    const blobName = entityType + "/" + crypto.randomUUID() + ext;
68    const blobClient = containerClient.getBlockBlobClient(blobName);
69
70    await blobClient.uploadStream(stream);
71    return success(blobClient.url);
72  }
73
74  async deleteImage(imageUrl: string): Promise<Result<void>> {
75    const blobClient = new BlobClient(imageUrl);
76    await blobClient.delete();
77    return success(undefined);
78  }
79}
80
81// Raíz de composición (ej. en el arranque de la app)
82function configureServices(): CreateCommunityHandler {
83  const blobClient = new BlobServiceClient(connectionString);
84  const dbContext = new PrismaClient();
85
86  const repository = new PrismaCommunityRepository(dbContext);
87  const unitOfWork = new PrismaUnitOfWork(dbContext);
88  const imageStorage = new AzureBlobImageStorage(blobClient);
89
90  return new CreateCommunityHandler(repository, unitOfWork, imageStorage);
91}
92
93// Configuración de prueba - intercambia implementaciones sin cambiar el handler
94function createTestHandler(): CreateCommunityHandler {
95  return new CreateCommunityHandler(
96    new InMemoryCommunityRepository(),
97    new InMemoryUnitOfWork(),
98    new InMemoryImageStorage()
99  );
100}

Beneficios de Aplicar DIP

1. Pruebas Unitarias Reales

Cada handler puede ser probado en completo aislamiento. Sin base de datos, sin servicios en la nube, sin llamadas de red. Las pruebas son rápidas, determinísticas y confiables.

2. Intercambiabilidad de Infraestructura

Gathering usa Azure Blob Storage hoy. Cambiar a AWS S3 significa crear una sola clase nueva: AwsS3StorageService. El handler, el dominio, los validadores, ninguno de ellos cambia.

3. Desarrollo en Paralelo

Un equipo puede construir la capa Application mientras otro construye la capa Infrastructure. Acuerdan las interfaces (IRepository, IImageStorageService) y trabajan independientemente. La raíz de composición los conecta en tiempo de despliegue.

4. Dependencias de Compilación Limpias

El proyecto Application no referencia EF Core, Azure SDK ni ningún paquete NuGet de infraestructura. Esto significa que los cambios en Infrastructure nunca fuerzan la recompilación de Application. Los tiempos de compilación se mantienen rápidos.

5. Límites Arquitectónicos Claros

DIP impone la “arquitectura cebolla”: el núcleo (Domain, Application) no tiene conocimiento de las capas externas (Infrastructure, API). Esto hace que la base de código sea navegable y mantenible a medida que crece.

Trampas del Mundo Real

Trampa 1: Abstraer Todo

No toda clase necesita una interfaz. Las clases utilitarias, los objetos de valor y las entidades de dominio no necesitan abstracciones. Aplica DIP en límites arquitectónicos(donde los módulos cruzan capas (Application → Infrastructure, Domain → Persistencia).

Guía: Abstrae lo volátil (almacenamiento, APIs externas, tiempo, email). No abstraigas lo estable (manipulación de strings, matemáticas, objetos de valor del dominio).

Trampa 2: Interfaz en la Capa Equivocada

Si IImageStorageService estuviera definida en Gathering.Infrastructure, la capa Application necesitaría referenciar Infrastructure, derrotando el propósito. Siempre define las abstracciones en la capa que las consume.

Trampa 3: Contenedor de DI como Service Locator

Evita inyectar IServiceProvider y resolver servicios manualmente. Esto oculta dependencias y hace el código más difícil de entender y probar. Usa inyección explícita por constructor.

Trampa 4: Abstracciones con Fugas

Si tu interfaz expone tipos específicos de Azure (como BlobProperties o S3Response), derrota el propósito de DIP. La interfaz debe usar tipos a nivel de dominio: streams, strings, objetos Result, nunca tipos de infraestructura.

Lista de Verificación: Detectando Violaciones de DIP

Si respondes “sí” a cualquiera de estas, probablemente se esté violando DIP.

Conclusión

El Principio de Inversión de Dependencias es lo que hace que todos los demás principios SOLID funcionen a nivel arquitectónico. Al asegurar que la lógica de negocio de alto nivel depende de abstracciones, no de infraestructura concreta, creas sistemas que son testeables, flexibles y resilientes al cambio.

Gathering demuestra esto a través de su arquitectura por capas: el Domain define interfaces de repositorio, Application define abstracciones de servicio, e Infrastructure las implementa todas. La raíz de composición en la capa API conecta todo al inicio. Ningún handler ve jamás una conexión a base de datos o un SDK de nube.

La idea clave: la dirección de las dependencias del código fuente debe ser opuesta al flujo de control. Tu lógica de negocio llama a métodos de almacenamiento en tiempo de ejecución, pero la dependencia del código fuente apunta desde la implementación de almacenamiento hacia la abstracción de negocio, no al revés.

Esto concluye nuestra serie sobre principios SOLID. Juntos, SRP, OCP, LSP, ISP y DIP forman una filosofía de diseño cohesiva: construye clases pequeñas y enfocadas (SRP) que estén abiertas para extensión (OCP), sean sustituibles de forma segura (LSP), con interfaces enfocadas (ISP) y conectadas a través de abstracciones (DIP). Dominar estos principios es la base para escribir software profesional y mantenible.

About

Dev Patterns & Practices is a space for long-form thinking on design, technology, and craft. Every piece is written with care and the belief that the best ideas deserve room to breathe.