My ColdFusion "Controller" Layer Is Just A Bunch Of Switch Statements And CFIncludes
The more experience I get, the more I appreciate using an appropriate amount of complexity when solving a problem. This is a big part of why I love ColdFusion so much: it allows one to easily scale-up in complexity if and when the requirements grow to warrant it. When I'm working on my own, I don't need a robust framework with all the bells-and-whistles. All I need is a simple dependency-injection strategy and a series of CFSwtich
and CFInclude
statements.
I wanted to write this post, in part, in response to a conversation that I saw taking place in our Working Code Podcast Discord chat. I wasn't following the chat closely; but, I detected some light jabbing at the CFInclude
tag in ColdFusion. I sensed (?perhaps incorrectly?) that it was being denigrated as a beginner's construct - not to be used by serious developers.
Nothing could be farther from the truth. As a ColdFusion developer with nearly 25-years of CFML experience, I can attest that I use - and get much value from - the CFInclude
tag every day.
To be clear, I am not advocating against frameworks. Frameworks can be wonderful, especially when you're working with larger teams. But, simpler contexts beg for simpler solutions.
And, when I started building Dig Deep Fitness, my ColdFusion fitness tracker, I wanted to build the simplest possible thing first. As Kent Beck (and others) have said: Make it work, then make it right, then make it fast.
In Dig Deep Fitness, the routing control flow is dictated by an event
value that is provided with the request. The event
is just a simple, dot-delimited list in which each list item maps to one of the nested switch
statements. The onRequestStart()
event-handler in my Application.cfc
parameterizes this value to setup the request processing:
component
output = false
hint = "I define the application settings and event handlers."
{
// Define the application settings.
this.name = "DigDeepFitnessApp";
this.applicationTimeout = createTimeSpan( 1, 0, 0, 0 );
this.sessionManagement = false;
this.setClientCookies = false;
// ... truncated code ....
/**
* I get called once to initialize the request.
*/
public void function onRequestStart() {
// Create a unified container for all of the data submitted by the user. This will
// make it easier to access data when a workflow might deliver the data initially
// in the URL scope and then subsequently in the FORM scope.
request.context = structNew()
.append( url )
.append( form )
;
// Param the action variable. This will be a dot-delimited action string of what
// to process.
param name="request.context.event" type="string" default="";
request.event = request.context.event.listToArray( "." );
request.ioc = application.ioc;
}
}
As you can see, the request.context.event
string is parsed into a request.event
array. The values within this array are then read, validated, and consumed as the top-down request processing takes place, passing through a series of nested CFSwitch
and CFInclude
tags.
The root index.cfm
of my ColdFusion application sets up this first switch
statement. It also handles the initialization and subsequent rendering of the layout. As such, its switch
statement is a bit more robust than any of the nested switch
statements.
Ultimately, the goal of each control-flow layer is to aggregate all of the data needed for the designated layout template. Some data - like statusCode
and statusText
- is shared universally across all layouts. Other data-points are layout-specific. I initialize all of the universal template properties in this root index.cfm
file.
<cfscript>
config = request.ioc.get( "config" );
errorService = request.ioc.get( "lib.ErrorService" );
logger = request.ioc.get( "lib.logger.Logger" );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
request.template = {
type: "internal",
statusCode: 200,
statusText: "OK",
title: "Dig Deep Fitness",
assetVersion: "2023.07.22.09.54", // Making the input borders more intense.
bugsnagApiKey: config.bugsnag.client.apiKey
};
try {
param name="request.event[ 1 ]" type="string" default="home";
switch ( request.event[ 1 ] ) {
case "auth":
include "./views/auth/index.cfm";
break;
case "exercises":
include "./views/exercises/index.cfm";
break;
case "home":
include "./views/home/index.cfm";
break;
case "jointBalance":
include "./views/joint_balance/index.cfm";
break;
case "journal":
include "./views/journal/index.cfm";
break;
case "security":
include "./views/security/index.cfm";
break;
case "system":
include "./views/system/index.cfm";
break;
case "workout":
include "./views/workout/index.cfm";
break;
case "workoutStreak":
include "./views/workout_streak/index.cfm";
break;
default:
throw(
type = "App.Routing.InvalidEvent",
message = "Unknown routing event: root."
);
break;
}
// Now that we have executed the page, let's include the appropriate rendering
// template.
switch ( request.template.type ) {
case "auth":
include "./layouts/auth.cfm";
break;
case "blank":
include "./layouts/blank.cfm";
break;
case "internal":
include "./layouts/internal.cfm";
break;
case "system":
include "./layouts/system.cfm";
break;
}
// NOTE: Since this try/catch is happening in the index file, we know that the
// application has, at the very least, successfully bootstrapped and that we have
// access to all the application-scoped services.
} catch ( any error ) {
logger.logException( error );
errorResponse = errorService.getResponse( error );
request.template.type = "error";
request.template.statusCode = errorResponse.statusCode;
request.template.statusText = errorResponse.statusText;
request.template.title = errorResponse.title;
request.template.message = errorResponse.message;
include "./layouts/error.cfm";
if ( ! config.isLive ) {
writeDump( error );
abort;
}
}
</cfscript>
While the root index.cfm
is more robust than any of the others, it sets-up the pattern for the rest. You will see that every single control-flow file has the same basic ingredients. First, it parameterizes the next relevant request.event
index:
<cfscript>
// In the root controller, we care about the FIRST index.
param name="request.event[ 1 ]" type="string" default="home";
</cfscript>
Then, once the request.event
has been defaulted, we figure out which controller to include using a simple switch
statement on the parameterized event value:
<cfscript>
switch ( request.event[ 1 ] ) {
case "auth":
include "./views/auth/index.cfm";
break;
case "exercises":
include "./views/exercises/index.cfm";
break;
// ... truncated code ...
case "workoutStreak":
include "./views/workout_streak/index.cfm";
break;
default:
throw(
type = "App.Routing.InvalidEvent",
message = "Unknown routing event: root."
);
break;
}
</cfscript>
Notice that each of the case
statements just turns around and CFInclude
's a nested controller's index.cfm
file. All of the nested index.cfm
files look similar, albeit much less complex. Let's take, as an example, the auth
controller:
<cfscript>
// Every page in the auth subsystem will use the auth template. This is exclusively a
// non-logged-in part of the application and will have a simplified UI.
request.template.type = "auth";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
param name="request.event[ 2 ]" type="string" default="requestLogin";
switch ( request.event[ 2 ] ) {
case "loginRequested":
include "./login_requested.cfm";
break;
case "logout":
include "./logout.cfm";
break;
case "requestLogin":
include "./request_login.cfm";
break;
case "verifyLogin":
include "./verify_login.cfm";
break;
default:
throw(
type = "App.Routing.Auth.InvalidEvent",
message = "Unknown routing event: auth."
);
break;
}
</cfscript>
As you can see, this controller looks very similar to the root controller. Only, instead of parameterizing and processing request.event[1]
, it uses request.event[2]
- the next index item in the event-list. Notice, also, that this controller overrides the request.template.type
value. This will cause the root controller to render a different layout template.
This "auth" controller doesn't need to turn around and route to any nested controllers; although, it certainly could - when you have simple switch
and include
statements, it's just controllers all the way down. Instead, this "auth" controller needs to start executing some actions. As such, its case
statements include local action files.
Each action file processes an action and then includes a view rendering. Some action files are very simple; and, some action files are a bit more complex. Let's look at the "request login" action file in this "auth" controller.
The goal of this action file is to accept an email address from the user and send out a one-time, passwordless magic link email. Remember, this controller / routing layer is just the delivery mechanism. It's not supposed to do any heavy lifting - all "business logic" needs to be deferred to the "application core". In this case, it means handing off the request to the AuthWorkflow.cfc
when the form is submitted:
<cfscript>
authWorkflow = request.ioc.get( "lib.workflow.AuthWorkflow" );
errorService = request.ioc.get( "lib.ErrorService" );
oneTimeTokenService = request.ioc.get( "lib.OneTimeTokenService" );
logger = request.ioc.get( "lib.logger.Logger" );
requestHelper = request.ioc.get( "lib.RequestHelper" );
requestMetadata = request.ioc.get( "lib.RequestMetadata" );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
request.user = authWorkflow.getRequestUser();
// If the user is already logged-in, redirect them to the app.
if ( request.user.id ) {
location( url = "/", addToken = false );
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
param name="form.submitted" type="boolean" default=false;
param name="form.formToken" type="string" default="";
param name="form.email" type="string" default="";
errorMessage = "";
if ( form.submitted && form.email.trim().len() ) {
try {
oneTimeTokenService.testToken( form.formToken, requestMetadata.getIpAddress() );
authWorkflow.requestLogin( form.email.trim() );
location(
url = "/index.cfm?event=auth.loginRequested",
addToken = false
);
} catch ( any error ) {
errorMessage = requestHelper.processError( error );
// Special overrides to create a better affordance for the user.
switch ( error.type ) {
case "App.Model.User.Email.Empty":
case "App.Model.User.Email.InvalidFormat":
case "App.Model.User.Email.SuspiciousEncoding":
case "App.Model.User.Email.TooLong":
errorMessage = "Please enter a valid email address.";
break;
case "App.OneTimeToken.Invalid":
errorMessage = "Your login form has expired. Please try submitting your request again.";
break;
}
}
}
request.template.title = "Request Login / Sign-Up";
formToken = oneTimeTokenService.createToken( 5, requestMetadata.getIpAddress() );
include "./request_login.view.cfm";
</cfscript>
Because of the special error-handling in this template (which is me wanting to override the error message under certain outcomes), this action file is a bit more complex than the average action file. But, the bones are all the same: it parameterizes the inputs, it processes a form submission, and then it CFInclude
's the view file, request_login.view.cfm
:
<cfsavecontent variable="request.template.primaryContent">
<cfoutput>
<h1>
Dig Deep Fitness
</h1>
<p>
Welcome to my fitness tracking application. It is currently a <strong>work in progress</strong>; but, you are welcome to try it out if you are curious.
</p>
<h2>
Login / Sign-Up
</h2>
<cfif errorMessage.len()>
<p>
#encodeForHtml( errorMessage )#
</p>
</cfif>
<form method="post" action="/index.cfm">
<input type="hidden" name="event" value="#encodeForHtmlAttribute( request.context.event )#" />
<input type="hidden" name="submitted" value="true" />
<input type="hidden" name="formToken" value="#encodeForHtmlAttribute( formToken )#" />
<input
type="text"
name="email"
value="#encodeForHtmlAttribute( form.email )#"
placeholder="ben@example.com"
inputmode="email"
autocapitalize="off"
size="30"
class="input"
/>
<button type="submit">
Login or Sign-Up
</button>
</form>
</cfoutput>
</cfsavecontent>
The only thing of note about this view file is that it isn't writing to the output directly - it's being captured in a CFSaveContent
buffer. You may not have thought about this before, but this is basically what every ColdFusion framework is doing for you: rendering a .cfm
file and then capturing the output. FW/1, for example, captures this in the body
variable. I'm just being more explicit here and I'm capturing it in the request.template.primaryContent
variable.
As the request has been routed down through the nested controllers and action files, it's been aggregating data in the request.template
structure. If you recall from our root index.cfm
file from above, the root controller both routes requests and renders templates. To refresh your memory, here's a relevant snippet from the root controller layout logic:
<cfscript>
// ... truncated code ...
request.template = {
type: "internal",
statusCode: 200,
statusText: "OK",
title: "Dig Deep Fitness",
assetVersion: "2023.07.22.09.54", // Making the input borders more intense.
bugsnagApiKey: config.bugsnag.client.apiKey
};
try {
// ... truncated code ...
// ... truncated code ...
// ... truncated code ...
// Now that we have executed the page, let's include the appropriate rendering
// template.
switch ( request.template.type ) {
case "auth":
include "./layouts/auth.cfm";
break;
case "blank":
include "./layouts/blank.cfm";
break;
case "internal":
include "./layouts/internal.cfm";
break;
case "system":
include "./layouts/system.cfm";
break;
}
// NOTE: Since this try/catch is happening in the index file, we know that the
// application has, at the very least, successfully bootstrapped and that we have
// access to all the application-scoped services.
} catch ( any error ) {
// ... truncated code ...
}
</cfscript>
As you can see, in the last part of the try
block, after the request has been routed to the lower-level controller, the last step is to render the designated layout. Each layout operates kind of like an "action file" in that is has its own logic and its own view. Sticking with the "auth" example from above, here's the ./layouts/auth.cfm
layout file:
<cfscript>
param name="request.template.statusCode" type="numeric" default=200;
param name="request.template.statusText" type="string" default="OK";
param name="request.template.title" type="string" default="";
param name="request.template.primaryContent" type="string" default="";
param name="request.template.assetVersion" type="string" default="";
// Use the correct HTTP status code.
cfheader(
statusCode = request.template.statusCode,
statusText = request.template.statusText
);
// Reset the output buffer.
cfcontent( type = "text/html; charset=utf-8" );
include "./auth.view.cfm";
</cfscript>
As you can see, the layout action file parameterizes (and documents) the request.template
properties that it needs for rendering, resets the output, and then includes the "layout view" file. Unlike an "action view" file, which is captured in a CFSaveContent
buffer, the "layout view" file writes directly to the response stream:
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<cfinclude template="./shared/meta.cfm" />
<cfinclude template="./shared/title.cfm" />
<cfinclude template="./shared/favicon.cfm" />
<link rel="stylesheet" type="text/css" href="/css/temp.css?version=#request.template.assetVersion#" />
<cfinclude template="./shared/bugsnag.cfm" />
</head>
<body>
#request.template.primaryContent#
</body>
</html>
</cfoutput>
And just like that, a request is received by my ColdFusion application, routed through a series of switch
statements and include
tags, builds-up a content buffer, and then renders the response for the user.
For me, there's a lot to like about this approach. First and foremost, it's very simple. Meaning - from a mechanical perspective - there's just not that much going on. The request is processed in a top-down manner; and, every single file is being explicitly included / invoked. There's zero magic in the translation of a request into a response for the user.
Furthermore, because I am using simple .cfm
files for the controller layer, I am forced to keep the logic in those files relatively simple. At first blush, I missed being able to define a private
"utility" controller method on a .cfc
-based component. But, what I came to discover is that those "private methods" could actually be moved into "utility components", ultimately making them more reusable across the application. It is a clear example of the "power of constraints."
I also appreciate that while there are clear patterns in this code, those patterns are by convention, not by mandate. This allows me to break the pattern if and when it serves a purpose. Right now, I only have one root error handler in the application. However, if I were to create an API controller, for example, I could very easily give the API controller its own error handling logic that normalized all error structures coming out of the API.
And, speaking of error handling, I love having an explicit error handler in the routing logic that is separate from the onError()
event-handler in the Application.cfc
. This allows me to make strong assumptions about the state of the application depending on which error-handling mechanism is being invoked.
I love that the action files and the view files are collocated in the folder structure. This makes it painless to edit and maintain files that often evolve in lock-step with each other. No having to flip back-and-forth between "controller" folders and "view" folders.
And speaking of "painless editing", since the action/view files are all just .cfm
files, there's no caching of the logic. Which means, I never have to re-initialize the application just to make an edit to the way in which my request is being routed and rendered.
ASIDE: This "no caching" point is not a clear-cut win. There are benefits to caching. And, there are benefits to not caching. And, the "business logic" is still all being cached inside ColdFusion components. So, if that changes, the application still needs to be re-initialized.
One of ColdFusion's super powers is that it allows you be as simple as you want and as complex as you need. In fact, I would argue that the existence of the CFInclude
tag is a key contributor to this desirable flexibility. So key, in fact, that I am able to create a robust and resilient routing and controller system using nothing but a series of try
, switch
, and include
tags.
Want to use code from this post? Check out the license.
Reader Comments
As they say, every application uses a framework. Either you're using one someone else wrote, or you're writing your own. Good stuff 🙌
@Chris,
Totally! I think people often use that phrase in a negative / pejorative way. But, if you keep your complexity in alignment with your needs, I think having your own simple way to do things keeps things at the right level of complexity and maintainability.
It's like, not every JavaScript project needs TypeScript - not every ColdFusion app needs a robust framework in order to get things done.
Great post! This methodology really resonates with how I've always built things when not using a formal framework. Keeps it nice and simple to understand and very straightforward for someone else to jump in and work on. That said, I've always wondered how cfincludes, or the equivalent in PHP perform; I assume at some layer there's some caching rather than it needing to do disk I/O multiple times for every call?
@James,
Great question! My understanding is that there are several layers of caching that happen. First, the CFML (the code / markup) is compiled down into Java Byte code, which is cached. Then, I believe file-existence is also cached, so it doesn't have to be checked again. That said, I also believe the last point is influenced by some performance & caching settings in the ColdFusion Admin.
According to this ColdFusion Tuning Guide by Convective:
I think I actually have this turned off in my personal server, since I never have to clear the cache for changes to take effect. That's something I should look into! 😨 I'm pretty sure I can clear the trusted cache progammatically when I have to refresh the files. I'll have to look into that. Thanks for the challenging question!
As a 27 year ColdFusion veteran, I agree: cfinclude makes it much easier to compartmentalize similar chunks of code, makes debugging faster, allows you to comment out features when needed, and your code base is cleaner and easier to read.
@Randy,
Clearly, great minds think alike 😉 But, in all seriousness, I completely agree. I also find that it's easier to debug why a request might not be routed to the correct place. This is not a knock on FW/1; but, I can't tell you how much time I've wasted in my life trying to understand why FW/1 wasn't routing to my controller.
Useful content, thank you! 👍
@Aytekin,
Thank you my friend!
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →