Digging Deeper
Dependency Injection Usage
This page is the hands-on companion to the DI concept tour. You already know what the container is and what the three scopes mean — now you’ll wire real patterns: swapping a real service for a test double, resolving a per-request value, constructing a service that needs custom wiring, relying on auto-wiring to keep call sites clean, and knowing when not to reach for the container.
You’ll learn:
- Swap a registered service for a test double in a BDD spec
- Use a request-scoped resolver to carry per-request state
- Register services whose construction needs custom logic
- Let the container auto-wire an
init()from registered names - Avoid the service-locator anti-pattern
Registration, recapped
Section titled “Registration, recapped”Every registration lives in config/services.cfm. You grab the container with injector(), call map() (or bind()) with a name, chain .to() with a dotted component path, and optionally chain a lifecycle:
local.di = injector();
local.di.map("emailService").to("app.lib.EmailService").asSingleton();local.di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton();map() and bind() are the same method — bind() reads better for interface-style names. Full API tour lives on the concept page; the rest of this guide assumes you’ve registered services this way.
Swapping a strategy for a test double
Section titled “Swapping a strategy for a test double”The payoff of registering by name is that you can re-point the name at a fake for the duration of a test. Any consumer that resolves service("emailService") picks up the fake without modification.
Put the swap in the spec’s beforeEach and restore the real binding in afterEach. The restore matters — without it, a failing test leaks the fake into every spec that runs afterward.
component extends="wheels.WheelsTest" { function run() { describe("Users##create", () => { beforeEach(() => { injector().map("emailService").to("tests._assets.FakeEmailService").asSingleton(); });
afterEach(() => { injector().map("emailService").to("app.lib.EmailService").asSingleton(); });
it("sends a welcome email", () => { var fake = service("emailService"); // drive the controller action that calls service("emailService").send(...) // then assert against fake.sentMessages or similar expect(fake.sentMessages).toHaveLength(1); }); }); }}Two things to know. First, the swap is global for the duration of the test — any code path that resolves emailService during that it block gets the fake, including model callbacks and background jobs that run inline. Second, the singleton cache is keyed by the alias (emailService), and re-binding the alias only invalidates the cached instance when the new target is a different component path. That’s why the fake lives under its own path (tests._assets.FakeEmailService vs app.lib.EmailService): the different-path re-bind evicts the cached real instance, so the next service("emailService") constructs the fake instead of handing back the stale singleton.
Per-request resolvers
Section titled “Per-request resolvers”Request-scoped bindings pair naturally with “resolve based on who’s making this request.” The resolver class looks up something from the current request on construction, and every caller in that request gets the same instance. The next request constructs a fresh one.
local.di = injector();local.di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped();component { public any function init() { if (StructKeyExists(session, "userId")) { variables.user = model("User").findByKey(session.userId); } else { variables.user = javaCast("null", 0); } return this; }
public any function get() { return variables.user; }
public boolean function isPresent() { return !IsNull(variables.user); }}In a controller, resolving by name gives you the cached resolver for this request:
var user = service("currentUser").get();if (service("currentUser").isPresent()) { // ...}Same instance inside filters, actions, views, and any service called during the request. Next request, a fresh resolver reads the new session.
Factory registration
Section titled “Factory registration”The Wheels injector’s public API is map(name).to(componentPath).asSingleton() | .asRequestScoped(). There is no toFactory() — .to() takes a dotted component path, not a callback. When construction needs logic (reading env vars, composing from other services, branching on environment), wrap it in a plain CFC whose init() returns the real instance.
component { public any function init() { variables.instance = new wheels.auth.JwtStrategy( secret=application.wo.get("jwtSecret"), issuer="my-app" ); return this; }
public any function build() { return variables.instance; }}Register the factory as a singleton, then have callers ask it for the built instance:
local.di = injector();local.di.map("jwtStrategyFactory").to("app.lib.JwtStrategyFactory").asSingleton();
// consumers do:// var jwt = service("jwtStrategyFactory").build();If you need the factory output to itself be registered under a name (so consumers call service("jwtStrategy") directly), register the strategy at app-init time after the factory has built it — the authenticator-wiring pattern in Authentication Patterns does exactly this.
Auto-wiring init() parameters
Section titled “Auto-wiring init() parameters”When a service’s init() takes named arguments matching registered names, the injector resolves them automatically on construction. You don’t pass initArguments; the container inspects the target’s init() metadata and fills in what it knows.
component { public any function init(required any emailService, required any currentUser) { variables.emailService = arguments.emailService; variables.currentUser = arguments.currentUser; return this; }
public void function signup(required struct attrs) { var user = model("User").create(arguments.attrs); if (user.valid()) { variables.emailService.send(to=user.email, subject="Welcome"); } }}local.di = injector();local.di.map("emailService").to("app.lib.EmailService").asSingleton();local.di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped();local.di.map("signupService").to("app.lib.SignupService").asSingleton();service("signupService") constructs SignupService, inspects its init() parameters, sees emailService and currentUser are registered, resolves both, and passes them as arguments. If you do pass initArguments, auto-wiring is skipped — your explicit struct wins.
A caveat: a singleton that auto-wires a request-scoped dependency captures the resolver from the first resolution and holds it forever. That’s almost never what you want. Keep request-scoped values on request-scoped or transient consumers, or resolve them per-call rather than per-construction.
Declarative injection in controllers
Section titled “Declarative injection in controllers”Controllers have a shorter syntax: call inject() in config() with a comma-delimited list of names. Each name becomes an instance variable on the controller, resolved per-request.
component extends="Controller" { function config() { inject("emailService, currentUser"); }
function create() { if (this.currentUser.isPresent()) { redirectTo(route="dashboard"); return; } var user = model("User").create(params.user); if (user.valid()) { this.emailService.send(to=user.email, subject="Welcome"); } }}This is the pattern Tutorial Part 6b uses to wire authenticator and currentUser into the controllers that need them. It keeps dependencies declared at the top of the file where a reader can see them, rather than scattered across action methods.
The service-locator anti-pattern
Section titled “The service-locator anti-pattern”Resolving service("something") deep inside business logic — inside model methods, private helpers, view partials — hides dependencies. A model that does this looks like it stands alone but secretly reaches into the container:
component extends="Model" { public void function welcome() { // Model now has a hidden dependency on emailService. // Tests must remember to stub the container. Readers // must grep to find what this method actually needs. service("emailService").send(to=this.email, subject="Welcome"); }}Prefer injection at the controller or service boundary. The controller injects emailService, does the work that needs it, and passes plain data to the model. The model stays a pure data object:
component extends="Controller" { function config() { inject("emailService"); } function create() { var user = model("User").create(params.user); if (user.valid()) { this.emailService.send(to=user.email, subject="Welcome"); } }}The rule of thumb: service() calls belong at the edges — controllers, background jobs, the app-init block. Core domain code takes its dependencies as arguments.
Singletons and statefulness
Section titled “Singletons and statefulness”.asSingleton() means one instance for the life of the application. Safe for stateless services (email senders, token hashers, HTTP clients with no per-request state). Unsafe for anything that holds per-request data — a singleton currentUser would hand every request the user who happened to hit the server first. Request-scoped resolvers fix this: per-request construction, per-request cache, automatic cleanup when the request ends.
If you’re tempted to stuff per-request state into a singleton with a setUser() method, stop and make it request-scoped instead. The container is built for exactly this case.
Debugging resolution
Section titled “Debugging resolution”When service("name") throws Wheels.Injector or hands back the wrong thing, run through this list:
- Is the registration in
config/services.cfm? The file is loaded once at app start. Typos don’t surface until resolution time. - Did the app reload after you edited
services.cfm? Runwheels reloador hit?reload=true&password=.... Service registrations are not re-read per request. - Does the binding name match? Names are case-insensitive — the injector stores bindings in plain CFML structs, so
service("EmailService")andservice("emailService")resolve the same registration. A “name not found” error means the name genuinely was never registered, not a casing mismatch. - Is
.to(...)pointing at a valid dotted path? A typo in the target path throws atcreateObject()time, not at registration time. The error surfaces the first time someone resolves the name. - Circular dependency? If A’s
init()auto-wires B and B’sinit()auto-wires A, the injector throwsWheels.DI.CircularDependencywith the resolution chain. Break the cycle by turning one side into a factory or resolving lazily.