Thursday, 2 May 2019

VBA - Dependency Injection

I've been reading about Dependency Injection and I was wondering what it would look like in VBA. Let's start with the Wikipedia definition ...

"In software engineering, dependency injection is a technique whereby one object (or static method) supplies the dependencies of another object. A dependency is an object that can be used (a service). An injection is the passing of a dependency to a dependent object (a client) that would use it. The service is made part of the client's state. Passing the service to the client, rather than allowing a client to build or find the service, is the fundamental requirement of the pattern."

Design decisions

It seems there are frameworks available to help implement this pattern in C#. I do not believe that they exist for VBA; not that I'd use them if they did exist. I want a simple implementation.

Also, I want to avoid the proliferation of interfaces because they can clutter the project explorer in VBA. Also, it seems odd to me that to facilitate loose coupling between two classes one has to define a third abstract class. Can't we achieve loose coupling using IDispatch, i.e. declaring variable objects to be of type Object? Well I choose to use IDispatch, let's see how it goes.

The Modules

In the example I have two business logic classes, TaxCalculator and TaxBiller. I have two logger classes, TempFileLogger and ImmediateWindowLogger which both support the Log method and so are interchangeable. I also have a DevelopmentIdentity class to supply identity services, I have omitted a substitute which queries ActiveDirectory. There is some client code. Finally, there is a modDependencyInjector module which holds a pool of services in a dictionary.

There are no separate interfaces as noted above. That is a design decision to prevent proliferation of classes (we already have five!).

The Logger classes, ImmediateWindowLogger and TempFileLogger

The logger classes provides logging services.

The ImmediateWindowLogger class is simple for illustrative purposes. It has a single method which takes the message to log.

Option Explicit

Public Sub Log(ByVal sMsg As String)
    Debug.Print sMsg
End Sub

The TempFileLogger class is simple shares the same interface, one single method. So the two are interchangeable. TempFileLogger differs in that it writes to a temporary file.

Option Explicit

Private msTempFile As String
Private moFSO As Scripting.FileSystemObject

Private Sub Class_Initialize()
    msTempFile = Environ$("temp") & "\LogFile.txt"
    Set moFSO = New Scripting.FileSystemObject
    If moFSO.FileExists(msTempFile) Then Kill msTempFile '* remove previous
    Debug.Print "Logging to:"; msTempFile
End Sub

Public Sub Log(ByVal sMsg As String)
    Dim oTxt As Scripting.TextStream
    
    If moFSO.FileExists(msTempFile) Then
        Set oTxt = moFSO.OpenTextFile(msTempFile, ForAppending)
    Else
        Set oTxt = moFSO.OpenTextFile(msTempFile, ForWriting, True)
    End If
    oTxt.WriteLine sMsg
    oTxt.Close
    Set oTxt = Nothing
End Sub

The Identity class, DevelopmentIdentity

This class provides identity services but the given class DevelopmentIdentity is a mock class. A proper identity services class would probably query ActiveDirectory but that is beyond the scope of this blog post. Here is the DevelopmentIdentity class

Option Explicit

Public Function HasPermission(ByVal sGroupName As String) As Boolean
    HasPermission = True ' grant all to a developer
End Function

Public Function LogonUsername() As String
    LogonUsername = Application.UserName
End Function

The Dependency Injector module, modDependencyInjector

So the point about dependency injection is that the business classes do not assemble their dependencies (services) themselves; it is done externally. But they have to be decided somewhere, that is why we have a separate module, modDependencyInjector, which maintains a collection (I have chosen Scripting.Dictionary) of services waiting to be injected into any new business logic classes. In the listing below one can see that one chooses Logger class and not both (which breaks the Dictionary). In a better example, a configuration file could be read to determine which services class to select.

The dependency injection mechanism is implemented in InjectDependenciesToDict() where a request dictionary is populated; this is called from a class's constructor. Technically this isn't injection; in C# a framework could read a class's meta data and be clever in its mechanism. As we are using the venerable VBA then such as clever mechanism is beyond reach.

Option Explicit

Private mdicServices As Scripting.Dictionary

Public Property Get Services() As Scripting.Dictionary
    
    If mdicServices Is Nothing Then
        Set mdicServices = New Scripting.Dictionary
        
        '* choose a logger
        mdicServices.Add "Logger", New ImmediateWindowLogger
        'mdicServices.Add "Logger", New TempFileLogger
        
        '* choose an identity service
        mdicServices.Add "Identity", New DevelopmentIdentity
        
    End If
    Set Services = mdicServices
End Property

Sub InjectDependenciesToDict(ByVal dicDependenciesOfNewObject As Scripting.Dictionary, _
            vDependencies As Variant)
    
    Dim dicServices As Scripting.Dictionary
    Set dicServices = Services
    
    Dim vDependency As Variant
    For Each vDependency In vDependencies
        If dicServices.Exists(vDependency) Then
            Set dicDependenciesOfNewObject.Item(vDependency) = dicServices.Item(vDependency)
        End If
    Next
    
End Sub

The Business Logic classes, TaxCalculator and TaxBiller

In the business logic classes below (whose purpose is self-explanatory, I hope) one can see the services being requested in the class constructor's Class_Initialize(). The returned services are kept in a class level dictionary. To access a service in the body of the class one calls into the Item method of that dictionary. So to get the logging service one writes mdicServices("Logger") and to get the identity service one writes mdicServices("Identity"). Note, that Item is called implicitly as it is the default method.

Here is the TaxCalculator class

Option Explicit

Private mdicServices As New Scripting.Dictionary

Private Sub Class_Initialize()
    InjectDependenciesToDict mdicServices, Array("Logger", "Identity")
End Sub

Public Function CalculateTax(ByVal lAmount As Long) As Long
    If mdicServices("Identity").HasPermission("Taxcalc") Then
        CalculateTax = lAmount * 0.2
        mdicServices("Logger").Log "Authorised, Calculated tax at 20%"
    Else
        mdicServices("Logger").Log "Not authorised to run this"
    End If
End Function

Here is the TaxBiller class

Option Explicit

Private mdicServices As New Scripting.Dictionary

Private Sub Class_Initialize()
    InjectDependenciesToDict mdicServices, Array("Logger")
End Sub

Public Sub SendTaxBill(ByVal lAmount As Long)
    mdicServices("Logger").Log "Bill for $" & lAmount & " sent to Mr Simpson"
End Sub

Some client code

So we need to illustrate how easy it is to call this code. In the client code below, one can see the dependency injection is completely absent and thus unobtrusive.

Option Explicit

Sub Test()

    Dim oTaxCalculator As TaxCalculator
    Set oTaxCalculator = New TaxCalculator

    Dim oTaxBiller As TaxBiller
    Set oTaxBiller = New TaxBiller

    Dim lTaxable As Long
    lTaxable = 50
    
    Dim lBill As Long
    lBill = oTaxCalculator.CalculateTax(lTaxable)
    oTaxBiller.SendTaxBill lBill

End Sub

Final thoughts

The term Dependency injection implies some mechanism takes over the instantiation and initialization of a class whereas the above code does not do that. Nevertheless, services are decoupled so the objective is achieved. Enjoy!

No comments:

Post a Comment