Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Is Dependency Injection the Missing Technique That's Holding Your Career Back?

5.00/5 (2 votes)
12 Feb 2024CPOL14 min read 9.6K   38  
A study of C# Interfaces, with implementation of program ActivityTracker (logs to File, Sqlite, & more)
We will build a complete example from Interface to Implementation to examine how Dependency Injection can make your code more easily extensible and testable.

Note: The code sample is .NET Core built with Top-Level statements (very simple) in Program.cs file.

You can also get the source code at my github:

Introduction

Yes, the title of this article was an attempt to get your attention. But, I hope you'll stay with me, because I've been learning & using OOP (Object Oriented Programming) for 30 years and Dependency Injection (DI) still sometimes alludes me and annoys me. I'm very interested in what you think of DI.

Dependency Injection Has Taken Over OOP?

I understand the benefit of using DI but there are places where it feels as if it obscures the clarity of code. At this moment in "Software Development History", it seems that Dependency Injection has taken over almost every other idea in OOP. I want to talk about that and hopefully get feedback from you about how you feel about it.

The promise of Dependency Injection (DI) is that code is more loosely coupled.
Loosely coupled code means you should be able to switch out pieces more easily.

That should mean that extending your code and changing it is easier.

Large Projects With DI Architecture?

However, I don't know if you've been on any larg(er) projects where much of the architecture is based upon Dependency Injection, because they can be very odd to debug and deal with. Especially if there is a IoC (Inversion of Control) Container involved -- the company I work for uses Microsoft Unity (see UnityContainer.org for more) -- this is not the Unity game engine.

Background

In the past 4 - 6 years, the company I work for has pushed DI to the forefront: all code that is created must use DI, there are no exceptions.

Write / Log Anywhere

I've also often thought about a specific solution that I'd like to see which allows me to write code debugging statements to a file, to a database or even post them to a WebAPI.

Request For Comments (RFC)

I finally created a good start that contains full implementation of my idea which is:

  1. Built on full Dependency Injection concept
  2. Guides the implementor to use the code to build their implementation

I'm really hoping that you'll take a look at my design and tell me what you think.
I'm hoping that if you've never really dealt with Dependency Injection, it'll open your eyes and make you consider where you might use it.

But I'm also hoping that some of you will give the counterpoint (why you don't like it) also.

Now, let's get started as I try to explain what I'm trying to get there and why I'm using Dependency Injection for this design.

It All Starts With Behaviors

Interfaces are really just contracts of behavior. They have no implementation, but instead are just a promise of some future behavior that will be provided by the classes.

What I Want

I want a way to track the activity that is going on in my code. I want a way to quickly and easily add a line of code which:

  1. Logs a message that I provide
  2. timestamps the message

WriteActivity Method

To me, that sounds like a WriteActivity method.

I'm going to add that to an Interface and here it is:

C#
public interface ITrackable
{
    bool WriteActivity(String message);
}

That's it. The method will take a String message that will be stored and it will return a bool (true if successful, otherwise false).

Forcing the Implementor

Now, keep in mind that this contract of WriteActivity is really a way to force the implementor (developer who uses Interface) to implement this method.

Next Question: Where Will It Write the Message?

We need a way to tell it to write to Storage of some kind. In this case I'm thinking of three possible choices (there could be more in the future):

  1. file (need a path and file name)
  2. database (need a database and table)
  3. WebAPI endpoint (need a URL)

Different Storage Types: Different Configurations

In each case, I need a different type of configuration.

Well, the first thing I'm thinking here is that I can store the file, the database connection or the URL as a string. Okay, let's force the implementor to also implement a place to hold the string which will represent the target storage. Plus we need a Configure() call to always be called to ensure that the StorageTarget is set, so let's add those now.

C#
public interface ITrackable {

  bool WriteActivity(String message);

   // StorageTarget is one of the following:
   // 1. full-filename (including path)
   // 2. DB Connection string
   // 3. URI to location where data will be posted.

   // StorageTarget will wrap the private var used to contain the filepath,
   // connectionString, URI, etc.

   String StorageTarget { get; }
   // We need to ensure that the Configure call is always made before the 
   // implementation class is instantiated.  We'll see how to force this.
   bool Configure();
}

So Little, Just An Idea Really

It's odd, because so far, it is not much of anything. There's no implementation and barely anything here. As a matter of fact, the new StorageTarget variable is read-only and can't even be set.

Very Generic

