Simple Dependency Injection (DI) With ColdFusion

Ben Nadel
Published: May 29, 2023

When this blog boots-up, I explicitly wire-together all of the ColdFusion components that get cached in memory. The domain model for this blog isn’t very big, so configuring the application right in the onApplicationStart() event-handler isn’t much of a lift. That said, as a fun code kata – as much as anything else – I wanted to start migrating the configuration over to use more declarative dependencies. To that end, I wanted to build myself a simple dependency injection (DI) container.

I actually talked about this concept 14-years ago. And, the approach that I’m taking in this post is more-or-less the same; only with more modern syntax and a bit more flexibility.

my BugSnag logger, I can inject just the portions of the config object that pertain to BugSnag:

property name="bugsnag" ioc:get="config.bugsnag";

Under the hood, the injector is translating this annotation into a structGet() call on its internal cache. As such, the “object path” in the ioc:get annotation can be arbitrarily deep.

The final annotation is the one that I’ll be using most often – this is the real value-add. The ioc:type annotation will instantiate, cache, and inject other ColdFusion components into the given component. The value of the ioc:type attribute is assumed to be a component path. When a ColdFusion component is instantiated, it is cached using the component path as the cache key.

For example, if my CommentWorkflow.cfc use-case needs the BugSnag logger, it might have the following CFProperty tag:

property name="logger" ioc:type="lib.logger.BugSnagLogger";

a double-check lock in order to make sure that the service hasn’t just been created by a concurrent request. If not, the injector calls buildService() to populate the cache:

component {
	private any function buildService( required string serviceToken ) {
		// CAUTION: I'm caching the "uninitialized" component instance in the services
		// collection so that we avoid potentially hanging on a circular dependency. This
		// way, each service can be injected into another service before it is ready. This
		// might leave the application in an unpredictable state; but, only if people are
		// foolish enough to have circular dependencies and swallow errors during the app
		// bootstrapping.
		var service = services[ serviceToken ] = buildComponent( serviceToken );
		// CAUTION: The buildInjectables() method may turn around and call the
		// buildService() recursively in order to create the dependency graph.
		var injectables = buildInjectables( serviceToken, service );
		return( setupComponent( service, injectables ) );
	}
}

As you can see here, building a service is a three step process:

  1. Construct the component.
  2. Gather the dependencies.
  3. Inject the dependencies into the component.

One concession that I’ve made here – in terms of simplicity – is that I cache the “constructed” component in the services cache immediately before I even wire-in the dependencies. If I didn’t do this, two components that refer to each other would end-up creating an infinite cycle of component creation. I don’t like the idea of caching a service before it is “ready”; but, I’ve rationalized this incomplete state by the fact that it’s all being done in a globally-exclusive lock.

ASIDE: I strongly believe that “circular dependencies” are a code-smell and should be avoided. I rare ever see a circular dependency that doesn’t smack of a missing architectural facet.

Let’s take a closer look at the the steps to service building. First, constructing the ColdFusion component:

component {
	private any function buildComponent( required string serviceToken ) {
		try {
			var componentPath = ( typeMappings[ serviceToken ] ?: serviceToken );
			var service = createObject( "component", componentPath );
			// CAUTION: The native init() function is called BEFORE any of the component's
			// dependencies are injected. There is a special "$init()" method that can be
			// used to provide a post-injection setup hook. The "$init()" method SHOULD be
			// preferred for a component that is wired-up via dependency-injection.
			service?.init();
			return( service );
		} catch ( any error ) {
			throw(
				type = "BenNadel.Injector.CreationError",
				message = "Injector could not create component.",
				detail = "Component [#serviceToken#] could not be created via createObject(#componentPath#).",
				extendedInfo = serializeErrorForNesting( error )
			);
		}
	}
}

As I mentioned before, the main gesture of the injector is to treat the DI token as a component path. However, in my case, I’m not just passing the token into the createObject() function. First, I check to see if there is a mapping provided for the given token. Mappings allow me to “alias” an abstract concept so that I don’t have to tightly couple the entire application to a concrete implementation.

This makes more sense for swappable behaviors such as Logging. The implementation details for my logger might change over time; so, instead of putting the concrete object path for my Logger in every component, I might just do:

property name="logger" ioc:type="Logger";

And then, map that to a concrete type in my application boot-strapping:

ioc.provideMapping( "Logger", "lib.logger.BugSnagLogger" );

In general, I try to avoid “magic” as much as possible. So, I’ll limit my use of mapping as much as possible.

