G'day:
Recently I wanted to abstract some logic out of one of our CFWheels model classes, into its own representation. Code had grown organically over time, with logic being inlined in functions, making a bunch of methods a bit weighty, and had drifted well away from the notion of following the "Single Responsibility Principle". Looking at the code in question, even if I separated it out into a bunch of private methods (it was a chunk of code, and refactoring into a single private method would not have worked), it was clear that this was just shifting the SRP violation out of the method, and into the class. This code did not belong in this class at all. Not least of all because we also needed to use some of it in another class. This is a pretty common refactoring exercise in OOP land.
I'm going to be a bit woolly in my descriptions of the functionality I'm talking about here, because the code in question is pretty business-domain-specific, and would not make a lot of sense to people outside our team. Let's just say it was around logging requirements. It wasn't, but that's a general notion that everyone gets. We need to log stuff in one of our classes, and currently it's all baked directly into that class. It's not. But let's pretend.
I could see what I needed to do: I'll just rip this lot out into another service class, and then use DI to… bugger. CFWheels does not have any notion of dependency injection. I mean... I'm fairly certain it doesn't even use the notion of constructors when creating objects. If one wants to use some code in a CFWheels model, one writes the code in the model. This is pretty much why we got to where we are. If one wants to use code from another class in one's model... one uses the model factory method to get a different model (eg: myOtherModel = model("Other")). Inline in one's code. There's a few drawbacks here:
- In CFWheels parlance, "model" means "an ORM representation of a DB row". The hard-coupling between models and a DB table is innate to CFWheels. It didn't seem to occur to the architects of CFWheels that not all domain model objects map to underlying DB storage. One can have a "tableless model", but it still betrays the inappropriate coupling between domain model and storage representation. A logging service is a prime example of this. It is part of the domain model. It does not represent persisted objects. In complex applications, the storage considerations around the logic is just a tier underneath the domain model. It's not baked right into the model. I've just found a telling quote on the CFWheels website:
That's not correct. That is not what the model is. But explains a lot about CFWheels. - Secondly, it's still a bit of a fail of separation of concerns / IoC if the "calling code" hard-codes which implementation of a concern it is going to use.
- If one is writing testable code, one doesn't want that second-point tight-coupling going on. If I'm testing features of my model, I want to stub out the logger it's using, for example. This is awkward to do if the decision as to which logger implementation we're using is baked-into the code I'm testing.
Anyway, you probably get it. Dependency injection exists for a reason. And this is something CFWheels appears to have overlooked.
0.1 - Baseline container
I have worked through the various bits and pieces I'm going to discuss already, to make sure it all works. But as I write this I am starting out with a bare-bones Lucee container, and a bare-bones MariaDB container (from my lucee_and_mariadb repo. I've also gone ahead and installed TestBox, and my baseline tests are passing:
Yes. I have a test that TestBox is installed and running correctly.
We have a green light, so that's a baseline to start with. I've tagged that in GitHub as 0.1.
0.2 - CFWheels operational
OK, I'll install CFWheels. But first my requirement here is "CFWheels is working". I will take this to mean that it displays its homepage after install, so I can test for that pretty easily:
it("displays the welcome page", () => {
cfhttp(url="http://lucee:8888/index.cfm", result="httpResponse");
expect(httpResponse.statusCode).toBe(200)
expect(httpResponse.fileContent).toInclude("<title>CFWheels</title>")
})
I'm using TDD for even mundane stuff like this so I don't get ahead of myself, and miss bits I need to do to get things working.
This test fails as one would expect. Installing CFWheels is easy: just box install cfwheels. This installs everything in the public web root which is not great, but it's the way CFWheels works. I've written another series about how to get a CFWheels-driven web app working whilst also putting the code in a sensible place, summarised here: Short version: getting CFWheels working outside the context of a web-browsable directory, but life's too short to do all that horsing around today, so we'll just use the default install pattern. Note: I do not consider this approach to be appropriate for production, but it'll do for this demonstration.
After the CFWheels installation I do still have to fix-up a few things:
- It steamrolled my existing Application.cfc, so I had to merge the CFWheels bit with my bit again.
- Anything in CFWheels will only work properly if it's called in the context of a CFWheels app, so I need to tweak my test config slightly to accommodate that.
- And that CFWheels "context" only works if it's called from index.cfm. So I need to rename my test/runTests.cfm to be index.cfm.
Having done that:
A passing test is all good, but I also made sure the thing did work. By actually looking at it:
I've tagged that lot again, as 0.2.
0.3 - A working model
I want to mess around with models, so I need to create one. I have a stub DB configured with this app, and it has a table test with a coupla test rows in it. I'll create a CFWheels model that maps to that. CFWheels expects plural table names, but mine's singular so I need a config tweak there. I will test that I can retrieve test records from it.
it("can find test records from the DB", () => {
tests = model("Test").findAll(returnAs="object")
expect(tests).notToBeEmpty()
tests.each((test) => {
expect(test).toBeInstanceOf("models.Test")
expect(test.properties().keyArray().sort("text")).toBe(["id", "value"])
})
})
And the implementation to make it pass:
import wheels.Model
component extends=Model {
function config() {
table(name="test")
}
}
Good. Next I wanted to check when CFWheels calls my Test class's constructor. Given one needs to use that factory method (eg: model("Test").etc) to do anything relating to model objects / collections . etc, I was not sure whether the constructor comes into play. Why do i care? Because when using dependency injection, one generally passes the dependencies in as constructor arguments. This is not the only way of doing it, but it's the most obvious ("KISS" / "Principle of Least Astonishment") approach. So let's at least check.
it("has its constructor called when it is instantiated by CFWheels", () => {
test = model("Test").new()
expect(test.getFindMe()).toBe("FOUND")
})
Implementation:
public function init() {
variables.findMe = "FOUND"
}
public string function getFindMe() {
return variables.findMe
}
Result:
OK so scratch that idea. CFWheels does not call the model class's constructor. Initially I was annoyed about this as it seems bloody stupid. But then I recalled that when one is using a factory method to create objects, it's not unusual to not use the public constructor to do so. OK fair enough.
I asked around, and (sorry I forget who told me, or where they told me) found out that CFWheels does provide an event hook I can leverage for when an model object is created: model.afterInitialization. I already have my test set up to manage my expectations, so I can just change my implementation:
function config() {
table(name="test")
afterInitialization("setFindMe")
}
public function setFindMe() {
variables.findMe = "FOUND"
}
And that passed this time. Oh I changed the test label from "has its constructor called…" to be "has its afterInitialization handler called…". But the rest of the test stays the same. This is an example of how with TDD we are testing the desired outcome rather than the implementation. It doesn't matter whether the value is set by a constructor or by an event handler: it's the end result of being able to use the value that matters.
At the moment I have found my "way in" to each object as they are created. I reckon from here I can have a DependencyInjectionService that I can call upon from the afterInitialization handler so the model can get the dependencies it needs. This is not exactly "dependency injection", it's more "dependency self-medication", but it should work.
I'll tag here before I move on. 0.3
0.4 integrating DI/1
My DI requirements ATM are fairly minimal, but I am not going to reinvent the wheel. I'm gonna use DI/1 to handle the dependencies. I've had a look at it before, and it's straight forward enough, and is solid.
My tests are pretty basic to start with: I just want to know it's installed properly and operations:
it("can be instantiated", () => {
container = new framework.ioc("/services")
expect(container).toBeInstanceOf("framework.ioc")
})
And now to install it: box install fw1
And we have a passing test (NB: I'm not showing you the failures necessarily, but I do always actually not proceed with anything until I see the test failing):
It's not much use unless it loads up some stuff, so I'll test that it can:
it("loads services with dependencies", () => {
container = new framework.ioc("/services")
testService = container.getBean("TestService")
expect(testService.getDependency()).toBeInstanceOf("services.TestDependency")
})
I'm gonna show the failures this time. First up:
This is reasonable because the TestService class isn't there yet, so we'd expect DI/1 to complain. The good news is it's complaining in the way we'd want it to. TestService is simple:
component {
public function init(required TestDependency testDependency) {
variables.dependency = arguments.testDependency
}
public TestDependency function getDependency() {
return variables.dependency
}
}
Now the failure changes:
This is still a good sign: DI/1 is doing what it's supposed to. Well: trying to. And reporting back with exactly what's wrong. Let's put it (and, I imagine: you) out of its misery and give it the code it wants. TestDependency:
component {
}
And now DI/1 has wired everything together properly:
As well as creating a DI/1 instance and pointing it at a directory (well: actually I won't be doing that), I need to hand-crank some dependency creation as they are not just a matter of something DI/1 can autowire. So I'm gonna wrap-up all that in a service too, so the app can just use a DependencyInjectionService, and not need to know what its internal workings are.
To start with, I'll just make sure the wrapper can do the same thing we just did with the raw IoC object from the previous tests:
describe("Tests for DependencyInjectionService", () => {
it("loads the DI/1 IoC container and its configuration", () => {
diService = new DependencyInjectionService()
testService = diService.getBean("DependencyInjectionService")
expect(testService).toBeInstanceOf("services.DependencyInjectionService")
})
})
Instead of testing the TestService here, I decided to use DependencyInjectionService to test it can… load itself
There's a bit more code this time for the implementation, but not much.
import framework.ioc
component {
public function init() {
variables.container = new ioc("")
configureDependencies()
}
private function configureDependencies() {
variables.container.declareBean("DependencyInjectionService", "services.DependencyInjectionService")
}
public function onMissingMethod(required string missingMethodName, required struct missingMethodArguments) {
return variables.container[missingMethodName](argumentCollection=missingMethodArguments)
}
}
- It creates an IOC container object, but doesn't scan any directories for autowiring opportunities this time.
- It hand-cranks the loading of the DependencyInjectionService object.
- It also acts as a decorator for the underlying IOC instance, so calling code just calls getBean (for example) on a DependencyInjectionService instance, and this is passed straight on to the IOC object to do the work.
And we have a passing test:
Now we can call our DI service in our model, and the model can use it to configure its dependencies. First we need to configure the DependencyInjectionService with another bean:
it("loads TestDependency", () => {
diService = new DependencyInjectionService()
testDependency = diService.getBean("TestDependency")
expect(testDependency).toBeInstanceOf("services.TestDependency")
})
private function configureDependencies() {
variables.container.declareBean("DependencyInjectionService", "services.DependencyInjectionService")
variables.container.declareBean("TestDependency", "services.TestDependency")
}
describe("Tests for TestDependency", () => {
describe("Tests for getMessage method")
it("returns SET_BY_DEPENDENCY", () => {
testDependency = new TestDependency()
expect(testDependency.getMessage()).toBe("SET_BY_DEPENDENCY")
})
})
})
// TestDependency.cfc
component {
public string function getMessage() {
return "SET_BY_DEPENDENCY"
}
}
That's not quite the progression of the code there. I had to create TestDependency first, so I did its test and it first; then wired it into DependencyInjectionService.
Now we need to wire that into the model class. But first a test to show it's worked:
describe("Tests for Test model", () => {
describe("Tests of getMessage method", () => {
it("uses an injected dependency to provide a message", () => {
test = model("Test").new()
expect(test.getMessage()).toBe("SET_BY_DEPENDENCY")
})
})
})
Hopefully that speaks for itself: we're gonna get that getMessage method in Test to call the equivalent method from TestDependency. And to do that, we need to wire an instance of TestDependency into our instance of the Test model. I should have thought of better names for these classes, eh?
// /models/Test.cfc
import services.DependencyInjectionService
import wheels.Model
component extends=Model {
function config() {
table(name="test")
afterInitialization("setFindMe,loadIocContainer")
}
public function setFindMe() {
variables.findMe = "FOUND"
}
public string function getFindMe() {
return variables.findMe
}
private function loadIocContainer() {
variables.diService = new DependencyInjectionService()
setDependencies()
}
private function setDependencies() {
variables.dependency = variables.diService.getBean("TestDependency")
}
public function getMessage() {
return variables.dependency.getMessage()
}
}
That works…
…but it needs some adjustment.
Firstly I want the dependency injection stuff being done for all models, not just this one. So I'm going to shove some of that code up into the Model base class:
// /models/Model.cfc
/**
* This is the parent model file that all your models should extend.
* You can add functions to this file to make them available in all your models.
* Do not delete this file.
*/
import services.DependencyInjectionService
component extends=wheels.Model {
function config() {
afterInitialization("loadIocContainer")
}
private function loadIocContainer() {
variables.diService = new DependencyInjectionService()
setDependencies()
}
private function setDependencies() {
// OVERRIDE IN SUBCLASS
}
}
// models/Test.cfc
import wheels.Model
component extends=Model {
function config() {
super.config()
table(name="test")
afterInitialization("setFindMe")
}
// ...
private function setDependencies() {
variables.dependency = variables.diService.getBean("TestDependency")
}
// ...
}
Now the base model handles the loading of the DependencyInjectionService, and calls a setDependencies method. Its own method does nothing, but if a subclass has an override of it, then that will run instead.
I will quickly tag that lot before I continue. 0.4.
But…
0.5 Dealing with the hard-coded DependencyInjectionService initialisation
The second problem is way more significant. Model is creating and initialising that DependencyInjectionService object every time a model object is created. That's not great. All that stuff only needs to be done once for the life of the application. I need to do that bit onApplicationStart (or whatever approximation of that CFWheels supports), and then I need to somehow expose the resultant object in Model.cfc. A crap way of doing it would be to just stick it in application.dependencyInjectionService and have Model look for that. But that's a bit "global variable" for my liking. I wonder if CFWheels has an object cache that it intrinsically passes around the place, and exposes to its inner workings. I sound vague because I had pre-baked all the code up to where I am now a week or two ago, and it was not until I was writing this article I went "oh well that is shit, I can't be having that stuff in there". And I don't currently know the answer.
Let's take the red-green-refactor route, and at least get the initialisation out of Model, and into the application lifecycle.
…
…
…
Ugh. Looking through the CFWheels codebase is not for the faint-hearted. Unfortunately the "architecture" of CFWheels is such that it's about one million (give or take) individual functions, and no real sense of cohesion to anything other than a set of functions might be in the same .cfm (yes: .cfm file :-| ), which then gets arbitrarily included all over the place. If I dump out the variables scope of my Test model class, it has 291 functions. Sigh.
There's a bunch of functions possibly relating to caching, but there's no Cache class or CacheService or anything like that... there's just some functions that act upon a bunch of application-scoped variable that are not connected in any way other than having the word "cache" in them. I feel like I have fallen back through time to the days of CF4.5. Ah well.
I'll chance my arm creating my DependencyInjectionService object in my onApplicationStart handler, use the $addToCache function to maybe put it into a cache… and then pull it back out in Model. Please hold.
[about an hour passes. It was mostly swearing]
Okey doke, so first things first: obviously there's a new test:
describe("Tests for onApplicationStart", () => {
it("puts an instance of DependencyInjectionService into cache", () => {
diService = $getFromCache("diService")
expect(diService).toBeInstanceOf("services.DependencyInjectionService")
})
})
The implementation for this was annoying. I could not use the onApplicationStart handler in my own Application.cfc because CFWheels steamrolls it with its own one. Rather than using the CFML lifecycle event handlers the way they were intended, and also using inheritance when an application and an application framework might have their own work to do, CFWheels just makes you write its handler methods into your Application.cfc. This sounds ridiculous, but this is what CFWheels does in the application's own Application.cfc. I'm going to follow-up on this stupidity in a separate article, perhaps. But suffice it to say that instead of using my onApplicationStart method, I had to do it the CFWheels way. which is … wait for it… to put the code in events/onapplicationstart.cfm. Yuh. Another .cfm file. Oh well. Anyway, here it is:
<cfscript>
// Place code here that should be executed on the "onApplicationStart" event.
import services.DependencyInjectionService
setDependencyInjectionService()
private void function setDependencyInjectionService() {
diService = new DependencyInjectionService()
$addToCache("diService", diService)
}
</cfscript>
And then in models/Model.cfc I make this adjustment:
private function loadIocContainer() {
variables.diService = new DependencyInjectionService()
variables.diService = $getFromCache("diService")
setDependencies()
}
And then…
I consider that a qualified sucessful exercise in "implementing dependency injection in a CFWheels web site". I mean I shouldn't have to hand-crank stuff like this. This article should not need to be written. This is something that any framework still in use in 2022 should do out of the box. But… well… here we are. It's all a wee bit Heath Robinson, but I don't think it's so awful that it's something one oughtn't do.
And now I'm gonna push the code up to github (0.5), press "send" on this, and go pretend none of it ever happened.
Righto.
--
Adam