Throughout my years of working as a Software Engineer, I came across many occasions where I couldn’t understand the code I am looking at.
First, I thought that this is originating from a lack of knowledge on my side or that my skills are not sharp enough, and this was always pushing me to learn more and more.
However, after years and years of learning and implementing, I was surprised that from time to time I am still facing the same problem. How come even after 12+ years of being a Software Engineer, I am still having the same exact problem?!
Someone might answer this question that maybe the code you are looking at is actually too bad and that’s why it would be so hard for anyone to understand, maybe even the one who wrote it.
Unfortunately, no. Believe me, I hoped that this is the answer, but this is not the case I am talking about. The code I am referring to is actually, by today’s standards, perfect.
Then what?!!
When I looked closely into it, and I invested some quiet time doing this, I found one of the most important findings in my life as a Software Engineer.
Dependency Injection (DI), Inversion of Control (IoC), and IoC Containers are our friends, but like everything in life, if you abuse using them, you would get what you don’t ever wish for.
Before DI and IoC Containers, it was hell to manage dependencies between different modules/classes, and that’s why we were more careful and cautious about defining these dependencies. We used to think twice or even more about each module/class dependency before starting the implementation.
However, now after having DI, IoC, and IoC Containers, defining a dependency became like breathing, you implicitly do it when you actually don’t recognize it.
To understand what I actually mean, I would walk you through a practical example. I know you love code, so, why not start coding and see how it works?
Our simple example here is about the software that calculates Taxes. We have two types of defined Taxes; VAT and Income.
Now, let’s start implementing the code.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TaxesCalculator.Abstractions
{
public interface ILogger
{
void LogMessage(string message);
}
}
What we can notice here:
ILogger
.double void LogMessage(string message);
.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TaxesCalculator.Abstractions
{
public interface ITaxCalculator
{
double CalculateTaxPerMonth(double monthlyIncome);
}
}
What we can notice here:
ITaxCalculator
.double CalculateTaxPerMonth(double monthlyIncome);
.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TaxesCalculator.Abstractions;
namespace TaxesCalculator.Implementations.Loggers
{
public class ConsoleLogger : ILogger
{
public void LogMessage(string message)
{
Console.WriteLine(message);
}
}
}
What we can notice here:
ConsoleLogger
class implementing ILogger
.System.Console
class and uses it to write to the Console. This is not the perfect implementation but it would be enough for now in order to drive your focus on the current scope.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TaxesCalculator.Abstractions;
namespace TaxesCalculator.Implementations.TaxCalculators
{
public class IncomeTaxCalculator : ITaxCalculator
{
private readonly ILogger m_Logger;
public IncomeTaxCalculator(ILogger logger)
{
m_Logger = logger;
}
public double CalculateTaxPerMonth(double monthlyIncome)
{
// Do some interesting calculations
var tax = monthlyIncome * 0.5;
// Don't forget to log the message
m_Logger.LogMessage($"Calculated Income Tax per month for Monthly Income: {monthlyIncome} equals {tax}");
return tax;
}
}
}
What we can notice here:
IncomeTaxCalculator
class implementing ITaxCalculator
.ILogger
to be able to log some important messages about the calculations.ILogger
is injected into the constructor.CalculateTaxPerMonth
method implementation, we just do the calculations and log the message using the injected ILogger
.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TaxesCalculator.Abstractions;
namespace TaxesCalculator.Implementations.TaxCalculators
{
public class VatTaxCalculator : ITaxCalculator
{
private readonly ILogger m_Logger;
public VatTaxCalculator(ILogger logger)
{
m_Logger = logger;
}
public double CalculateTaxPerMonth(double monthlyIncome)
{
var tax = 0.0;
// Do some complex calculations on more than one step
// Step1
tax += monthlyIncome * 0.0012;
m_Logger.LogMessage($"VAT Calculation Step 1, Factor: {monthlyIncome * 0.0012}, Total: {tax}");
// Step2
tax += monthlyIncome * 0.003;
m_Logger.LogMessage($"VAT Calculation Step 2, Factor: {monthlyIncome * 0.003}, Total: {tax}");
// Step3
tax += monthlyIncome * 0.00005;
m_Logger.LogMessage($"VAT Calculation Step 3, Factor: {monthlyIncome * 0.00005}, Total: {tax}");
// Don't forget to log the final message
m_Logger.LogMessage($"Calculated Vat Tax per month for Monthly Income: {monthlyIncome} equals {tax}");
return tax;
}
}
}
What we can notice here:
VatTaxCalculator
class implementing ITaxCalculator
.ILogger
to be able to log some important messages about the calculations.ILogger
is injected into the constructor.CalculateTaxPerMonth
method implementation, we just do the calculations and log the message using the injected ILogger
.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Autofac;
using TaxesCalculator.Abstractions;
using TaxesCalculator.Implementations.Loggers;
using TaxesCalculator.Implementations.TaxCalculators;
namespace TaxesCalculator
{
class Program
{
private static IContainer Container;
static void Main(string[] args)
{
var builder = new ContainerBuilder();
builder.RegisterType<ConsoleLogger>().As<ILogger>();
builder.RegisterType<IncomeTaxCalculator>().As<ITaxCalculator>();
builder.RegisterType<VatTaxCalculator>().As<ITaxCalculator>();
Container = builder.Build();
using (var scope = Container.BeginLifetimeScope())
{
var logger = scope.Resolve<ILogger>();
var taxCalculators = scope.Resolve<IEnumerable<ITaxCalculator>>();
var monthlyIncome = 2000.0;
var totalTax = 0.0;
foreach (var taxCalculator in taxCalculators)
{
totalTax += taxCalculator.CalculateTaxPerMonth(monthlyIncome);
}
logger.LogMessage($"Total Tax for Monthly Income: {monthlyIncome} equals {totalTax}");
}
Console.ReadLine();
}
}
}
What we can notice here:
Program
class. It is the main entry point to the whole application, which is, by the way, a C# Console Application.Main
method, the first thing we are doing is that we are initializing the IoC container, defining our abstractions-implementations pairs, and creating our IoC container scope.ILogger
, and a list of all available ITaxCalculator
implementations.
When running the application, we would get the result in the image below.
Great, the application is working as expected, we have defined our dependencies, we are using DI, IoC, and IoC Containers… perfect.
Ok, you might find this perfect and easy to read and understand. However, what if you have too many modules, too many loggers, too many calculators,…
What if…
We have a lot of “What ifs”, and don’t get me wrong, I understand that these were not a part of the requirements in the first place. So, I am not blaming you for not considering these into the design.
However, what is concerning me here is:
Again, I know that you should not apply complicated designs based on dreams of what could come in the next 50 years or something. However, we sometimes oversimplify things when actually applying simple best practices would make the whole design more robust and dependable.
What you can do is simply change the way you think about dependencies.
Yes, we know that a dependency is coupled with an implementation, not with an abstraction. But still, do you think that the implementation IncomeTaxCalculator
should be dependent on a logger?
I can understand that an SQLDatabaseRepository
class implementation, which implements IRepository
interface, would -by definition- depend on some module that opens and closes an SQL database connection. This is something that you can easily say with full trust.
However, you can’t easily say, with the same level of trust, that the same SQLDatabaseRepository
class implementation depends on a logger module, right?
With some simple changes to the design, we can make it happen. So, let’s dive into the code.
namespace TaxesCalculator.Abstractions
{
public delegate void TaxCalculationReportReadyEventHandler(object sender, string report);
public interface ITaxCalculator
{
event TaxCalculationReportReadyEventHandler TaxCalculationReportReady;
double CalculateTaxPerMonth(double monthlyIncome);
}
}
What we can notice here:
ITaxCalculator
interface.TaxCalculationReportReadyEventHandler
.TaxCalculationReportReadyEventHandler
.
using TaxesCalculator.Abstractions;
namespace TaxesCalculator.Implementations.TaxCalculators
{
public abstract class TaxCalculatorBase : ITaxCalculator
{
public event TaxCalculationReportReadyEventHandler TaxCalculationReportReady;
public abstract double CalculateTaxPerMonth(double monthlyIncome);
protected void OnTaxCalculationReportReady(string report)
{
TaxCalculationReportReady?.Invoke(this, report);
}
}
}
What we can notice here:
TaxCalculatorBase
for all ITaxCalculators
implementations.OnTaxCalculationReportReady
method which is responsible for internal triggering the TaxCalculationReportReady
event. This is one of the best practices advised by Microsoft.
namespace TaxesCalculator.Implementations.TaxCalculators
{
public class IncomeTaxCalculator : TaxCalculatorBase
{
public override double CalculateTaxPerMonth(double monthlyIncome)
{
// Do some interesting calculations
var tax = monthlyIncome * 0.5;
// Don't forget to report
OnTaxCalculationReportReady(
$"Calculated Income Tax per month for Monthly Income: {monthlyIncome} equals {tax}");
return tax;
}
}
}
What we can notice here:
IncomeTaxCalculator
class extends the TaxCalculatorBase
class instead of the ITaxCalculator
interface.ILogger
interface as it used to be in the old implementation.TaxCalculationReportReady
event instead of directly using an instance of the ILogger
interface.
namespace TaxesCalculator.Implementations.TaxCalculators
{
public class VatTaxCalculator : TaxCalculatorBase
{
public override double CalculateTaxPerMonth(double monthlyIncome)
{
var tax = 0.0;
// Do some complex calculations on more than one step
// Step1
tax += monthlyIncome * 0.0012;
OnTaxCalculationReportReady($"VAT Calculation Step 1, Factor: {monthlyIncome * 0.0012}, Total: {tax}");
// Step2
tax += monthlyIncome * 0.003;
OnTaxCalculationReportReady($"VAT Calculation Step 2, Factor: {monthlyIncome * 0.003}, Total: {tax}");
// Step3
tax += monthlyIncome * 0.00005;
OnTaxCalculationReportReady($"VAT Calculation Step 3, Factor: {monthlyIncome * 0.00005}, Total: {tax}");
// Don't forget to log the final message
OnTaxCalculationReportReady(
$"Calculated Vat Tax per month for Monthly Income: {monthlyIncome} equals {tax}");
return tax;
}
}
}
The same kind of changes as in IncomeTaxCalculator
class.
using System;
using System.Collections.Generic;
using Autofac;
using TaxesCalculator.Abstractions;
using TaxesCalculator.Implementations.Loggers;
using TaxesCalculator.Implementations.TaxCalculators;
namespace TaxesCalculator
{
class Program
{
private static ILogger Logger;
private static IContainer Container;
static void Main(string[] args)
{
var builder = new ContainerBuilder();
builder.RegisterType<ConsoleLogger>().As<ILogger>();
builder.RegisterType<IncomeTaxCalculator>().As<ITaxCalculator>();
builder.RegisterType<VatTaxCalculator>().As<ITaxCalculator>();
Container = builder.Build();
using (var scope = Container.BeginLifetimeScope())
{
Logger = scope.Resolve<ILogger>();
var taxCalculators = scope.Resolve<IEnumerable<ITaxCalculator>>();
var monthlyIncome = 2000.0;
var totalTax = 0.0;
foreach (var taxCalculator in taxCalculators)
{
taxCalculator.TaxCalculationReportReady += (sender, report) => LogTaxReport(report);
totalTax += taxCalculator.CalculateTaxPerMonth(monthlyIncome);
}
Logger.LogMessage($"Total Tax for Monthly Income: {monthlyIncome} equals {totalTax}");
}
Console.ReadLine();
}
private static void LogTaxReport(string report)
{
Logger.LogMessage(report);
}
}
}
What we can notice here:
TaxCalculationReportReady
event for each ITaxCalculator
interface implementation.LogTaxReport
method.
Running the application, we would get the result as before as in the image below:
So, now we have the same result but with a different design and extended capabilities.
Now, we can easily adapt the design to add new features as we wished for in the “What If” section above, easy and clean…
In the end, I want to stress something — in the software world, you keep growing day by day, and you should always keep your eyes focused on what to learn next. It is never too late to learn.
Finally, hope you found reading this article as interesting as I found writing it.
Also Published Here