Dependency Inversion Principle
Explains Dependency Inversion with DI examples and abstractions to decouple modules, making code easier to test and evolve.
Introduction
The Dependency Inversion Principle (DIP) states two things: high-level modules should not depend on low-level modules, both should depend on abstractions. And: abstractions should not depend on details, details should depend on abstractions.
In practice, DIP means your business logic (commands, handlers, domain services) should never directly reference infrastructure details (database connections, cloud SDKs, HTTP clients). Instead, they reference interfaces. The concrete implementations are injected at runtime through a dependency injection container.
This principle is the capstone of SOLID. SRP ensures each class has one responsibility. OCP enables extension without modification. LSP guarantees subtypes are safe. ISP keeps interfaces focused. DIP ties them all together by ensuring that the direction of dependencies flows from concrete details toward stable abstractions.
In this article, we explore DIP through a real-world scenario: building a Community of Practice management system. You will see how Gathering's architecture inverts dependencies across every layer, and what happens when you skip this step.
The Problem: High-Level Code Chained to Low-Level Details
A Simple Example: Notification Service
Before diving into the Gathering codebase, let's see a simple DIP violation: a notification service that directly depends on an email library. This illustrates the core principle without needing application context.
Without DIP: Direct Dependency on Implementation
1public class OrderService
2{
3 // Direct dependency on a concrete email library
4 private readonly SmtpClient _smtpClient;
5 private readonly SqlConnection _database;
6
7 public OrderService()
8 {
9 // Creates its own dependencies - tightly coupled
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 // Business logic mixed with infrastructure
18 _database.Open();
19 var cmd = new SqlCommand(
20 "INSERT INTO Orders ...", _database);
21 cmd.ExecuteNonQuery();
22 _database.Close();
23
24 // Directly coupled to SMTP implementation
25 var message = new MailMessage(
26 "noreply@company.com",
27 order.CustomerEmail,
28 "Order Confirmed",
29 $"Your order {order.Id} has been placed.");
30 _smtpClient.Send(message);
31 }
32}The problem: OrderService directly creates SmtpClient and SqlConnection. You cannot test without a real SMTP server and database. You cannot switch from SMTP to SendGrid, or from SQL Server to PostgreSQL, without rewriting OrderService. The high-level policy (placing orders) is chained to low-level details (SMTP, SQL).
With DIP: Depend on Abstractions
1// Abstractions defined by the high-level module
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// High-level module depends on abstractions
13public class OrderService
14{
15 private readonly IOrderRepository _repository;
16 private readonly INotificationService _notificationService;
17
18 // Dependencies are INJECTED, not created
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// Low-level modules implement the abstractions
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 "Order Confirmed",
67 $"Your order {order.Id} has been placed.");
68 await _smtpClient.SendMailAsync(message, ct);
69 }
70}Benefits of DIP Here:
- ✓ Testable: Inject mock implementations. No real database or SMTP server needed.
- ✓ Swappable: Switch from SMTP to SendGrid by creating a new INotificationService implementation. OrderService never changes.
- ✓ Decoupled: OrderService knows nothing about SQL, SMTP, or any infrastructure. It speaks only in abstractions.
This simple pattern, injecting abstractions instead of creating concrete dependencies, is the foundation of DIP. Now let's apply it to a larger, real-world system.
Case Study: Dependency Architecture in Gathering
Gathering's architecture is a textbook example of DIP applied at the architectural level. The project has four layers, and the dependency flow is deliberately inverted:
Layer Dependency Flow:
- Gathering.SharedKernel: Base types (Entity, Result, Error). Depends on nothing.
- Gathering.Domain: Entities and repository interfaces. Depends only on SharedKernel.
- Gathering.Application: Command handlers, query handlers, abstractions. Depends on Domain and SharedKernel.
- Gathering.Infrastructure: EF Core, Azure Blob Storage, implementations. Depends on Application and Domainimplements their abstractions.
- Gathering.Api: Endpoints and DI composition root. Wires everything together.
The critical insight: Infrastructure depends on Domain and Application, not the other way around. The high-level business logic defines the interfaces; the low-level infrastructure implements them.
The Violation: Handler Coupled to Azure SDK
Imagine a developer skips the abstraction and references Azure Blob Storage directly in the command handler:
1// ✗ VIOLATION: High-level handler directly depends on Azure SDK
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) // Direct Azure SDK dependency!
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 // Business logic polluted with Azure-specific code
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 // Azure-specific URI construction
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}Problems with Direct Dependencies:
- Untestable: Testing requires a real Azure Blob Storage account. You cannot unit test the business logic without cloud infrastructure.
- Locked to Azure: Switching to AWS S3 or local storage means rewriting the handler. Business logic and infrastructure are inseparable.
- Application layer references Infrastructure: The handler (Application layer) imports Azure.Storage.Blobs (Infrastructure detail). The dependency arrow points the wrong way.
- Leaked abstractions: BlobServiceClient, BlobContainerClient, BlobUploadOptions, all Azure-specific types leak into business logic.
- Mixed responsibilities: The handler knows how Azure generates blob names, how to set content types, and how URIs work. That is not its job.
The Right Approach: Abstractions Owned by High-Level Modules
Gathering inverts this dependency. The Application layer defines the interface; the Infrastructure layer implements it. The handler never sees Azure.
Step 1: Abstraction in the Application Layer
1// From: Gathering.Application/Abstractions/IImageStorageService.cs
2// DEFINED in Application - the high-level module OWNS the interface
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}Notice where this interface lives: Gathering.Application. Not in Infrastructure. The high-level module defines what it needs. The low-level module adapts to serve it.
Step 2: Implementation in the Infrastructure Layer
1// From: Gathering.Infrastructure/Storage/AzureBlobStorageService.cs
2// IMPLEMENTS the interface defined by the Application layer
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}All Azure-specific code is isolated here. The class is internal sealednothing outside Infrastructure even knows it exists. The only thing visible is the IImageStorageService interface.
Step 3: Handler Depends Only on Abstractions
1// From: 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 // Every dependency is an ABSTRACTION
11 public CreateCommunityCommandHandler(
12 ICommunityRepository communityRepository, // Abstraction
13 IUnitOfWork unitOfWork, // Abstraction
14 IValidator<CreateCommunityCommand> validator, // Abstraction
15 IImageStorageService imageStorageService) // Abstraction
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 // Uses the ABSTRACTION - no Azure code here
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 in Action, Look at the Imports:
- Handler imports: Gathering.Application.Abstractions, Gathering.Domain.Abstractions, Gathering.Domain.Communities, Gathering.SharedKernel
- Handler does NOT import: Azure.Storage.Blobs, Microsoft.EntityFrameworkCore, System.Data.SqlClient, nothing from Infrastructure
- Result: The handler can be compiled, tested, and run without any Infrastructure assembly
The Composition Root: Wiring It All Together
If handlers depend on abstractions and infrastructure implements them, where does the wiring happen? In the composition root (the startup configuration where the DI container maps interfaces to implementations.
Infrastructure DI Registration
1// From: Gathering.Infrastructure/DependencyInjection.cs
2public static class DependencyInjection
3{
4 public static IServiceCollection AddInfrastructureServices(
5 this IServiceCollection services, IConfiguration configuration)
6 {
7 // Database - maps IUnitOfWork to 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 // Time abstraction
22 services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
23
24 // Persistence - maps abstractions to implementations
25 services.AddScoped<IUnitOfWork>(
26 provider => provider.GetRequiredService<ApplicationDbContext>());
27 services.AddScoped<ICommunityRepository, CommunityRepository>();
28 services.AddScoped<ISessionRepository, SessionRepository>();
29
30 // Storage - maps IImageStorageService to Azure implementation
31 var connectionString = configuration
32 .GetSection("AzureStorage:ConnectionString").Value
33 ?? throw new InvalidOperationException(
34 "AzureStorage:ConnectionString is not configured.");
35 services.AddSingleton(new BlobServiceClient(connectionString));
36 services.AddScoped<IImageStorageService, AzureBlobStorageService>();
37
38 return services;
39 }
40}Application DI Registration
1// From: Gathering.Application/DependencyInjection.cs
2public static class DependencyInjection
3{
4 public static IServiceCollection AddApplicationServices(
5 this IServiceCollection services)
6 {
7 // Mediator - maps ISender to Sender
8 services.AddScoped<ISender, Sender>();
9
10 // Auto-register all command and query handlers
11 var assemblies = new[] { typeof(DependencyInjection).Assembly };
12 services.RegisterHandlers(assemblies);
13
14 // Auto-register all FluentValidation validators
15 services.AddValidatorsFromAssembly(
16 typeof(DependencyInjection).Assembly);
17
18 return services;
19 }
20}The DIP Pattern at Every Level:
- ICommunityRepository → CommunityRepository: Domain defines the interface. Infrastructure provides the EF Core implementation.
- IUnitOfWork → ApplicationDbContext: Domain defines the persistence contract. Infrastructure maps it to the DbContext.
- IImageStorageService → AzureBlobStorageService: Application defines the storage contract. Infrastructure provides the Azure implementation.
- IDateTimeProvider → DateTimeProvider: Even time is abstracted. Tests can inject a fixed time provider for deterministic behavior.
- ISender → Sender: The mediator pattern itself follows DIP, handlers don't know how messages are dispatched.
Why Abstractions Live in Domain and Application
A critical DIP detail in Gathering: the interfaces are defined where they are needed, not where they are implemented. This is the inversion.
1// ✗ WRONG: Interface defined alongside its implementation
2// File: Gathering.Infrastructure/Repositories/ISessionRepository.cs
3// This would make Domain depend on Infrastructure
4
5// ✓ CORRECT: Interface defined where it's consumed
6// File: Gathering.Domain/Sessions/ISessionRepository.cs
7// Infrastructure depends on Domain to implement this interface
8
9// The dependency arrows:
10//
11// Gathering.Domain (defines ISessionRepository, IUnitOfWork)
12// ↑
13// Gathering.Application (defines IImageStorageService, uses Domain interfaces)
14// ↑
15// Gathering.Infrastructure (implements ALL interfaces)
16// ↑
17// Gathering.Api (composition root, wires interfaces to implementations)This is the inversion in Dependency Inversion. Without DIP, the natural dependency flow would be:
1// Without DIP (traditional dependency flow):
2// Domain → Infrastructure (Domain uses SqlConnection directly)
3// Application → Infrastructure (Handler uses BlobServiceClient directly)
4// This means business logic DEPENDS ON infrastructure details
5
6// With DIP (inverted dependency flow):
7// Infrastructure → Domain (Infrastructure implements IRepository)
8// Infrastructure → Application (Infrastructure implements IImageStorageService)
9// This means infrastructure details DEPEND ON business abstractionsTesting: Where DIP Pays Off
The most tangible benefit of DIP is testability. Because handlers depend on abstractions, you can substitute any implementation, including test doubles.
1// Unit testing CreateCommunityCommandHandler
2// NO Azure account needed. NO database needed.
3
4public class CreateCommunityCommandHandlerTests
5{
6 [Test]
7 public async Task Handle_WithValidCommand_CreatesCommunity()
8 {
9 // Arrange - all dependencies are test doubles
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 "Clean Code Community",
23 "A community focused on clean code practices");
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("Clean Code Community"));
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 "Community", "Description")
51 {
52 ImageStream = imageStream,
53 ImageFileName = "logo.png",
54 ImageContentType = "image/png"
55 };
56
57 await handler.HandleAsync(command, CancellationToken.None);
58
59 // Verify image was uploaded through the abstraction
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// Test double - trivial to implement because the interface is small
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 in TypeScript
TypeScript achieves DIP through interfaces and constructor injection. Here is the same pattern in a Node.js/Express context:
1// Abstractions - defined by the high-level module
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// High-level module - depends ONLY on abstractions
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// Low-level module - Azure implementation
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// Composition root (e.g., in app bootstrap)
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// Test setup - swap implementations without changing handler
94function createTestHandler(): CreateCommunityHandler {
95 return new CreateCommunityHandler(
96 new InMemoryCommunityRepository(),
97 new InMemoryUnitOfWork(),
98 new InMemoryImageStorage()
99 );
100}Benefits of Applying DIP
1. True Unit Testing
Every handler can be tested in complete isolation. No database, no cloud services, no network calls. Tests are fast, deterministic, and reliable.
2. Infrastructure Swapability
Gathering uses Azure Blob Storage today. Switching to AWS S3 means creating one new class: AwsS3StorageService. The handler, the domain, the validators, none of them change.
3. Parallel Development
One team can build the Application layer while another builds the Infrastructure layer. They agree on the interfaces (IRepository, IImageStorageService) and work independently. The composition root connects them at deployment time.
4. Clean Compile Dependencies
The Application project does not reference EF Core, Azure SDK, or any infrastructure NuGet package. This means changes to Infrastructure never force recompilation of Application. Build times stay fast.
5. Clear Architectural Boundaries
DIP enforces the “onion architecture”: the core (Domain, Application) has zero knowledge of the outer layers (Infrastructure, API). This makes the codebase navigable and maintainable as it grows.
Real-World Pitfalls
Pitfall 1: Abstracting Everything
Not every class needs an interface. Utility classes, value objects, and domain entities do not need abstractions. Apply DIP at architectural boundaries(where modules cross layers (Application → Infrastructure, Domain → Persistence).
Guideline: Abstract the volatile (storage, external APIs, time, email). Don't abstract the stable (string manipulation, math, domain value objects).
Pitfall 2: Interface in the Wrong Layer
If IImageStorageService were defined in Gathering.Infrastructure, the Application layer would need to reference Infrastructure, defeating the purpose. Always define abstractions in the layer that consumes them.
Pitfall 3: DI Container as Service Locator
Avoid injecting IServiceProvider and resolving services manually. This hides dependencies and makes code harder to understand and test. Use explicit constructor injection.
Pitfall 4: Leaky Abstractions
If your interface exposes Azure-specific types (like BlobProperties or S3Response), it defeats the purpose of DIP. The interface must use domain-level types: streams, strings, Result objects, never infrastructure types.
Checklist: Detecting DIP Violations
If you answer “yes” to any of these, DIP is likely being violated.
Conclusion
The Dependency Inversion Principle is what makes all other SOLID principles work at the architectural level. By ensuring that high-level business logic depends on abstractions, not concrete infrastructure, you create systems that are testable, flexible, and resilient to change.
Gathering demonstrates this through its layered architecture: the Domain defines repository interfaces, the Application defines service abstractions, and the Infrastructure implements them all. The composition root in the API layer wires everything together at startup. No handler ever sees a database connection or cloud SDK.
The key insight: the direction of source code dependencies should be the opposite of the flow of control. Your business logic calls storage methods at runtime, but the source code dependency points from the storage implementation toward the business abstraction, not the other way around.
This concludes our series on SOLID principles. Together, SRP, OCP, LSP, ISP, and DIP form a cohesive design philosophy: build small, focused classes (SRP) that are open for extension (OCP), safely substitutable (LSP), with focused interfaces (ISP), and wired through abstractions (DIP). Mastering these principles is the foundation for writing professional, maintainable software.