Search code examples
excelvbarubberduck

Properly handle Worksheet events when sheet's code-behind is implemented in a "proxy" interface+class


I am trying to further build on this excellent example, which already implements the best practices discussed in these very insightful RubberduckVBA.com articles:

  1. Abstracting away from the Excel workbook/worksheet through proxy classes;
  2. Utilizing the UserForm control without messing with the state of its default instance;
  3. Adding "Apply" logic to #2.

I would like to add to the existing example an event handler which (for simplicity's sake) reports the value of the upper-left cell of Sheet2's "changed" range in Sheet1's "A1" cell, along with the time of the change in "A2". I would normally set about doing this in Sheet2's code-behind like this:

Private Sub Worksheet_Change(ByVal Target As Range)
    Sheet1.Cells(1, 1).Value2 = Target.Cells(1, 1).Value2
    Sheet1.Cells(1, 2).Value2 = CStr(Now)
End Sub

but I want to know how to best achieve this in the given example, considering it is designed around the MVP pattern and utilizes workbook&worksheet abstraction through proxy interfaces - meaning zero/minimal sheet code-behind is expected.

I was able to make sense of how event-handling is implemented in the awesome Battleship tutorial but its design differs in some significant ways:

  1. "Battleship" follows the MVC design pattern while I would like to stick with MVP as in the example;
  2. "Battleship" abstracts away from its worksheets through a "View" class while I would like to have a separate proxy interface+class for each sheet;
  3. "Battleship" deploys the Adapter Pattern while I am fine with having my view and sheet proxy implementations coupled with the presenter (if possible with regards to event handling).

With that in mind I would absolutely love to see a code sample which adds the "Worksheet_Change" event I described above to the base project which has already implemented Workbook and Worksheet proxies and follows the MVP pattern.

Even without a code sample it would be of great help if I get those questions cleared up:

  1. Does the Worksheet proxy approach dictate that there should be absolutely zero sheet code-behind? Would it be a step in the wrong direction if I begin my "Worksheet_Change" event implementation inside Sheet2 (not its proxy) like this:
Public Event SheetChanged(ByVal changedRange As Range)

Private Sub Worksheet_Change(ByVal Target As Range)
    RaiseEvent SheetChanged(Target)
End Sub
  1. If it is not absolutely necessary to use the Adapter Pattern for event handling, is it still a good idea to have the "IViewCommands" and "IViewEvents" interfaces for listing all commands sent from the Presenter to the View and events raised from the View and sent to the Presenter respectively?
  2. I assume I would need to use Lazy Object/Weak Reference to be able to expose events. If so, and assuming I can get the job done without an Adapter (see #2 above), does that mean that my "Sheet2Proxy" class will have to hold a weak reference to the Presenter, through its "IViewEvents" (again see #2 above) interface?

Solution

  • You're abstracting the Worksheet behind a "proxy" class; by definition, it's coupled with the worksheet, and what you want is to make sure the abstraction is air-tight, lest you're looking at a leaky abstraction and end up coupling other code with the Excel.Worksheet type, which defeats the entire purpose.

    For the rest of the project, the worksheet proxy class is acting as a facade that manipulates and understands everything there is to know about a specific Excel.Worksheet: a consequence of this is that you can now use two modules to abstract worksheet things - the worksheet itself, and the proxy class:

    • Worksheet code-behind can abstract things like ListObject/tables, named ranges, etc.; using Property Get members that the proxy can consume.
    • Worksheet proxy class abstracts worksheet manipulations from the rest of the code.

    Indeed, this approach doesn't leave much room/need for actual worksheet code-behind: I would start coding everything in the proxy class, and if that module gets too wordy, or if I'm finding its abstraction level needs to get a bit higher, then I'd move the lower-level stuff to the worksheet's code-behind itself.

    Workheet modules and other document modules should not implement interfaces - making a worksheet implement an interface is a good way to confuse and crash VBA: don't do it. So this might be your code-behind:

    Option Explicit
    
    Public Property Get SomeSpecificRange() As Range
        Set SomeSpecificRange = Me.Names("SomeSpecificRange").RefersToRange
    End Property
    

    Then the proxy class can do this:

    Option Explicit
    Private sheetUI As Sheet1
    Private WithEvents sheet As Worksheet
    
    Private Sub Class_Initialize()
        Set sheet = Sheet1
        Set sheetUI = Sheet1
    End Sub
    
    Private Sub sheet_Change(ByVal Target As Range)
        If Intersect(Target, sheetUI.SomeSpecificRange) Then
            '...
        End If
    End Sub
    

    So the proxy class can handle worksheet events just fine without the whole adapter plumbing. It can also handle commands coming from your presenter, through its exposed Public members.

    But the proxy class aka "abstracted worksheet" isn't the right place to respond to events: it's the presenter that needs to be running the show.

    So you make the proxy fire an event in response to worksheet events, wrapping up and forwarding the message to the presenter:

    Option Explicit
    Public Event SomeSpecificRangeChanged()
    Private sheetUI As Sheet1
    Private WithEvents sheet As Worksheet
    
    Private Sub Class_Initialize()
        Set sheet = Sheet1
        Set sheetUI = Sheet1
    End Sub
    
    Private Sub sheet_Change(ByVal Target As Range)
        If Intersect(Target, sheetUI.SomeSpecificRange) Then
            RaiseEvent SomeSpecificRangeChanged
        End If
    End Sub
    

    The presenter can then handle SomeSpecificRangeChanged off the proxy class - bring up some UserForm, fire up some database query, whatever the requirements are:

    Private WithEvents proxy As Sheet1Proxy
    
    Private Sub Class_Initialize()
        Set proxy = New Sheet1Proxy
    End Sub
    
    Private Sub proxy_SomeSpecificRangeChanged()
        'business logic to run when SomeSpecificRange is changed
    End Sub
    

    The problem is that the proxy class is coupled with the worksheet, and now the presenter is coupled with the proxy: we've abstracted a lot of things, but there's still no way to swap the worksheet/proxy dependency for something else and test the presenter logic without involving the worksheet.

    So we make an interface to decouple the presenter from the proxy - say, ISheet1Proxy... and now we're stuck, because we can't expose events on an interface.

    This is where the adapter pattern comes into play and allows us to formalize interfaces for "commands" (presenter -> view) and "events" (view -> presenter).

    With an adapter, the worksheet/proxy and the presenter are now fully decoupled, and now you can implement the presenter logic without any knowledge of any Excel.Worksheet, and ideally any Excel.Range or Excel.*: every single worksheet interaction is formalized as some "command" that's sent to the view/worksheet/proxy, or some "event" that sent to the presenter, exactly like in the Battleship project.

    Side note, I've found that the WeakReference stuff wasn't always needed for properly tearing down the object hierarchies: that's why it's no longer in use in the current version of the Battleship code.


    Obviously this is a lot of work. It's great practice for OOP principles and learning to write decoupled code that can be unit-tested... but for a small VBA project, it's wildly overkill IMO.


    All this treats Excel.* classes as concrete types, which as far as VBA is concerned, might as well be the case. However the Excel interop types are all interfaces as far as .NET is concerned, so Rubberduck is about to tremendously simplify everything, by providing a wrapper API for Moq, a wildly popular .NET mocking framework:

    Rubberduck mocking framework coming soon-ish

    This will remove the need to fully decouple worksheets from user code in order to make it fully testable - the only requirement will be dependency injection, i.e. prefer this:

    Public Sub DoSomething(ByVal target As Range)
        target.Value = 42
    End Sub
    

    Over this:

    Public Sub DoSomething()
        Dim target As Range
        Set target = Sheet1.Range("A1")
        target.Value = 42
    End Sub