ASIDE: Another way to do this would be to have an actual component for logging that can be used as a dependency. And then, have the dynamic behaviors injected into the logger itself.

Once the ColdFusion component is constructed, step 2 extracts the dependencies using the component’s metadata. Step 2 is the most complicated part of this whole process and can end up calling buildService() recursively as it gathers up the dependency graph.

Step 2 is composed of two sub-steps that I’ve broken out into different methods for easier maintenance:

  1. Gather (and normalize) the CFProperty entries.
  2. Translate each property into a dependency.

I’ve listed the methods here in the order in which they are invoked.

component {
	private struct function buildInjectables(
		required string serviceToken,
		required any service
		) {
		var properties = buildProperties( serviceToken, service );
		var injectables = [:];
		for ( var entry in properties ) {
			injectables[ entry.name ] = buildInjectable( serviceToken, entry );
		}
		return( injectables );
	}

	private array function buildProperties(
		required string serviceToken,
		required any service
		) {
		try {
			var metadata = getMetaData( service );
		} catch ( any error ) {
			throw(
				type = "BenNadel.Injector.MetadataError",
				message = "Injector could not inspect metadata.",
				detail = "Component [#serviceToken#] could not be inspected for metadata.",
				extendedInfo = serializeErrorForNesting( error )
			);
		}
		var iocProperties = metadata
			.properties
			.filter(
				( entry ) => {
					return( entry.name.len() );
				}
			)
			.map(
				( entry ) => {
					var name = entry.name;
					var type = ( entry[ "ioc:type" ] ?: entry.type ?: "" );
					var token = ( entry[ "ioc:token" ] ?: "" );
					var get = ( entry[ "ioc:get" ] ?: "" );
					// Depending on how the CFProperty tag is defined, the native type is
					// sometimes the empty string and sometimes "any". Let's normalize
					// this to be the empty string.
					if ( type == "any" ) {
						type = "";
					}
					return([
						name: name,
						type: type,
						token: token,
						get: get
					]);
				}
			)
		;
		return( iocProperties );
	}

	private any function buildInjectable(
		required string serviceToken,
		required struct entry
		) {
		// If we have a TYPE defined, the service look-up is unambiguous.
		if ( entry.type.len() ) {
			return( services[ entry.type ] ?: buildService( entry.type ) );
		}
		// If we have a TOKEN defined, the look-up is unambiguous (but cannot have any
		// auto-provisioning applied to it).
		if ( entry.token.len() ) {
			if ( services.keyExists( entry.token ) ) {
				return( services[ entry.token ] );
			}
			throw(
				type = "BenNadel.Injector.MissingDependency",
				message = "Injector could not find injectable by token.",
				detail = "Component [#serviceToken#] has a property named [#entry.name#] with [ioc:token][#entry.token#], which is not cached in the injector. You must explicitly provide the service to the injector during the application bootstrapping process."
			);
		}
		// If we have a GET defined, the service look-up is an unambiguous property-chain
		// lookup within the injector cache.
		if ( entry.get.len() ) {
			var value = structGet( "variables.services.#entry.get#" );
			// CAUTION: Unfortunately, the StructGet() function basically "never fails".
			// If you try to access a value that doesn't exist, ColdFusion will auto-
			// generate the path to the value, store an empty Struct in the path, and then
			// return the empty struct. We do not want this to fail quietly. As such, if
			// the found value is an empty struct, we are going to assume that this was an
			// error and throw.
			if ( isStruct( value ) && value.isEmpty() ) {
				throw(
					type = "BenNadel.Injector.EmptyGet",
					message = "Injector found an empty struct at get-path.",
					detail = "Component [#serviceToken#] has a property named [#entry.name#] with [ioc:get][#entry.get#], which resulted in an empty struct look-up. This is likely an error in the object path."
				);
			}
			return( value );
		}
		// If we have NO IOC METADATA defined, we will assume that the NAME is tantamount
		// to the TYPE. However, this will only be considered valid if there is already a
		// service cached under the given name - we can't assume that the name matches a
		// valid ColdFusion component.
		if ( services.keyExists( entry.name ) ) {
			return( services[ entry.name ] );
		}
		throw(
			type = "BenNadel.Injector.MissingDependency",
			message = "Injector could not find injectable by name.",
			detail = "Component [#serviceToken#] has a property named [#entry.name#] with no IoC metadata and which is not cached in the injector. Try adding an [ioc:type] or [ioc:token] attribute; or, explicitly provide the value (with the given name) to the injector during the application bootstrapping process."
		);
	}
}

