Create Flexible Code Using Interfaces
In object-oriented programming, interfaces define a contract that classes can implement. They specify method signatures and properties that implementing classes must provide. This allows for consistent behavior across different types while enabling flexibility in implementation. In C#, interfaces are defined using the interface
keyword, and classes implement them using the : InterfaceName
syntax.
In this exercise, you will refactor a tightly coupled console application to use interfaces. By introducing interfaces and dependency injection, you will decouple the application logic from specific implementations, making the code more flexible and easier to maintain.
This exercise takes approximately 20-25 minutes to complete.
Before you start
Before you can start this exercise, you need to:
- Ensure that you have the latest short term support (STS) version of the .NET SDK installed on your computer. You can download the latest versions of the .NET SDK using the following URL: Download .NET.
- Ensure that you have Visual Studio Code installed on your computer. You can download Visual Studio Code using the following URL: Download Visual Studio Code.
- Ensure that you have the C# Dev Kit configured in Visual Studio Code.
For additional help configuring the Visual Studio Code environment, see Install and configure Visual Studio Code for C# development.
Exercise scenario
Suppose you’re a software developer at a tech company working on a new project. Your team has identified that some parts of the codebase are tightly coupled, making it difficult to test and extend. To address this, you decide to refactor the code to use interfaces and dependency injection. This will decouple the application logic from specific implementations, improving flexibility and maintainability.
This exercise includes the following tasks:
- Create a new C# project.
- Define interfaces to abstract logging and database access.
- Update existing classes to implement the interfaces.
- Refactor the application to use dependency injection.
- Test the refactored application to ensure it works as expected.
Task 1: Create a new C# project
To start, you need to create a new C# project in your development environment. This project will serve as the foundation for refactoring the code.
- Open Visual Studio Code.
- Ensure that the C# Dev Kit extension is installed.
- Open the terminal in Visual Studio Code by selecting
View > Terminal
. - Navigate to the directory where you want to create your project.
-
Run the following command to create a new console application:
dotnet new console -n RefactorWithInterfaces
This command creates a new console application named
RefactorWithInterfaces
, which will serve as the starting point for the exercise. -
Navigate into the newly created project directory:
cd RefactorWithInterfaces
This step ensures that you are working within the correct project directory.
-
Open the project in Visual Studio Code:
code .
Opening the project in Visual Studio Code allows you to edit and manage the files easily.
Check your work: Create a new C# project
Ensure that the project has been created successfully by verifying the presence of the Program.cs
file in the project directory. You should also see the project structure in the Visual Studio Code Explorer pane.
Task 2: Define interfaces to abstract logging and database access
Next, define two interfaces: one for logging and another for database access. These interfaces will serve as contracts for the application to depend on.
- In the
RefactorWithInterfaces
project, create a new file namedILogger.cs
. -
Add the following code to define the
ILogger
interface:public interface ILogger { void Log(string message); }
The
ILogger
interface defines a contract for logging functionality, ensuring that any class implementing it provides aLog
method. - Create another file named
IDataAccess.cs
. -
Add the following code to define the
IDataAccess
interface:public interface IDataAccess { void Connect(); string GetData(); }
The
IDataAccess
interface abstracts database operations, requiring implementing classes to provide methods for connecting to a database and retrieving data.
Check your work: Define interfaces
Verify that the ILogger
and IDataAccess
interfaces are correctly defined by checking their respective files. The ILogger
interface should include the Log
method, and the IDataAccess
interface should include the Connect
and GetData
methods.
Task 3: Update existing classes to implement the interfaces
Now, update the existing ConsoleLogger
and DatabaseAccess
classes to implement the newly defined interfaces.
- In the
RefactorWithInterfaces
project, open theConsoleLogger.cs
file. -
Modify the class to implement the
ILogger
interface:using System; public class ConsoleLogger : ILogger { public void Log(string message) { Console.WriteLine($"ConsoleLogger: {message}"); } }
The
ConsoleLogger
class now implements theILogger
interface, providing a concrete implementation of theLog
method to log messages to the console. - Open the
DatabaseAccess.cs
file. -
Modify the class to implement the
IDataAccess
interface:using System; public class DatabaseAccess : IDataAccess { public void Connect() { Console.WriteLine("DatabaseAccess: Connected to the database."); } public string GetData() { return "Sample Data"; } }
The
DatabaseAccess
class now implements theIDataAccess
interface, providing methods to connect to a database and retrieve data.
Check your work: Update existing classes
Ensure that the ConsoleLogger
and DatabaseAccess
classes correctly implement their respective interfaces by checking their files. Each class should provide concrete implementations for the methods defined in the interfaces.
Task 4: Refactor the application to use dependency injection
Refactor the Application
class to depend on the ILogger
and IDataAccess
interfaces instead of directly instantiating the ConsoleLogger
and DatabaseAccess
classes.
- In the
RefactorWithInterfaces
project, create a new file namedApplication.cs
. -
Add the following code:
public class Application { private readonly ILogger _logger; private readonly IDataAccess _dataAccess; public Application(ILogger logger, IDataAccess dataAccess) { _logger = logger; _dataAccess = dataAccess; } public void Run() { _logger.Log("Application started."); _dataAccess.Connect(); var data = _dataAccess.GetData(); _logger.Log($"Data retrieved: {data}"); _logger.Log("Application finished."); } }
The
Application
class now uses dependency injection to receive its dependencies, making it more flexible and easier to test. - Open the
Program.cs
file. -
Replace the existing code with the following:
var logger = new ConsoleLogger(); var dataAccess = new DatabaseAccess(); var app = new Application(logger, dataAccess); app.Run();
The
Program.cs
file creates instances ofConsoleLogger
andDatabaseAccess
and injects them into theApplication
class, demonstrating how dependency injection works in practice.
Check your work: Refactor the application
Verify that the Application
class now depends on the ILogger
and IDataAccess
interfaces. Ensure that the Program.cs
file creates instances of ConsoleLogger
and DatabaseAccess
and passes them to the Application
constructor.
Task 5: Test the refactored application
Finally, test the refactored application to ensure it works as expected.
-
Run the application using the following command:
dotnet run
Running the application executes the refactored code, allowing you to verify its behavior.
-
Verify the output to ensure that the application logs messages to the console and retrieves data from the database.
Check your work: Test the refactored application
Confirm that the application runs without errors and produces the following output:
ConsoleLogger: Application started.
DatabaseAccess: Connected to the database.
ConsoleLogger: Data retrieved: Sample Data
ConsoleLogger: Application finished.
This output confirms that the application is functioning correctly, with logging and database operations working as expected.
Refactoring code using techniques like interfaces and dependency injection helps decouple components, making your application more flexible and maintainable. Interfaces define clear contracts between parts of the system, while dependency injection ensures that dependencies are provided in a modular and testable way. Together, these practices improve the structure of your code, making it easier to extend, test, and adapt to future requirements.
Now that you’ve finished the exercise, consider archiving your project files for review at a later time. Having your own projects available for review can be a valuable resource when you’re learning to code. Additionally, building a portfolio of projects can be a great way to demonstrate your skills to potential employers.