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:

  1. 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.
  2. 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.
  3. 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:

  1. Review the current version of your logging application.
  2. Create IDataAccess interface for data access.
  3. Implement the IDataAccess interface to decouple the DatabaseAccess class.
  4. Create ILogger interface for logging a message.
  5. Update ConsoleLogger class to implement the interfaces.
  6. Refactor the Application class to use dependency injection.
  7. Refactor the Program.cs file to use dependency injection.
  8. Test the refactored application.

Task 1: Review the current version of your logging application

In this task, you download the existing version of your banking app and review the code.

Use the following steps to complete this section of the exercise:

  1. Download the starter code from the following URL: Implement interfaces - exercise code projects

  2. Extract the contents of the LP2SampleApps.zip file to a folder location on your computer.

  3. Expand the LP2SampleApps folder, and then open the Interface_M3 folder.

    The Interface_M3 folder contains the following code project folders:

    • Solution
    • Starter

    The Starter folder contains the starter project files for this exercise.

  4. Use Visual Studio Code to open the Starter folder.

  5. In the EXPLORER view, collapse the STARTER folder, select SOLUTION EXPLORER, and expand the Interface_M3 project.

    You should see the following project files:

    • Application.cs
    • ConsoleLogger.cs
    • DatabaseAccess.cs
    • IDataAccess.cs
    • ILogger.cs
    • Program.cs
  6. Take a minute to open and review each of the files.

    • Application.cs: Defines the Application class, which contains the main application logic and is tightly coupled to ConsoleLogger and DatabaseAccess.

    • ConsoleLogger.cs: Defines the ConsoleLogger class, responsible for logging messages to the console, currently tightly coupled to Application.

    • DatabaseAccess.cs: Defines the DatabaseAccess class, responsible for connecting to a database and retrieving data, currently tightly coupled to Application.

    • IDataAccess.cs: Placeholder for the IDataAccess interface (will be used to specify the contract for data access operations like Connect and GetData).

    • ILogger.cs: Placeholder for the ILogger interface (will be used to specify the contract for logging operations with a Log(string message) method).

    • Program.cs: Contains the entry point of the application, creating and running an instance of the Application class.

    NOTE: Reviewing the initial project files code reveals the tightly coupled nature of the application components. Reviewing TASK comments in the files provides context for understanding how the project code is refactored into loosely coupled code.

  7. Run the application using the following command (or debug with F5):

     dotnet run
    

    To debug, set breakpoints in your code (e.g., in Application.cs or Program.cs) and press F5 in Visual Studio Code to start debugging. This allows you to step through the code and inspect variables.

  8. Verify the output to ensure that the application logs messages to the console and retrieves data from the database.

    Your app should produce output that’s similar to the following example:

     ConsoleLogger: Application started.
     Database connected.
     ConsoleLogger: Data retrieved: Sample Data from Database
     ConsoleLogger: Application finished.
    

Task 2: Create IDataAccess interface for data access

In Visual Studio Code open IDataAccess.cs, the file only has comments.

  1. Open the IDataAccess.cs file in Visual Studio Code.

  2. Under the TASK 2: comment, to define the IDataAccess interface, add the following code:

     public interface IDataAccess
     {
         // Connects to the data source.
         void Connect();
        
         // Retrieves data from the data source.
         string GetData();
     }
    

    The IDataAccess interface defines a contract for data access operations, ensuring that any class implementing it provides Connect and GetData methods.

  3. Save your changes.

  4. Notice that adding the IDataAccess interface definition provides the foundation for functionality with a clearly defined contract for data access operations. This ensures that any class implementing the interface will provide concrete implementations for the Connect and GetData methods, enabling consistent and flexible data access across the application.

Task 3: Implement the IDataAccess interface to decouple the DatabaseAccess class

Now, update the existing DatabaseAccess class to implement the newly defined interface.

  1. Open the DatabaseAccess.cs file.

  2. Under the TASK 3: comment, to implement the IDataAccess interface, replace the existing DatabaseAccess class with the following code:

     // This class implements the IDataAccess interface and is responsible for connecting to a database and retrieving data.
     public class DatabaseAccess : IDataAccess
     {
         // Simulates connecting to a database.
         public void Connect()
         {
             Console.WriteLine("Database connected.");
         }
        
         // Simulates retrieving data from the database.
         public string GetData()
         {
             return "Sample Data from Database";
         }
     }
    

    The DatabaseAccess class now implements the IDataAccess interface, providing methods to connect to a database and retrieve data.

  3. Save your changes.

  4. Notice that the only code change is adding the interface reference (: IDataAccess) to the DatabaseAccess class. This ensures the class adheres to the contract defined by the IDataAccess interface, which includes the Connect and GetData methods.