Once we have our constructed component and we’ve gather all of the dependencies as defined in the ioc: annotations, our last step is to wire them all together. The injector will try to use any setter methods that exist. But, if there are left-over dependencies after the setter methods have been exercised, the injector will just tunnel into the ColdFusion component and append them to the variables scope.

component {
	private any function setupComponent(
		required any service,
		required struct injectables
		) {
		// When it comes to injecting dependencies, we're going to try two different
		// approaches. First, we'll try to use any setter / accessor methods available for
		// a given property. And second, we'll tunnel-in and deploy any injectables that
		// weren't deployed via the setters.
		// Step 1: Look for setter / accessor methods.
		for ( var key in injectables ) {
			var setterMethodName = "set#key#";
			if (
				structKeyExists( service, setterMethodName ) &&
				isCustomFunction( service[ setterMethodName ] )
				) {
				invoke( service, setterMethodName, [ injectables[ key ] ] );
				// Remove from the collection so that we don't double-deploy this
				// dependency through the dynamic tunneling in step-2.
				injectables.delete( key );
			}
		}
		// Step 2: If we have any injectables left to deploy, we're going to apply a
		// temporary injection tunnel on the target ColdFusion component and just append
		// all the injectables to the variables scope.
		if ( structCount( injectables ) ) {
			try {
				service.$$injectValues$$ = $$injectValues$$;
				service.$$injectValues$$( injectables );
			} finally {
				structDelete( service, "$$injectValues$$" );
			}
		}
		// Since the native init() method is invoked prior to the injection of its
		// dependencies, see if there is an "$init()" hook to allow for post-injection
		// setup / initialization of the service component.
		service?.$init();
		return( service );
	}
}

That crazy looking $$injectValues$$ method is private method defined within the injector itself. It will take the method reference, attache it to the given ColdFusion component, and then invoke it in the page context of the given component:

component {
	private void function $$injectValues$$( required struct values ) {
		// CAUTION: In this moment, the "variables" scope here is the private scope of an
		// arbitrary component - it is NOT the Injector's private scope.
		structAppend( variables, values );
	}
}

Don’t you just gush over how dynamic ColdFusion is!?

At this point, the given service has been constructed and all of its dependencies have been injected into the variables scope, either through setter-injection or by tunneling. The constructed service gets cached internally to the injector and any subsequent request for the same token will return the already-cached value.

Dependency-injection containers can get pretty complicated. I’m sure someone will come along and tell me that DI/1 or WireBox can do much more than what I’ve outlined here. And, that’s true. But, those libraries have to be everything to everyone; and, I just need my injector to work for me. As such, I like the constraints and limitations that I’ve put in place.

If nothing else, it’s just great to see that relatively little ColdFusion code is required to implement was is actually a rather sophisticated concept. Now, I just need to update all my components to use ioc annotations!

a “Maybe Result” for a given service.

