Code Kata: Simple Dependency Injection (DI) With ColdFusion
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.
The Power of Constraints
Since this Injector is for me, it doesn't have to be endless flexible. In fact, I'd rather err on the side of constraints so that I can simplify the code and force a bit of consistency in the way that I wire my components together.
The biggest constraint that I am putting in place is that there is no constructor injection. Meaning, this Injector will call any .init()
method that might exist; but, it won't pass-in any arguments. If I have a component that "needs" constructor arguments, I'm either going to refactor it ever-so-slightly to use CFProperty
tags instead; or, I might construct it outside of the injector (manually) and then "provide" it to the injector for caching.
That said, the injector will call a special initializer method called .$init()
after all of the dependencies have been injected. This will provide a post-injection hook that I sometimes need for further data massaging.
The other big constraint that I've made is that all injector values are cached as single-instances for the life-span of the application. There's no sense of transient objects in my injector.
Defining Injector Dependencies
There are two ways to define dependencies. First, we can explicitly provide a dependency definition to the injector using the .provide()
method:
.provide( token, value )
You can think of the dependency injection container as a simple key-value "cache". And, in this case, the token
is just the key at which the given value
will be cached internally. The value
can be anything you want.
For example, I have a configuration object with environment variables that I like to provide to the injector in the onApplicationStart()
method:
.provide( "config", loadEnvironment() )
The other way to define dependencies to implicitly treat the "token" as a ColdFusion component path. For example, if I were to ask the injector for the value at the given token:
.get( "lib.Logger" )
... the injector would turn around call:
createObject( "component", "lib.Logger" )
This is, of course, assuming that the injector doesn't already have a value cached at the key, lib.Logger
. If there were a value cached, the injector would simply return the cached value - as I said above, one of the constraints in my injector is that all values are cached, single-instances.
Annotating ColdFusion Components
With that said, asking the injector for a token isn't that exciting; the injector starts to add real value when we have it automatically wire-together components using annotations. In my injector, all of the annotations live on the CFProperty
tag.
Though, ironically, the most basic form of the CFProperty
tag has no explicit annotation. If no annotations exist, the name of the property is consumed as the token annotation. As such, the following two properties are semantically the same (from the injector's perspective):
property name="config";
property name="config" ioc:token="config";
When the injector sees one of these CFProperty
tags, it will look for a value cached at the key, config
. And, if it has one, it will "inject" it into the given component as variables.config
.
ASIDE: If the given component has a setter method defined for the given property, such as
.setConfig()
, the injector will invoke it. However, setters are not required for my injector - it will happily tunnel into the component in order to wire-in the dependencies.
While the ioc:token
annotation can be excluded, including it allows the dependency to be renamed during injection. Meaning, if for some reason we wanted to refer the config
object as myConfig
internally, we could use the following CFProperty
tag:
property name="myConfig" ioc:token="config";
In this case, the injector would locate the cached value identified by the token, config
, and then inject it into the given component as variables.myConfig
.
My config
struct tends to be a complex object that covers configuration for a number of areas within the ColdFusion application. For example, I might have an smtp
sub-struct and a datasouce
sub-struct and a bugsnag
sub-struct. Instead of always injecting the whole config
object into every ColdFusion component, I have an annotation - ioc:get
- that allows me to extract sub-values as dependencies.
For example, in 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";
Wiring ColdFusion Components Together
Now that have a high-level sense of how dependencies are defined and how components are annotated, let's follow the low-level control flow that actually makes all the Inversion of Control (IoC) magic happen. To start with, let's act the injector for a token:
ioc.get( "lib.Logger" )
What we want here is the ColdFusion component that has been cached at the key lib.Logger
. And, here's what the injector does to make this happen:
component {
public any function get( required string serviceToken ) {
// NOTE: "variables.services" is just a private struct (cache) of values.
if ( services.keyExists( serviceToken ) ) {
return( services[ serviceToken ] );
}
lock
name = "Injector.ServiceCreation"
type = "exclusive"
timeout = 60
{
return( services[ serviceToken ] ?: buildService( serviceToken ) );
}
}
}
First, the injector checks to see if there is a value cached at the given token. If so, it just returns it - there are no transient values in my injector. If no value has been cached, we enter 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:
- Construct the component.
- Gather the dependencies.
- 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:
- Gather (and normalize) the
CFProperty
entries. - 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!
The Full Code
For completeness, here's the full code for my Injector.cfc
. It has some functionality that I haven't outlined above, such as a maybeGet()
method, which returns 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.
Reader Comments
I ran into an Injector-related bug this morning in Adobe ColdFusion (2021 and 2023). If you use the
?.
safe navigation operator to invoke a method, any errors emitted in that method will be completely swallowed by ColdFusion:www.bennadel.com/blog/4721-safe-navigation-operator-swallows-method-errors-in-adobe-coldfusion-2023.htm
As such, I'll have to go back and—at least for now—wrap the
?.init()
calls, and the like, withstructKeyExists()
conditionals and then invoke the relevant methods with the traditional.
operator.Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →