Monday 30 May 2022

CFML: Implementing an ObservableScopeAdapter using the Adapter Pattern, Decorator Pattern and Observer Pattern

G'day:

In my last article (A super-quick Observer Pattern implementation in CFML, and I skip TDD. Or do I?), I did what it suggests: I created a very simple observer pattern implementation in CFML.

Why? Well: to copy and paste from that article:

A few days back I was chatting to someone about application-scope-usage, and how to trigger an event when any changes to the application scope took place. I mentioned that the application scope should never be accessed directly in one's application code, and it should be wrapped up in an adapter. And it's easy for one's adapter to trigger events if needs must. An implementation of the Observer Pattern would do the trick here.

I wanted to keep the article brief-ish and on-point, so just focused on one part of it. In this article I'll put that observer service to use in an application scope adapter.

Firstly I need a scope adapter. The interface to this will be the same for any scope, so I'm gonna keep the name generic.

Well: firstly I need some tests to clearly define how the thing should work:

describe("Tests for set method", () => {
    it("sets a key's value in the scope", () => {
        var scopeAdapter = new ScopeAdapter(local)

        scopeAdapter.set("testVariable", "test value")

        expect(local).toHaveKey("testVariable")
        expect(local.testVariable).toBe("test value")
    })
})

describe("Tests for get method", () => {
    it("gets a value from the scope by its key", () => {
        var scopeAdapter = new ScopeAdapter(local)
        var testVariable = "test value"

        var result = scopeAdapter.get("testVariable")

        expect(result).toBe("test value")
    })
})

And a very simple implementation:

component {

    public function init(required struct scope) {
        variables.scope = arguments.scope
    }

    public void function set(required string key, required any value) {
        variables.scope[key] = value
    }

    public any function get(required string key) {
        return variables.scope[key]
    }

}

And that works. In real life before this was production-ready we'd probably want some error-handling around trying to get values that aren't set, and poss something to handle overwriting values etc. But for my purposes here: this will do.

When I first planned the code for this article in my head, the next step was gonna be to initialise the ScopeAdapter with an ObserverService object, and have it fire off some events on set. Then it occurred to me that this is breaking the Single Responsibility Principle a bit. It's not for the general ScopeAdapter to be doing anything other than being an adapter to a scope. So what I'm going to do is to use the Decorator Pattern: I'm going to create a decorator for a ScopeAdapter that is an ObservableScopeAdapter.

I need to back up slightly and do a slight refactor. If I'm using a decorator, then I need to be able to code to an interface, not to an implementation. I can't be having code that takes specifically a cfml.forBlog.applicationScopeAdapter.ScopeAdapter object; I need it to take an implementation of a cfml.forBlog.applicationScopeAdapter.ScopeAdapter interface. This is because when using a decorator, obviously it's no longer the same concrete implementation class. EG: if I have SomeService class, and it takes as one of its constructor parameters a ScopeAdapter object, I can't then initialise it with an ObservableScopeAdapter. One might think this is solved by making ObservableScopeAdapter simply extend ScopeAdapter. This would work, but it's a bit of an anti-pattern, and I discuss this in "Decorator Pattern vs simple inheritance". Instead we provide an interface for both ObservableScopeAdapter and the current ScopeAdapter (which will need to be renamed) to implement, and we make our SomeService take one of those as its argument.

I'm going to rename ScopeAdapter to be GeneralScopeAdapter, and create an interface that it implements called ScopeAdapter. Yay for tests, as I am refactoring here, not just "changing shit". The only new code here is the interface:

interface {
    public void function set(required string key, required any value);
    public any function get(required string key);
}

Now we want our decorator. It will do two things:

  • Hand off any calls to methods in the ScopeAdapter interface to a full implementation of a ScopeAdapter it has been configured with
  • When set is called, it triggers an event that other code can have subscribed to.

"Hang on", you might say "isn't it doing two things, which is still a violation of the SRP?". Not really. It's only implementing the event trigger part. It's delegating the scope-adapting to its dependent GeneralScopeAdapter. Indeed it's not even implementing the event-triggering side of things. It's delegating that to the ObserverService. So the one thing the decorator is implementing is "making the scope access observable". Make sense?

Anyway here are some tests to set our expectations:

import cfml.forBlog.applicationScopeAdapter.GeneralScopeAdapter
import cfml.forBlog.applicationScopeAdapter.ObservableScopeAdapter
import cfml.forBlog.observerService.SimpleObserverService

component extends=Testbox.system.BaseSpec {
    function run() {
        describe("Tests for ObservableScopeAdapter", () => {
            describe("Tests for set method", () => {
                it("functions as a ScopeAdapter when setting a key's value in the scope", () => {
                    // same implementation as above
                })

                it("triggers an event when set is called, which receives the key/value of the set call", () => {
                    var eventLog = []
                    var localScopeAdapter = new GeneralScopeAdapter(local)
                    var observerService = new SimpleObserverService()
                    var observableScopeAdapter = new ObservableScopeAdapter(localScopeAdapter, observerService)

                    observerService.on("scope.set", (event) => {
                        eventLog.append(event)
                    })

                    observableScopeAdapter.set("testVariable", "test value")

                    expect(eventLog).toHaveLength(1)
                    expect(eventLog[1]).toBe({
                        name = "scope.set",
                        data = javaCast("null", ""),
                        detail = {
                            key = "testVariable",
                            value = "test value"
                        }
                    })
                })
            })

            describe("Tests for get method", () => {
                it("functions as a ScopeAdapter when getting a value from the scope by its key", () => {
                    // same implementation as above
                })
            })
        })
    }
}