That's because I want the user to define a String variable name of their own to hold the StorageTarget. You see the StorageTarget may be a file path or it may be a connection string to a database. So I need this thing to be very generic.
Let's keep going and see if it makes any sense.

Forcing the Implementor To Initialize

There's one more thing I want to force the implementor to do when using this design. I want the implementor to always run a Configure() method before any other code runs.

I want the implementor to do this because there will be different required configuration / initialization for each of the TargetStorage types.

Example With A File

In the case of a file, we will need a target file path to be set so that when the WriteActivity() method calls, it will write to the correct (expected) file.

However, it will be different for a database table.

Example With A Database Table

When the TargetStorage location is a database connection string, then the configuration will be different. It will consist of a connection string which points to a database and a table and sometimes other information.

Note: In our implementation, I will be using Sqlite database because you will be able to run all of the code very easily -- no special database set up like SQL Server or mysql.

I can't force an implementor to call a method with an Interface. However, I can do so with an abstract class.

Abstract Class: Part Virtual, Part Implementation

C#
public abstract class Trackable, ITrackable {

  public Trackable(){

     Configure();
  }

  // Since we are derived from ITrackable, we have to "implement" 
  // the ITrackable methods, but we want the implementing class to 
  // implement them so we are going to make them abstract here.
  public abstract bool Configure();
  public abstract bool WriteActivity(String);
  public abstract String StorageTarget{get;}

}

We have four methods, three abstract (unimplemented) and the other implemented (constructor):

  1. Trackable() constructor calls Configure()
  2. The rest of the methods are the ITrackable methods that we are forced to "implement" but we've made them abstract so the target implementation class will have to write those implementations.

Now, let's finally see what an implementor will do to build on top of the ITrackable and Trackable classes. After that, we can talk about whether or not it is valuable and helpful.

Finally, A Realized Class: Implementation Rules!

Let's do this in steps. First let's just add the class.

C#
public class FileActivityTracker : Trackable
{

}

A Trackable is a ITrackable (via inheritance) so that means our FileActivityTracker is a ITrackable. This will become important later.

If you do that and then try building the program, you'll get some errors from the C# compiler that look like:

  1. error CS0535: 'FileActivityTracker' does not implement inherited abstract member 'Trackable.Configure()'
  2. error CS0535: 'FileActivityTracker' does not implement interface member 'ITrackable.StorageTarget'
  3. error CS0535: 'FileActivityTracker' does not implement interface member 'ITrackable.WriteActivity(string)'

It's warning us that we have not implemented the items which are defined in the abstract class and Interface.

Let's implement the Configure() method first.

Note: Linux or Windows

I wrote this code so it runs on both Linux and Windows so you'll see an odd thing where I get the Path.DirectorySeparatorChar for use in the path (on Windows it is backslash \ but on Linux it's slash /) but that is just overhead and not related to the true Configure() method.

In reality, you would probably just read the FileName from a configuration file and that'd be it.

I added some special code here so it will create a file in your userprofile\temp directory.

  1. On Windows, that is \users\<user.name>\temp\
  2. On Linux, that is /home/<user.name>/temp/

Then, after that line, I've added a Console.WriteLine() which will let us know when the Configure() method runs. This is just for demonstration purposes, because I want you to see that the method runs before the implementation class (FileActivityTracker) runs.

Real Implementation, Probably Uses App.Config

My point is that in the real implementation, the only code that would really be in the Configure() method is the code to set up the FileName that will be written to. And that would probably come from some startup config (app.config) that is read when the app starts.

And, oh yeah, keep your finger on that FileName variable because it is really a backing field to the class TargetStorage Property that we need to add (below). Hopefully, it'll make more sense in a moment.

C#
public override bool Configure(){
   char pathSlash = Path.DirectorySeparatorChar;
   Console.WriteLine
   ($"{DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss.fff tt")} : Configure() method called! ");

   // read values from configuration
   FileName = @$"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}
                 {pathSlash}temp{pathSlash}InterfaceStudy.log";

   return true;

}

We are also missing the implementation of StorageTarget so let's add that property now.

StorageTarget: Just A Pass-thru Property

In an attempt to force the implementor to understand that she will need a place to store the file name, connection string, etc. I've added this StorageTarget thing. However, it is really just a common variable name that we use to get to the true value that holds the value of target.

The user will create a private backing field which has a more definitive name for what the StorageTarget is. In my example, I name that backing field FileName. Here's the code that will be added to our FileActivityTracker class.

C#
private overrid String FileName;

public override string StorageTarget
{
   get
   {
       return FileName;
   }
}

