Introduction
Dependency injection is a design pattern where objects receive their dependencies from an external source rather than creating them internally. This makes code less tightly coupled and easier to modify.
While it can sound complex, it's actually a straightforward idea that every programmer should know. The main reasons to learn and use dependency injection are:
- Greater flexibility by decoupling components and allowing easy replacement of implementations
- Simpler, more isolated testing by injecting mock or stub dependencies
- Improved maintainability and scalability as systems grow more complex
Do I need to know C# to understand this?
Before we get to it, we must first talk about...
Composition
Let's say we have a simple abstract class Database that supports 3 actions:
- Opening a connection to the database.
- Closing the connection we opened.
- Executing an SQL query (returning an IDataReader).
public abstract class Database
{
private IDbConnection _connection;
public Database() { }
public Database(IDbConnection connection)
{
Connection = connection;
}
public required IDbConnection Connection
{
get => _connection;
init => _connection = value
?? throw new ArgumentNullException(nameof(value));
}
public void Open()
{
if (Connection.State != ConnectionState.Open)
{
Connection.Open();
}
}
public void Close()
{
if (Connection.State != ConnectionState.Closed)
{
Connection.Close();
}
}
public abstract IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType,
params IDataParameter[] queryParameters);
}
We want to make two specific database subclasses that inherit from it.
- One for MySQL
- One for SQLServer
Here are the MySQL and SQLServer implementations
public sealed class MySql : Database
{
public MySql() : base() { }
public MySql(MySqlConnection connection) : base(connection) { }
public MySqlConnection MySqlConnection => (MySqlConnection)Connection;
public override IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
MySqlCommand command = (MySqlCommand)Connection.CreateCommand();
command.CommandText = sql;
command.CommandType = commandType;
if (queryParameters != null)
foreach (var param in queryParameters)
command.Parameters.Add(param);
return command.ExecuteReader(CommandBehavior.CloseConnection);
}
}
public sealed class SqlServer : Database
{
public SqlServer() : base() { }
public SqlServer(SqlConnection connection) : base(connection) { }
public SqlConnection SqlConnection => (SqlConnection)Connection;
public override IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
SqlCommand command = (SqlCommand)Connection.CreateCommand();
command.CommandText = sql;
command.CommandType = commandType;
if (queryParameters != null)
foreach (var param in queryParameters)
command.Parameters.Add(param);
return command.ExecuteReader(CommandBehavior.CloseConnection);
}
}
This works well. MySQL and SQLServer are SQL databases, so it makes sense to override ExecuteSqlQuery.
When inheritance forces the wrong behaviour.
Now let's assume we want to expand our hierarchy to also include our own database "SimpleDB" that supports opening and closing connections but not executing SQL queries.
Here's how it looks:
public sealed class SimpleDb : Database
{
public SimpleDb() : base() { }
public SimpleDb(SimpleDbConnection connection) : base(connection) { }
public SimpleDbConnection SimpleDbConnection => (SimpleDbConnection)Connection;
public override IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
throw new NotImplementedException("SimpleDb doesn't support SQL queries!");
}
}
This feels wrong.
The base class forces SimpleDB to implement ExecuteSqlQuery even though SimpleDB doesn't support SQL. The only thing we can do is throw an exception, which makes the method pointless.
Attempted fix: Separate executor class
One way to avoid this is to move the SQL-specific behavior into its own class. We can create a DatabaseSqlQueryExecutor class that extends Database and provides the ExecuteSqlQuery method:
public abstract class DatabaseSqlQueryExecutor : Database
{
public DatabaseSqlQueryExecutor() : base() { }
public DatabaseSqlQueryExecutor(IDbConnection connection) : base(connection) { }
public abstract IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType,
params IDataParameter[] queryParameters);
}
public sealed class MySql : DatabaseSqlQueryExecutor
{
public MySql() : base() { }
public MySql(MySqlConnection connection) : base(connection) { }
public MySqlConnection MySqlConnection => (MySqlConnection)Connection;
public override IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
MySqlCommand command = (MySqlCommand)Connection.CreateCommand();
command.CommandText = sql;
command.CommandType = commandType;
if (queryParameters != null)
foreach (var param in queryParameters)
command.Parameters.Add(param);
return command.ExecuteReader(CommandBehavior.CloseConnection);
}
}
public sealed class SqlServer : DatabaseSqlQueryExecutor
{
// ...
}
This way, SQL databases inherit from DatabaseSqlQueryExecutor , while those that don't support SQL don't.
When inheritance becomes a trap
We started with a simple Database class. It had an ExecuteSqlQuery() method, and subclasses like MySqlDatabase and SqlServerDatabase inherited it.
Then, we introduced an extra layer DatabaseSqlQueryExecutor.
This now breaks everything that expected Database to have the ExecuteSqlQuery() method. If we keep adding database types (e.g., different NoSQL databases), we might need more subclasses, or entirely different classes like NoSqlDatabase , making the hierarchy more complex.
We have coupled ourselves into a hierarchy that's too rigid.
Can we do better?
Yes! Let's look at the three subclasses. They both share the methods Open() and Close() .
Instead of baking these into a shared parent class, we can extract this functionality into its own, independent class.
Extracting connection management
public class DatabaseConnectionManager
{
private IDbConnection _connection;
public DatabaseConnectionManager() { }
public DatabaseConnectionManager(IDbConnection connection)
{
Connection = connection;
}
public required IDbConnection Connection
{
get => _connection;
init => _connection = value
?? throw new ArgumentNullException(nameof(value));
}
public void Open()
{
if (Connection.State != ConnectionState.Open)
{
Connection.Open();
}
}
public void Close()
{
if (Connection.State != ConnectionState.Closed)
{
Connection.Close();
}
}
}
Now, DatabaseConnectionManager represents only a connection to a database, which each of our subclasses have. We can make use of the DatabaseConnectionManager if we need to, but we don't have to. If we introduce a Database that doesn't support opening and closing connections we can just choose to ignore it.
From inheritance to composition
So how do we apply this to our database classes? Instead of subclassing Database , our database classes can instead compose a DatabaseConnectionManager object.
public sealed class MySql
{
private readonly DatabaseConnectionManager _connectionManager;
public MySql() { }
public MySql(DatabaseConnectionManager connectionManager)
{
ConnectionManager = connectionManager;
}
public required DatabaseConnectionManager ConnectionManager
{
init => _connectionManager = value
?? throw new ArgumentNullException(nameof(ConnectionManager));
}
public MySqlConnection MySqlConnection =>
(MySqlConnection)_connectionManager.Connection;
public IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
MySqlCommand command =
(MySqlCommand)_connectionManager.Connection.CreateCommand();
command.CommandText = sql;
command.CommandType = commandType;
if (queryParameters != null)
{
foreach (var param in queryParameters)
{
command.Parameters.Add(param);
}
}
return command.ExecuteReader(CommandBehavior.CloseConnection);
}
public void Open() => _connectionManager.Open();
public void Close() => _connectionManager.Close();
}
public sealed class SqlServer
{
// ...
}
public sealed class SimpleDb
{
private readonly DatabaseConnectionManager _connectionManager;
public SimpleDb() { }
public SimpleDb(DatabaseConnectionManager connectionManager)
{
ConnectionManager = connectionManager;
}
public required DatabaseConnectionManager ConnectionManager
{
init => _connectionManager = value
?? throw new ArgumentNullException(nameof(value));
}
public SimpleDbConnection SimpleDbConnection =>
(SimpleDbConnection)_connectionManager.Connection;
public void Open() => _connectionManager.Open();
public void Close() => _connectionManager.Close();
}
Extracting query execution
We've seen how to manage database connections, but running SQL queries is still mixed in with our database classes. Let's separate that responsibility. DatabaseSqlQueryExecutor
public class DatabaseSqlQueryExecutor
{
private DatabaseConnectionManager _connectionManager;
public DatabaseSqlQueryExecutor() { }
public DatabaseSqlQueryExecutor(DatabaseConnectionManager connectionManager)
{
ConnectionManager = connectionManager;
}
public required DatabaseConnectionManager ConnectionManager
{
init => _connectionManager = value
?? throw new ArgumentNullException(nameof(value));
}
public IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
var command = _connectionManager.Connection.CreateCommand();
command.CommandText = sql;
command.CommandType = commandType;
if (queryParameters != null)
{
foreach (var param in queryParameters)
{
command.Parameters.Add(param);
}
}
return command.ExecuteReader(CommandBehavior.CloseConnection);
}
}
Now, instead of embedding query logic inside our database classes, we can inject a DatabaseSqlQueryExecutor whenever we need to run SQL quereis.
Putting it together
Here's our Improved MySql and SqlServer classes using both components.
public sealed class MySql
{
private readonly DatabaseConnectionManager _connectionManager;
private readonly DatabaseSqlQueryExecutor _queryExecutor;
public MySql() { }
public MySql(
DatabaseConnectionManager connectionManager,
DatabaseSqlQueryExecutor queryExecutor)
{
ConnectionManager = connectionManager;
QueryExecutor = queryExecutor;
}
public required DatabaseConnectionManager ConnectionManager
{
init => _connectionManager = value
?? throw new ArgumentNullException(nameof(ConnectionManager));
}
public required DatabaseSqlQueryExecutor QueryExecutor
{
init => _queryExecutor = value
?? throw new ArgumentNullException(nameof(QueryExecutor));
}
public MySqlConnection MySqlConnection =>
(MySqlConnection)_connectionManager.Connection;
public MySqlDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
return (MySqlDataReader)
_queryExecutor.ExecuteSqlQuery(sql, commandType, queryParameters);
}
public void Open() => _connectionManager.Open();
public void Close() => _connectionManager.Close();
}
public sealed class SqlServer
{
// ...
}
Why this is better
This is the beauty of composition:
- Need connection management? Use DatabaseConnectionManager.
- Need to execute SQL queries? Use DatabaseSqlQueryExecutor.
- Don't need one of them? Don't include it.
This makes our architecture flexible - each class has a single responsibility and can be reused elsewhere.
But… we lost something important along the way.
Interfaces
Let's look back at the relationships between classes we've had at the beginning.
Contracts are expectations
Initially, our database classes inherited from a common base class Database . This acted as a contract:
"All of my children will have at least these methods and these properties" .
This allows us for example to create a DatabaseFactory class that dynamically creates different Database subclasses.
Database factory implementation
public enum DatabaseType
{
MySql,
SqlServer,
SimpleDb
}
public static class DatabaseFactory
{
public static Database CreateDatabase(DatabaseType type, IDbConnection connection)
{
return type switch
{
DatabaseType.MySql =>
new MySql((MySqlConnection)connection),
DatabaseType.SqlServer =>
new SqlServer((SqlConnection)connection),
DatabaseType.SimpleDb =>
new SimpleDb((SimpleDbConnection)connection),
_ => throw new
ArgumentException("Unsupported database type", nameof(type))
};
}
}
But after moving to composition, our classes are independent. They no longer share a common base, so we can't treat them interchangeably.
Notice that the classes aren't pointing at anything anymore.
If we tried implementing the factory class now we'd get an error because there is no Database contract that our subclasses are fulfilling.
Interfaces as contracts
Introducing IDatabaseConnection
We'll fix that by using an interface.
public interface IDatabaseConnection
{
void Open();
void Close();
}
Let's implement this interface. We only have to change three lines.
public sealed class MySql : IDatabaseConnection
public sealed class SqlServer : IDatabaseConnection
public sealed class SimpleDb : IDatabaseConnection
With this, our classes once again share a common contract - but instead of inheritance, we use interfaces.
Let's try reimplementing back our DatabaseFactory class
Interfaced factory implementation
public enum DatabaseType
{
MySql,
SqlServer,
SimpleDb
}
public static class DatabaseFactory
{
public static IDatabaseConnection CreateDatabase(DatabaseType type,
IDbConnection connection)
{
var connectionManager = new DatabaseConnectionManager(connection);
return type switch
{
DatabaseType.MySql =>
new MySql(connectionManager,
new DatabaseSqlQueryExecutor(connectionManager)),
DatabaseType.SqlServer =>
new SqlServer(connectionManager,
new DatabaseSqlQueryExecutor(connectionManager)),
DatabaseType.SimpleDb =>
new SimpleDb(connectionManager),
_ => throw new ArgumentException("Unsupported database type", nameof(type))
};
}
}
Great, we've gone back to what we've had before. We could tighten up the hierarchy with a IDatabaseSql interface but I won't be covering that.
From inheritance to flexible contracts
We've moved from inheritance to composition for more flexibility.
- Composition gives us reusable components ( DatabaseConnectionManager, DatabaseSqlQueryExecutor ).
- Interfaces give us polymorphism (treating all database classes the same).
- Together, they make our architecture cleaner, more flexible, and easier to extend.
We're now ready to finally tackle dependency injection.
Dependency injection
So, what actually is dependency injection? You've already seen it!
Recall these pieces of code from before:
public Database(IDbConnection connection)
{
Connection = connection;
}
public MySql(
DatabaseConnectionManager connectionManager,
DatabaseSqlQueryExecutor queryExecutor)
{
ConnectionManager = connectionManager;
QueryExecutor = queryExecutor;
}
What does "Injection" mean?
Dependency injection (DI) is a design pattern where a class receives its required objects (dependencies) from the outside, instead of creating them itself.
You typically inject through the constructor or a property. This is what makes dependency injection powerful.
Outside, not inside
What's the big deal?
I like to think of this as a puzzle piece pattern.
Think of this like building with puzzle pieces. Each piece is independent and can be swapped out.
If you build components separately (like IDatabaseConnection), replacing one affects all subclasses automatically, without rewriting everything.
Testing with DI
Let's make this more concrete. Suppose we have a class DatabaseSqlQueryExecutor that writes database query results to a file using a SqlToFile class, which itself uses a SqlEncryption class to encrypt data.
Let's first modify DatabaseSqlQueryExecutor
public class DatabaseSqlQueryExecutor
{
private readonly DatabaseConnectionManager _connectionManager;
private readonly SqlToFile _sqlToFile;
public DatabaseSqlQueryExecutor() { }
public DatabaseSqlQueryExecutor(DatabaseConnectionManager connectionManager, SqlToFile sqlToFile)
{
ConnectionManager = connectionManager;
SqlToFile = sqlToFile;
}
public required DatabaseConnectionManager ConnectionManager
{
init => _connectionManager = value
?? throw new ArgumentNullException(nameof(value));
}
public required SqlToFile SqlToFile
{
init => _sqlToFile = value
?? throw new ArgumentNullException(nameof(value));
}
public IDataReader ExecuteSqlQuery(
string sql,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
var command = _connectionManager.Connection.CreateCommand();
command.CommandText = sql;
command.CommandType = commandType;
if (queryParameters != null)
{
foreach (var param in queryParameters)
{
command.Parameters.Add(param);
}
}
return command.ExecuteReader(CommandBehavior.CloseConnection);
}
public void ExecuteQueryAndWriteToFile(
string sql,
string filePath,
CommandType commandType = CommandType.Text,
params IDataParameter[] queryParameters)
{
using IDataReader reader = ExecuteSqlQuery(sql, commandType, queryParameters);
_sqlToFile.WriteToFile(reader, filePath);
}
}
Implementing SqlToFile
The SqlToFile class uses an injected SqlEncryption object to encrypt each row before writing:
public class SqlEncryption : ISqlEncryption
{
private readonly _secretKey;
public Encrypt(string row)
{
// Encryption logic
}
}
public class SqlToFile
{
private readonly string SqlEncryption _encryption;
public SqlToFile() { }
public SqlToFile(SqlEncryption encryption)
{
Encryption = encryption;
}
public required SqlEncryption Encryption
{
init => _encryption = value
?? throw new ArgumentNullException(nameof(value));
}
public void WriteToFile(IDataReader reader, string filePath)
{
using var writer = new StreamWriter(filePath, false, Encoding.UTF8);
for (int i = 0; i < reader.FieldCount; i++)
{
string columnName = reader.GetName(i);
writer.Write(columnName);
if (i < fieldCount - 1)
{
writer.Write(",");
}
}
writer.WriteLine();
while (reader.Read())
{
string[] rowData = new string[fieldCount];
for (int i = 0; i < fieldCount; i++)
{
object value = reader.GetValue(i);
rowData[i] = value != null ? value.ToString()! : string.Empty;
}
string row = string.Join(",", rowData);
string encryptedRow = _encryption.Encrypt(row);
writer.WriteLine(encryptedRow);
}
}
}
But when testing if it's writing the content correctly, wouldn't we rather want to see plain text with no encryption?
This is the crux of dependency injection.
Using a factory for injection
Let's modify our factory so we can pass in any implementation of ISqlEncryption:
public static class DatabaseFactory
{
public static IDatabaseConnection CreateDatabase(DatabaseType type,
IDbConnection connection,
ISqlEncryption? encryption)
{
var connectionManager = new DatabaseConnectionManager(connection);
return type switch
{
DatabaseType.MySql =>
new MySql(connectionManager,
new DatabaseSqlQueryExecutor(connectionManager,
encryption)),
DatabaseType.SqlServer =>
new SqlServer(connectionManager,
new DatabaseSqlQueryExecutor(connectionManager,
encryption)),
DatabaseType.SimpleDb =>
new SimpleDb(connectionManager),
_ => throw new ArgumentException("Unsupported database type", nameof(type))
};
}
}
The puzzle piece pattern
Now we can replace SqlEncryption with a test-friendly mock class that does nothing:
public class TestSqlEncryption : ISqlEncryption
{
private readonly string _secretKey = String.empty;
public Encrypt(string row)
{
return row;
}
}
And inject it like this:
TestSqlEncryption testSqlEncryption = new ("Testing");
MySql mySqlDb = DatabaseFactory.CreateDatabase(
DatabaseType.MySql,
new MySqlConnection
{
ConnectionString = "...";
},
testSqlEncryption
);
SqlServer sqlServerDb = DatabaseFactory.CreateDatabase(
DatabaseType.SqlServer,
new MySqlConnection
{
ConnectionString = "...";
},
testSqlEncryption
);
If each database class created its own encryption object internally, you'd have to change them all for testing.
With DI, you only change it in one place.
Conclusion
In this article, we traced the evolution inheritance-based approaches to using interfaces, and finally to dependency injection (DI). Initially, inheritance was used to share behavior, but it often led to rigid and tightly coupled code.
Introducing interfaces allowed us to define contracts that multiple classes could implement, promoting flexibility and decoupling. However, even with interfaces, classes often created their own dependencies internally, limiting testability and reusability.
Dependency Injection addresses these issues by inverting control: dependencies are passed in from the outside rather than being created inside the class. This shift enhances modularity, makes swapping implementations easy, and greatly improves testing by enabling the use of mocks or stubs.
By moving from inheritance to interfaces and finally to DI, we build software that is more maintainable, flexible, and aligned with modern best practices.