C# Best Practices: Dependency Injection Done Right

Introduction
In software development, one common problem that developers often face is tight coupling between classes. You might have experienced a situation where making a small change in one class unexpectedly breaks several other classes. This is a classic symptom of tightly coupled code.
Tightly coupled code is hard to maintain, difficult to test, and fragile when it comes to extending functionality. This article explores why tight coupling occurs, why it’s problematic, and how Dependency Injection (DI) — specifically Constructor Injection — can help you write clean, maintainable, and testable C# code.
Understanding Tight Coupling
Tight coupling happens when classes are highly dependent on each other’s concrete implementations. This often occurs when classes create instances of their dependencies internally using the new
keyword. For example:
public class Car
{
private Engine _engine;
public Car()
{
_engine = new Engine(); // Creating dependency inside the class
}
public void Start()
{
_engine.Run();
}
}
public class Engine
{
public void Run()
{
Console.WriteLine("Engine started");
}
}
In this example, the Car
class directly creates an instance of Engine
. This approach leads to several issues:
Difficult to test: You cannot replace
Engine
with a mock or stub in tests.Hard to maintain: Changes to the
Engine
class might require changes toCar
.Low flexibility: You cannot easily switch to different types of engines without modifying the
Car
class.
Why is Tight Coupling a Problem?
Reduced Testability: If a class creates its own dependencies, it becomes difficult to write unit tests that isolate that class. You cannot substitute real dependencies with test doubles like mocks or fakes.
Poor Separation of Concerns: Classes that create their own dependencies are responsible for both business logic and managing dependencies, mixing different concerns.
Fragile Code: Changes in one dependency can cause unexpected failures in dependent classes, making the codebase fragile and hard to refactor.
Limited Extensibility: Adding new implementations or swapping dependencies requires modifying existing code, violating the Open/Closed Principle (OCP).
The Solution: Dependency Injection
Dependency Injection is a design pattern that helps solve these problems by inverting the control of dependency creation. Instead of classes creating their own dependencies, dependencies are provided (injected) from outside.
This makes classes focus solely on their responsibilities, while dependency management is handled externally. DI improves testability, maintainability, and flexibility.
Constructor Injection: A Simple and Effective Form of DI
Constructor Injection is one of the most common forms of DI, where dependencies are passed as parameters to the constructor.
This method has several advantages:
Explicit dependencies: The required dependencies of a class are clear from its constructor signature.
Immutable dependencies: Dependencies can be assigned to readonly fields, preventing accidental reassignment.
Simpler testing: You can easily pass mock implementations when writing tests.
Better separation: Classes are not responsible for creating dependencies.
Applying Constructor Injection with Interfaces
To maximize flexibility and abstraction, it’s best practice to depend on interfaces instead of concrete classes.
Let’s improve the previous example:
Step 1: Define an interface for the dependency
public interface IEngine
{
void Run();
}
Step 2: Implement the interface in a concrete class
public class Engine : IEngine
{
public void Run()
{
Console.WriteLine("Engine started");
}
}
Step 3: Update the dependent class to accept the interface via constructor
public class Car
{
private readonly IEngine _engine;
public Car(IEngine engine)
{
_engine = engine; // Dependency injected via constructor
}
public void Start()
{
_engine.Run();
}
}
Benefits of This Approach
- You can easily create mock implementations of
IEngine
for testing.
public class MockEngine : IEngine
{
public bool WasRunCalled { get; private set; }
public void Run()
{
WasRunCalled = true;
}
}
Your classes follow the Dependency Inversion Principle (DIP) — high-level modules (
Car
) depend on abstractions (IEngine
), not concrete implementations.Swapping out engine implementations in the future requires no changes to the
Car
class.
Using IoC Containers for Managing Dependencies
In a real-world application, manually creating and injecting dependencies can become tedious as the number of classes grows.
Inversion of Control (IoC) containers, also known as dependency injection frameworks, automate this process by managing object lifetimes and wiring dependencies.
Microsoft’s built-in DI container in ASP.NET Core, for example, allows you to register services like this:
var services = new ServiceCollection();
services.AddTransient<IEngine, Engine>();
services.AddTransient<Car>();
var serviceProvider = services.BuildServiceProvider();
var car = serviceProvider.GetService<Car>();
car.Start();
The container resolves Car
, injects an instance of Engine
implementing IEngine
, and you get fully wired objects without manually new-ing anything.
Summary
Tight coupling in your code causes maintainability, testability, and extensibility issues. By applying Dependency Injection using Constructor Injection and relying on interfaces, you create loosely coupled, flexible, and testable code.
This practice enables:
Clear declaration of dependencies
Easy unit testing with mock dependencies
Better adherence to SOLID principles
Easier maintenance and refactoring
Adopting DI early in your projects can save time and headaches in the long run.
Your Experience
Have you encountered tight coupling issues in your projects? How do you implement Dependency Injection in your team? Feel free to share your thoughts or questions in the comments section.
Subscribe to my newsletter
Read articles from Ashok K directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