The FileName variable cannot be touched externally and can only be set from within the FileActivityTracker class

Finally, we get to see the implementation of the WriteActivity(String message) method and I believe that will all make it a bit clearer.

C#
public override bool WriteActivity(String message)
{
  try
  {
    File.AppendAllText(StorageTarget, $"{DateTime.Now.ToLongTimeString()}: 
                      {message} {Environment.NewLine}");
    return true;
  }
  catch (Exception ex)
  {
    return false;
  }
}

Keeping It Simple

The WriteActivity method is very simple. It is just a call to File.AppendAllText (part of .NET libraries) to write a line of text to a text file.

File.AppendAllText takes two parameters:

  1. Full file path to the file the method will open and write to
  2. An output string to write to the target file

The magic in our implementation is that the first parameter is our StorageTarget. It is our generically named String which was configured to point to our target file.

The output just contains some additional information which is added to the user's message variable. In this case, we add a DateTime stamp and a newline so that each time we write to the file, it will be on a new line.

How Do We Use the Class?

Seeing how to use the class brings it all home.

Hiding the Details of the Implementation

One of the key features of seeing a simple usage is that we discover that the details of all of this implementation are hidden to the user and that is a huge benefit. The user of the final implementation class doesn't have to think about all of this.

C#
ITrackable fat = new FileActivityTracker();

fat.WriteActivity("The app is running.");

All the user needs to do is:

  1. new up a FileActivityTracker
  2. Call the WriteActivity method to write a message.

In our case with the example, the output will be stored in a file named InterfaceStudy.log and the output will look like:

5:26:38 PM:   The app is running.

ITrackable: Our FileActivityTracker is ITrackable

It is very important to notice that our FileActivityTracker is actually an ITrackable. That means we could replace this ITrackable implementation with any other ITrackable implementation. We will see how this is important when we create our SqliteActivityTracker and how this allows Dependency Injection.

We Could End the Article Here

I could stop now and you'd have a full example of designing toward the idea of Dependency Injection except we haven't exactly injected anything, have we?

To really understand why this technique of designing to an Interface is important, we need to see how The FileActivityTracker could be easily replaced by another class. We also need to see the actual concept of injection.

Constructor Injection

What does injection mean anyways? In our case, it is going to mean injecting a particular type into a class that will use our type. How will we inject the type? We will send it into the class via the constructor. This is called constructor injection.

What is the Point?

But, what is the point of all of this? Let's say you have a class that has a method named WriteActivity() -- same name as the one that we have in our Interface. Let's say that you've implemented the WriteActivity() method so it will write to a file. Again, just as we have in our Interface. Now, further imagine that your method is called all over the place so that users can write messages to the file.

The Change Comes & Destroys Your Code

Then someone at a high level says, "We can't write files to the user's drive. We have to store that data in a database." Now, you'd have to go in and alter the class where you've written your WriteActivity() algorithm and you'd have to test all the places that call the code to make sure it works.

Let's say your class and class usage looks something like the following:

C#
class FileWriter{
      String FileName {get;set;}
      WriteActivity(String message){
          File.AppendAllText(FileName, message);
      }

class Thing1 {
       private FileWriter fw = new FileWriter();

       Thing1(){
              fw.WriteActivity("In the Thing1 ctor...");
       }
}

class Thing2 {
       private FileWriter fw = new FileWriter();
       Thing2(){
              fw.WriteActivity("In the Thing1 ctor...");
       }
}

This is a very simple example but really take a look at it and think about it for a moment.

Imagine if you change the FileWriter WriteActivity method. First of all, if you have other classes which are depending upon the class still writing to a file and you update the method to write to a Database table, then you are going to mess up all that other code.

Or, as another example, imagine if the Thing2 class needs to write to the database but the Thing1 should still write to the File. What could you do then? Maybe create an fake overloaded method for WriteActivity that looks like WriteActivity(String message, bool useDB) just so you have a different method signature. That's not great.

Design to An Interface: Design to Behavior

However, if we design to behaviors and then allow just the behaviors to change, we make a much easier way forward. Let's see how we might use Dependency Injection with the classes above and our ITrackable, Trackable solution.

C#
class Thing1 {

   private ITrackable fat;
 
   // Here's the actual Dependency Injection!
   // When we construct the Thing1 object, we inject the tracker implementation 
   // which will be used later 
   Thing1(ITrackable tracker){
        fat = tracker;
    }

    int DoingWork(){

       // This code will write to a file when fat is a FileActivityTracker
       // and to Sqlite when we implement SqliteActivityTracker.
      fat.WriteActivity("In the DoingWork() method...");

   }
}

So, if we inject our FileActivityTracker into Thing1 it will write to a file.

Our calling code might look like:

C#
Thing1 t = new Thing1(new FileActivityTracker());

We can change that at any time without changing any code. That's the value of Dependency Injection.

Probably Set Up In App Configuration

And, when you really carry this out, the call to new FileActivityTracker() would be set up in a app configuration so that when the app starts, all of these types are set.

Let's implement a ITrackable that writes to Sqlite now so you can see this in action.

SqliteActivityTracker Implementation Code

C#
public SqliteActivityTracker()
{
   try{
   // ########### FYI THE DB is created (if doesn't exist) when it is OPENED ########
      connection.Open();
      File.AppendAllText(@$"{rootPath}{pathSlash}{tempDir}
      {pathSlash}InterfaceStudy.log", $"{DateTime.Now.ToLongTimeString()}: 
      {Environment.CurrentDirectory} {Environment.NewLine}");
      FileInfo fi = new FileInfo(@$"{rootPath}{pathSlash}{tempDir}{pathSlash}tracker.db");

      if (fi.Length == 0){
         connectionString = fi.Name;

         foreach (String tableCreate in allTableCreation){
            command.CommandText = tableCreate;
            command.ExecuteNonQuery();
        }
     }

    Console.WriteLine(connection.DataSource);
   }
   finally{
    if (connection != null){
     connection.Close();
    }
   }
}

public override bool WriteActivity(String message)
{
   Command.CommandText = @"INSERT into Task (Description)values($message);
           select * from task where id =(SELECT last_insert_rowid())";
   Command.Parameters.AddWithValue("$message",message);

   try{
      Console.WriteLine("Saving...");
      connection.Open();
      Console.WriteLine("Opened.");

      // id should be last id inserted into table

      var id = Convert.ToInt64(command.ExecuteScalar());

      Console.WriteLine("inserted.");

      return true;
   }

   catch(Exception ex){
     Console.WriteLine($"Error: {ex.Message}");
     return false;
   }

   finally{
      if (connection != null){
      connection.Close();
   }
 }
}

  // This creates the table for the sample project so everything is
  // done for you and all the code will run properly.
  protected String [] allTableCreation = {

      @"CREATE TABLE Task  
      ( [ID] INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
        [Description] NVARCHAR(1000) check(length(Description) <= 1000),
        [Created] NVARCHAR(30) default (datetime('now','localtime')) 
                               check(length(Created) <= 30)
      )"
  };
}

This is The Amazing Part: Change Without Changing Old Code

Now that we've implemented that code, all we have to make our Thing1 use the SqliteActivityTracker is the following:

C#
Thing1 t = new Thing1(new SqliteActivityTracker());

Now all the internal (to Thing1) calls to WriteActivity will write to the Sqlite database instead.

Get the Source Code and Try It

You can get the source code and build it and try it.

You will see that the only calling code is the following four lines:

C#
ITrackable fat = new FileActivityTracker();
fat.WriteActivity("The app is running.");

ITrackable sat = new SqliteActivityTracker();
sat.WriteActivity("This is from the app!");

We could've even made that a bit more generic to show that the same ITrackable can carry any ITrackable type by showing the following code:

C#
ITrackable tracker = new FileActivityTracker();

// Writes to the file
tracker.WriteActivity("The app is running.");

// set tracker to carry a SqliteActivityTracker
tracker = new SqliteActivityTracker();

// writes to sqlite db
tracker.WriteActivity("This is from the app!");

Dependency Injection is Really About Design

As you can see, DI is really about Design and thinking about your classes in a more abstract way.

You have to think about shared behaviors and how you might create classes that support those similar behaviors that are implemented in different ways.

Get the Source & Try It Out

If you get the code, you will see that it will write:

  1. Write to a InterfaceStudy.log file
  2. Create and write to a Sqlite db (tracker.db in same location as file)

You'll see output something like the following:

Quote:

2024-02-12 11:14:16.358 AM : Configure() method called!
2024-02-12 11:14:16.394 AM : FileActivityTracker ctor...
/home/<user-name>/temp/tracker.db
/home/<user-name>/temp/tracker.db
Saving...
Opened.
inserted.

New Ideas & Comments?

I hope this has given you some new ideas about this concept and I hope you'll comment about what you like and don't like.

History

  • 12th February, 2024: Code & article, first publication

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)