Notes:

  • I've changed the name of the ObserverService to be SimpleObserverService because I've extracted an interface for that too, and now that is called ObserverService. I didn't need to do this, but I figured it was more balanced, and "one should program to an interface", etc
  • Where I say same implementation as above, it's literally the same implementation as the earlier tests, because I want to test that the decorated implementation still behaves the same.
  • data is data passed along with the on call, which I am not using here.
  • I had actually neglected to add the feature of trigger to accept details (ie: data) at trigger-time in my initial implementation of BasicObserverService! So I had to add that functionality in. See below.

There's not much to the implementation:

import cfml.forBlog.applicationScopeAdapter.GeneralScopeAdapter
import cfml.forBlog.observerService.ObserverService


component implements=ScopeAdapter {

    public function init(required GeneralScopeAdapter scopeAdapter, ObserverService observerService) {
        variables.scopeAdapter = arguments.scopeAdapter
        variables.observerService = arguments.observerService
    }

    public void function set(required string key, required any value) {
        variables.observerService.trigger("scope.set", arguments)
        variables.scopeAdapter.set(argumentCollection=arguments)
    }

    public any function get(required string key) {
        return variables.scopeAdapter.get(argumentCollection=arguments)
    }

}
  • It implements the same interface as GeneralScopeAdapter, so it can be used anywhere that one of those is required.
  • It's initialised with an underlying GeneralScopeAdapter that does all the scope-adapting, as well as an ObserverService which does all the event handling.
  • get simply proxies to the underlying GeneralScopeAdapter
  • set does that too, but not before triggering an event that it's been called.

As I touched on above, I needed to add a feature to SimpleObservrService so that it passed detail data when triggering an event. Here's the test:

it("passes any extra details as part of the event", () => {
    var observerService = new SimpleObserverService()

    var eventResults = []

    observerService.on("testEvent", (event) => {
        eventResults.append([
            message = "test event handler 1",
            event = event
        ])
    }, {key="value set in handler"})

    observerService.trigger("testEvent", {
        key = "value set by trigger"
    })
    expect(eventResults).toBe([[
        message = "test event handler 1",
        event = {
            name = "testEvent",
            data = {key = "value set in handler"},
            detail = {key = "value set by trigger"}
        }
    ]])
})

That's self-explanatory I think.

And implementation:

public boolean function trigger(required string event, struct detail={}) {
    registerEvent(event)
    return variables.eventRegistry[event].some((eventData) => eventData.handler({
        name = event,
        data = eventData?.data,
        detail = detail
    }) == false)
}

This broke one other test, but in an expected way because it was testing what came back in the event struct, focusing on testing the data part, but it did not expect the new (empty) detail part of the data. All the other tests continued to pass, so I know this change has no unexpected side-effects.


That's it, all working. But how does one wire this into one's app? Well. Any place one currently accesses the application scope (remember the initial requirement was to trigger events when there were changes to the application scope) directly in the app needs to be swapped out for an instance of ObservableScopeAdapter that is adapting the application scope. This can be done easily enough with a DI framework: just inject it into whichever objects formerly accessed the application scope directly, and then use it. Secondly: wherever it is relevant to react to the "scope.set" event: pass in an instance of ObserverService, and attach an event handler to the "scope.set" event that does whatever one needs to. Obviously (?) it needs to be the same instance of ObserverService that the ObservableScopeAdapter is using. Again: super easy with your DI framework.

I realise this section is a bit like this:

Possibly © 9gag.com - I am using it without permission. If you are the copyright owner and wish me to remove this, please let me know.

But it's not really in scope here to explain how dependency injection works. You should already know.


The original question is begging slightly though. It presupposed the situation where there's application-scope access all over the place in an application. In a well-designed app, there simply shouldn't be. Everything ought to be managed by a DI container, and no actual objects ought to know what scope they are in, or care what scope their dependency objects are in. And data values that need to persist for the life of the application should just be in objects that persist for the life of the application, so this should not directly involve the application scope at all.

The same applies for any scope in your CFML code. The only scopes that should generally be relevant to your code are:

  • The variables scope of the CFC the code is in; reflecting the state of the object, or its dependencies, etc
  • The arguments scope of any argument values being passed into your function.
  • The local scope within your function.
  • One is possibly tied to some thread-specific scopes if using cfthread

That's it. If yer accessing any scopes other than those, you should be questioning the design of your application, I think.

The code for this lot is in Github @ /forBlog/applicationScopeAdapter (and its tests) and /forBlog/observerService (and its tests).

Righto.

--
Adam