Task 4: Create ILogger interface for logging a message

  1. Open the ILogger.cs file in Visual Studio Code.

  2. Under the TASK 4: comment, to define the ILogger interface, add the following code:

     // Interface for logging messages.
     public interface ILogger
     {
         // Logs a message.
         void Log(string message);
     }
    

    The ILogger interface abstracts logging operations, requiring implementing classes to provide a method for logging messages.

  3. Save your changes.

  4. Notice that adding the ILogger interface definition establishes a clear contract for logging functionality. This ensures that any class implementing the interface will provide a concrete implementation of the Log method, enabling consistent and reusable logging behavior across the application.

Task 5: Update ConsoleLogger class to implement the interfaces

Next, you update the existing ConsoleLogger class to implement the newly defined interface.

  1. Open the ConsoleLogger.cs file.

  2. Under the TASK 5: comment, to implement the ILogger interface, replace the existing ConsoleLogger class with the following code:

     // This class implements the ILogger interface and is responsible for 
     // logging messages to the console.
     public class ConsoleLogger : ILogger
     {
         // Logs a message to the console.
         public void Log(string message)
         {
             Console.WriteLine($"ConsoleLogger: {message}");
         }
     }
    

    The ConsoleLogger class now implements the ILogger interface, providing a concrete implementation of the Log method to log messages to the console.

    NOTE The constructor code in Task 6 is incomplete. It doesn’t show the full Application class or how _logger and _dataAccess are declared - those items must remain intact.

  3. Save your changes.

  4. Notice that adding the ILogger interface definition establishes a clear contract for logging functionality. This ensures that any class implementing the interface will provide a concrete implementation of the Log method, enabling consistent and reusable logging behavior across the application.

Task 6: Refactor the Application class 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.

  1. Open the Application.cs file.

  2. Under the TASK 6: comment, replace the field declarations and Application class constructor with the following code:

     private readonly ILogger _logger;
     private readonly IDataAccess _dataAccess;
    
     public Application(ILogger logger, IDataAccess dataAccess)
     {
         _logger = logger;
         _dataAccess = dataAccess;
     }
    

    The Application class now uses dependency injection to receive its dependencies, making it more flexible and easier to test.

Task 7: Refactor the Program.cs file to use dependency injection

  1. Open the Program.cs file.

  2. Replace all of the existing code with the following:

     var logger = new ConsoleLogger();
     var dataAccess = new DatabaseAccess();
        
     // Inject the dependencies into the Application class.
     var app = new Application(logger, dataAccess);
     app.Run();
    

    The Program.cs file creates instances of ConsoleLogger and DatabaseAccess and injects them into the Application class, demonstrating how dependency injection works in practice.

Task 8: Test the refactored application

Finally, test the refactored application to ensure it works as expected.

  1. Run the application using the following command (or debug with F5):

     dotnet run
    

    To debug, set breakpoints in your code (e.g., in Application.cs or Program.cs) and press F5 in Visual Studio Code to start debugging. This allows you to step through the code and inspect variables.

  2. Verify the output to ensure that the application logs messages to the console.

    Your app should produce output that’s similar to the following example:

     ConsoleLogger: Application started.
     Database connected.
     ConsoleLogger: Data retrieved: Sample Data from Database
     ConsoleLogger: Application finished.
    

    This output confirms that the application is functioning correctly, with logging and database operations working as expected.

    The output is the same as the starter code, but now the code is decoupled from specific implementations by using interfaces and dependency injection. This exercise demonstrated how to refactor tightly coupled code into loosely coupled components by defining interfaces (ILogger and IDataAccess), implementing them in concrete classes (ConsoleLogger and DatabaseAccess), and injecting these dependencies into the Application class.

NOTE 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 manner. Together, these practices improve the structure of your code, making it easier to extend, test, and adapt to future requirements.

Clean up

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.