component
	output = false
	hint = "I provide an Inversion of Control (IoC) container."
	{
	/**
	* I initialize the IoC container with no services.
	*/
	public void function init() {
		variables.services = [:];
		variables.typeMappings = [:];
	}
	// ---
	// PUBLIC METHODS.
	// ---
	/**
	* I get the service identified by the given token. If the service has not yet been
	* provided, it will be instantiated (as a ColdFusion component) and cached before
	* being returned.
	*/
	public any function get( required string serviceToken ) {
		if ( services.keyExists( serviceToken ) ) {
			return( services[ serviceToken ] );
		}
		lock
			name = "Injector.ServiceCreation"
			type = "exclusive"
			timeout = 60
			{
			return( services[ serviceToken ] ?: buildService( serviceToken ) );
		}
	}

	/**
	* I get all of the currently-cached services.
	*/
	public struct function getAll() {
		return( services.copy() );
	}

	/**
	* I return a MAYBE result for the service identified by the given token. If the
	* service has not yet been provided, the service does not get auto-instantiated.
	*/
	public struct function maybeGet( required string serviceToken ) {
		if ( services.keyExists( serviceToken ) ) {
			return([
				exists: true,
				value: services[ serviceToken ]
			]);
		} else {
			return([
				exists: false
			]);
		}
	}

	/**
	* I provide the given service to be associated with the given token identifier. The
	* provided service will be returned so that it might be used in a local variable
	* assignment in the calling context.
	*/
	public any function provide(
		required string serviceToken,
		required any serviceValue
		) {
		services[ serviceToken ] = serviceValue;
		return( serviceValue );
	}

	/**
	* I provide a mapping from the given service token to the given concrete service
	* token. This will only be used when instantiating new components.
	*/
	public any function provideTypeMapping(
		required string serviceToken,
		required string concreteServiceToken
		) {
		typeMappings[ serviceToken ] = concreteServiceToken;
	}
	// ---
	// PRIVATE METHODS.
	// ---
	/**
	* I provide a temporary tunnel into any ColdFusion component that allows the given
	* payload to be appended to the internal, private scope of the component.
	*/
	private void function $$injectValues$$( required struct values ) {
		// CAUTION: In this moment, the "variables" scope here is the private scope of an
		// arbitrary component - it is NOT the Injector's private scope.
		structAppend( variables, values );
	}

	/**
	* I build the ColdFusion component using the given token. Since this is part of an
	* auto-provisioning workflow, the token here is assumed to be the path to a ColdFusion
	* component.
	*/
	private any function buildComponent( required string serviceToken ) {
		try {
			var componentPath = ( typeMappings[ serviceToken ] ?: serviceToken );
			var service = createObject( "component", componentPath );
			// CAUTION: The native init() function is called BEFORE any of the component's
			// dependencies are injected. There is a special "$init()" method that can be
			// used to provide a post-injection setup hook. The "$init()" method SHOULD be
			// preferred for a component that is wired-up via dependency-injection.
			service?.init();
			return( service );
		} catch ( any error ) {
			throw(
				type = "BenNadel.Injector.CreationError",
				message = "Injector could not create component.",
				detail = "Component [#serviceToken#] could not be created via createObject(#componentPath#).",
				extendedInfo = serializeErrorForNesting( error )
			);
		}
	}

	/**
	* I build the injectable service from the given CFProperty entry.
	*/
	private any function buildInjectable(
		required string serviceToken,
		required struct entry
		) {
		// If we have a TYPE defined, the service look-up is unambiguous.
		if ( entry.type.len() ) {
			return( services[ entry.type ] ?: buildService( entry.type ) );
		}
		// If we have a TOKEN defined, the look-up is unambiguous (but cannot have any
		// auto-provisioning applied to it).
		if ( entry.token.len() ) {
			if ( services.keyExists( entry.token ) ) {
				return( services[ entry.token ] );
			}
			throw(
				type = "BenNadel.Injector.MissingDependency",
				message = "Injector could not find injectable by token.",
				detail = "Component [#serviceToken#] has a property named [#entry.name#] with [ioc:token][#entry.token#], which is not cached in the injector. You must explicitly provide the service to the injector during the application bootstrapping process."
			);
		}
		// If we have a GET defined, the service look-up is an unambiguous property-chain
		// lookup within the injector cache.
		if ( entry.get.len() ) {
			var value = structGet( "variables.services.#entry.get#" );
			// CAUTION: Unfortunately, the StructGet() function basically "never fails".
			// If you try to access a value that doesn't exist, ColdFusion will auto-
			// generate the path to the value, store an empty Struct in the path, and then
			// return the empty struct. We do not want this to fail quietly. As such, if
			// the found value is an empty struct, we are going to assume that this was an
			// error and throw.
			if ( isStruct( value ) && value.isEmpty() ) {
				throw(
					type = "BenNadel.Injector.EmptyGet",
					message = "Injector found an empty struct at get-path.",
					detail = "Component [#serviceToken#] has a property named [#entry.name#] with [ioc:get][#entry.get#], which resulted in an empty struct look-up. This is likely an error in the object path."
				);
			}
			return( value );
		}
		// If we have NO IOC METADATA defined, we will assume that the NAME is tantamount
		// to the TYPE. However, this will only be considered valid if there is already a
		// service cached under the given name - we can't assume that the name matches a
		// valid ColdFusion component.
		if ( services.keyExists( entry.name ) ) {
			return( services[ entry.name ] );
		}
		throw(
			type = "BenNadel.Injector.MissingDependency",
			message = "Injector could not find injectable by name.",
			detail = "Component [#serviceToken#] has a property named [#entry.name#] with no IoC metadata and which is not cached in the injector. Try adding an [ioc:type] or [ioc:token] attribute; or, explicitly provide the value (with the given name) to the injector during the application bootstrapping process."
		);
	}

	/**
	* I inspect the given ColdFusion component for CFProperty tags and then use those tags
	* to collect the dependencies for subsequent injection.
	*/
	private struct function buildInjectables(
		required string serviceToken,
		required any service
		) {
		var properties = buildProperties( serviceToken, service );
		var injectables = [:];
		for ( var entry in properties ) {
			injectables[ entry.name ] = buildInjectable( serviceToken, entry );
		}
		return( injectables );
	}

	/**
	* I inspect the metadata of the given service and provide a standardized properties
	* array relating to dependency injection.
	*/
	private array function buildProperties(
		required string serviceToken,
		required any service
		) {
		try {
			var metadata = getMetaData( service );
		} catch ( any error ) {
			throw(
				type = "BenNadel.Injector.MetadataError",
				message = "Injector could not inspect metadata.",
				detail = "Component [#serviceToken#] could not be inspected for metadata.",
				extendedInfo = serializeErrorForNesting( error )
			);
		}
		var iocProperties = metadata
			.properties
			.filter(
				( entry ) => {
					return( entry.name.len() );
				}
			)
			.map(
				( entry ) => {
					var name = entry.name;
					var type = ( entry[ "ioc:type" ] ?: entry.type ?: "" );
					var token = ( entry[ "ioc:token" ] ?: "" );
					var get = ( entry[ "ioc:get" ] ?: "" );
					// Depending on how the CFProperty tag is defined, the native type is
					// sometimes the empty string and sometimes "any". Let's normalize
					// this to be the empty string.
					if ( type == "any" ) {
						type = "";
					}
					return([
						name: name,
						type: type,
						token: token,
						get: get
					]);
				}
			)
		;
		return( iocProperties );
	}

	/**
	* I build the service to be identified by the given token. Since this is part of an
	* auto-provisioning workflow, the token is assumed to be the path to a ColdFusion
	* component.
	*/
	private any function buildService( required string serviceToken ) {
		// CAUTION: I'm caching the "uninitialized" component instance in the services
		// collection so that we avoid potentially hanging on a circular dependency. This
		// way, each service can be injected into another service before it is ready. This
		// might leave the application in an unpredictable state; but, only if people are
		// foolish enough to have circular dependencies and swallow errors during the app
		// bootstrapping.
		var service = services[ serviceToken ] = buildComponent( serviceToken );
		// CAUTION: The buildInjectables() method may turn around and call the
		// buildService() recursively in order to create the dependency graph.
		var injectables = buildInjectables( serviceToken, service );
		return( setupComponent( service, injectables ) );
	}

	/**
	* I serialize the given error for use in the [extendedInfo] property of another error
	* object. This will help strike a balance between usefulness and noise in the errors
	* thrown by the injector.
	*/
	private string function serializeErrorForNesting( required any error ) {
		var simplifiedTagContext = error.tagContext
			.filter(
				( entry ) => {
					return( entry.template.reFindNoCase( "\.(cfc|cfml?)$" ) );
				}
			)
			.map(
				( entry ) => {
					return([
						template: entry.template,
						line: entry.line
					]);
				}
			)
		;
		return(
			serializeJson([
				type: error.type,
				message: error.message,
				detail: error.detail,
				extendedInfo: error.extendedInfo,
				tagContext: simplifiedTagContext
			])
		);
	}

	/**
	* I wire the given injectables into the given service, initialize it, and return it.
	*/
	private any function setupComponent(
		required any service,
		required struct injectables
		) {
		// When it comes to injecting dependencies, we're going to try two different
		// approaches. First, we'll try to use any setter / accessor methods available for
		// a given property. And second, we'll tunnel-in and deploy any injectables that
		// weren't deployed via the setters.
		// Step 1: Look for setter / accessor methods.
		for ( var key in injectables ) {
			var setterMethodName = "set#key#";
			if (
				structKeyExists( service, setterMethodName ) &&
				isCustomFunction( service[ setterMethodName ] )
				) {
				invoke( service, setterMethodName, [ injectables[ key ] ] );
				// Remove from the collection so that we don't double-deploy this
				// dependency through the dynamic tunneling in step-2.
				injectables.delete( key );
			}
		}
		// Step 2: If we have any injectables left to deploy, we're going to apply a
		// temporary injection tunnel on the target ColdFusion component and just append
		// all the injectables to the variables scope.
		if ( structCount( injectables ) ) {
			try {
				service.$$injectValues$$ = $$injectValues$$;
				service.$$injectValues$$( injectables );
			} finally {
				structDelete( service, "$$injectValues$$" );
			}
		}
		// Since the native init() method is invoked prior to the injection of its
		// dependencies, see if there is an "$init()" hook to allow for post-injection
		// setup / initialization of the service component.
		service?.$init();
		return( service );
	}
}

CFML for the Win!

Want to use code from this post?
Check out the license.

Source: www.bennadel.com