Skip to content

Commit 3b51644

Browse files
authored
Merge pull request #1933 from wheels-dev/peter/di-container-1912
Add expanded DI container with request scope and auto-wiring
2 parents a6e9f69 + 3562676 commit 3b51644

File tree

17 files changed

+970
-31
lines changed

17 files changed

+970
-31
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Dependency Injection Container
2+
3+
## Quick Reference
4+
5+
### Registration (config/services.cfm)
6+
```cfm
7+
var di = injector();
8+
di.map("name").to("dotted.component.Path"); // transient
9+
di.map("name").to("dotted.component.Path").asSingleton(); // singleton
10+
di.map("name").to("dotted.component.Path").asRequestScoped(); // per-request
11+
di.bind("IName").to("dotted.component.Path"); // interface alias (= map)
12+
```
13+
14+
### Resolution
15+
```cfm
16+
service("name"); // global helper — resolves from container
17+
injector(); // returns the DI container reference
18+
injector().getInstance("name"); // direct container resolution
19+
injector().getInstance(name="x", initArguments={key: val}); // with explicit init args
20+
```
21+
22+
### Controller inject()
23+
```cfm
24+
component extends="Controller" {
25+
function config() {
26+
inject("svcA, svcB"); // comma-delimited list
27+
}
28+
function myAction() {
29+
this.svcA.doWork(); // resolved per-request on this.*
30+
}
31+
}
32+
```
33+
34+
## Scopes
35+
36+
| Scope | Chained Method | Cache Location | Lifetime |
37+
|-------|---------------|----------------|----------|
38+
| Transient | *(none)* | No cache | Per-call |
39+
| Singleton | `.asSingleton()` | `variables.singletons` | Application |
40+
| Request | `.asRequestScoped()` | `request.$wheelsDICache` | Single HTTP request |
41+
42+
## Auto-Wiring
43+
44+
When `initArguments` is empty, the container inspects `init()` parameter names via `getMetaData()`. If a parameter name matches a registered mapping (`containsInstance(paramName)`), it auto-resolves and injects it.
45+
46+
**Precedence:** Explicit `initArguments` > auto-wired > plain `init()` with no args.
47+
48+
## Error Types
49+
50+
| Type | When |
51+
|------|------|
52+
| `Wheels.DI.NotInitialized` | `service()` or `injector()` called before app start |
53+
| `Wheels.DI.ServiceNotFound` | `service("name")` where name is not registered |
54+
| `Wheels.DI.CircularDependency` | Auto-wiring detects A→B→A cycle |
55+
| `Wheels.Injector` | `to()` called without preceding `map()` |
56+
57+
## Introspection API
58+
59+
```cfm
60+
di.containsInstance("name") // boolean — mapping exists?
61+
di.isSingleton("name") // boolean
62+
di.isRequestScoped("name") // boolean
63+
di.getMappings() // struct {name: componentPath, ...}
64+
```
65+
66+
## Environment Overrides
67+
68+
```
69+
config/services.cfm # base
70+
config/<environment>/services.cfm # override (loaded after base)
71+
```
72+
73+
## File Locations
74+
75+
| File | Purpose |
76+
|------|---------|
77+
| `vendor/wheels/Injector.cfc` | Container implementation |
78+
| `vendor/wheels/Global.cfc` | `service()` and `injector()` helpers |
79+
| `vendor/wheels/controller/services.cfc` | `inject()`, `injectedServices()`, `$resolveInjectedServices()` |
80+
| `vendor/wheels/Controller.cfc` | Initializes `$class.services`, calls `$resolveInjectedServices()` |
81+
| `vendor/wheels/events/onapplicationstart.cfc` | Loads `config/services.cfm` |
82+
| `config/services.cfm` | User service registrations |
83+
84+
## Common Patterns
85+
86+
### Service with dependencies (auto-wired)
87+
```cfm
88+
// app/lib/OrderService.cfc — init params match registered names
89+
component {
90+
public OrderService function init(required any emailService, required any logger) {
91+
variables.emailService = arguments.emailService;
92+
variables.logger = arguments.logger;
93+
return this;
94+
}
95+
}
96+
97+
// config/services.cfm
98+
di.map("emailService").to("app.lib.EmailService").asSingleton();
99+
di.map("logger").to("app.lib.AppLogger").asSingleton();
100+
di.map("orderService").to("app.lib.OrderService"); // auto-wires emailService + logger
101+
```
102+
103+
### Testing with mock services
104+
```cfm
105+
// config/testing/services.cfm
106+
var di = injector();
107+
di.map("emailService").to("app.lib.MockEmailService").asSingleton();
108+
```

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
2828
- Enum support with `enum()` for named property values, auto-generated `is*()` checkers, auto-scopes, and inclusion validation
2929
- Query scopes with `scope()` for reusable, composable query fragments in models
3030
- Batch processing with `findEach()` and `findInBatches()` for memory-efficient record iteration
31+
- Expanded DI container with `asRequestScoped()` for per-request service instances, `service()` global helper, declarative `inject()` in controller config, `bind()` interface binding, auto-wiring of init() arguments, and `config/services.cfm` for service registration
3132

3233
----
3334

CLAUDE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,33 @@ mapper()
278278

279279
Built-in: `wheels.middleware.RequestId`, `wheels.middleware.Cors`, `wheels.middleware.SecurityHeaders`, `wheels.middleware.RateLimiter`. Custom middleware: implement `wheels.middleware.MiddlewareInterface`, place in `app/middleware/`.
280280

281+
## DI Container Quick Reference
282+
283+
Register services in `config/services.cfm` (loaded at app start, environment overrides supported):
284+
285+
```cfm
286+
var di = injector();
287+
di.map("emailService").to("app.lib.EmailService").asSingleton();
288+
di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped();
289+
di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton();
290+
```
291+
292+
Resolve with `service()` anywhere, or use `inject()` in controller `config()`:
293+
294+
```cfm
295+
// In any controller/view
296+
var svc = service("emailService");
297+
298+
// Declarative injection in controller config()
299+
function config() {
300+
inject("emailService, currentUser");
301+
}
302+
function create() {
303+
this.emailService.send(to=user.email); // resolved per-request
304+
}
305+
```
306+
307+
Scopes: transient (default, new each call), `.asSingleton()` (app lifetime), `.asRequestScoped()` (per-request via `request.$wheelsDICache`). Auto-wiring: `init()` params matching registered names are auto-resolved when no `initArguments` passed. `bind()` = semantic alias for `map()`.
281308
### Rate Limiting
282309

283310
```cfm

docs/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
* [Contributing to Wheels Windows Installer](working-with-wheels/contributing-to-wheels-windows-installer.md)
121121
* [Contributing to Wheels macOS Installer](working-with-wheels/contributing-to-wheels-macos-installer.md)
122122
* [Background Jobs](working-with-wheels/background-jobs.md)
123+
* [Dependency Injection](working-with-wheels/dependency-injection.md)
123124
* [Submitting Pull Requests](working-with-wheels/submitting-pull-requests.md)
124125
* [Documenting your Code](working-with-wheels/documenting-your-code.md)
125126

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# Dependency Injection
2+
3+
Wheels includes a built-in dependency injection (DI) container that manages service objects and their lifecycles. Register services once in `config/services.cfm`, then resolve them anywhere in your application with `service()` or declarative `inject()`.
4+
5+
## Registering Services
6+
7+
Create `config/services.cfm` to register your services at application startup:
8+
9+
```cfm
10+
<cfscript>
11+
// Get a reference to the DI container
12+
var di = injector();
13+
14+
// Register a transient (new instance each time)
15+
di.map("emailService").to("app.lib.EmailService");
16+
17+
// Register a singleton (one instance for the app lifetime)
18+
di.map("cacheService").to("app.lib.CacheService").asSingleton();
19+
20+
// Register a request-scoped service (one instance per HTTP request)
21+
di.map("currentUser").to("app.lib.CurrentUserService").asRequestScoped();
22+
23+
// Interface binding — semantic alias for map()
24+
di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton();
25+
</cfscript>
26+
```
27+
28+
## Lifecycle Scopes
29+
30+
| Scope | Method | Behavior |
31+
|-------|--------|----------|
32+
| **Transient** | *(default)* | New instance every time `getInstance()` or `service()` is called |
33+
| **Singleton** | `.asSingleton()` | One shared instance for the entire application lifetime |
34+
| **Request** | `.asRequestScoped()` | One instance per HTTP request, automatically cleaned up |
35+
36+
### When to use each scope
37+
38+
- **Transient**: Stateless utilities, formatters, validators
39+
- **Singleton**: Database connection pools, configuration, caches
40+
- **Request**: Current user context, request-specific state, audit loggers
41+
42+
## Using service() in Controllers and Views
43+
44+
The `service()` global helper resolves a registered service by name:
45+
46+
```cfm
47+
// In a controller action
48+
function show() {
49+
var emailService = service("emailService");
50+
emailService.send(to=user.email, subject="Welcome!");
51+
}
52+
```
53+
54+
```cfm
55+
<!--- In a view --->
56+
<cfset var config = service("appConfig")>
57+
<p>Version: #config.getVersion()#</p>
58+
```
59+
60+
## Declarative inject() in Controllers
61+
62+
For services used across multiple actions, declare them in your controller's `config()`:
63+
64+
```cfm
65+
component extends="Controller" {
66+
function config() {
67+
inject("emailService");
68+
// Or inject multiple at once:
69+
inject("emailService, cacheService, currentUser");
70+
71+
// Other config...
72+
filters(through="authenticate");
73+
}
74+
75+
function create() {
76+
// Services are available as this.serviceName
77+
this.emailService.sendWelcome(user);
78+
this.cacheService.invalidate("users");
79+
}
80+
}
81+
```
82+
83+
Injected services are resolved when the controller instance is created (per-request), so request-scoped services get fresh instances automatically.
84+
85+
## Interface Binding with bind()
86+
87+
Use `bind()` instead of `map()` when you want to emphasize that the name represents an abstraction:
88+
89+
```cfm
90+
// In config/services.cfm
91+
var di = injector();
92+
93+
// Bind an interface name to a concrete implementation
94+
di.bind("IPaymentGateway").to("app.lib.StripeGateway").asSingleton();
95+
96+
// In production, swap the implementation without changing consumers
97+
// di.bind("IPaymentGateway").to("app.lib.PayPalGateway").asSingleton();
98+
```
99+
100+
`bind()` is functionally identical to `map()` — CFML has no formal interfaces, so this is semantic sugar for code clarity.
101+
102+
## Auto-Wiring
103+
104+
When a service's `init()` method has parameters whose names match registered service names, the container automatically resolves and injects them:
105+
106+
```cfm
107+
// app/lib/OrderService.cfc
108+
component {
109+
public OrderService function init(
110+
required any emailService,
111+
required any cacheService
112+
) {
113+
variables.emailService = arguments.emailService;
114+
variables.cacheService = arguments.cacheService;
115+
return this;
116+
}
117+
}
118+
```
119+
120+
```cfm
121+
// config/services.cfm
122+
var di = injector();
123+
di.map("emailService").to("app.lib.EmailService").asSingleton();
124+
di.map("cacheService").to("app.lib.CacheService").asSingleton();
125+
di.map("orderService").to("app.lib.OrderService").asSingleton();
126+
127+
// orderService.init() automatically receives emailService and cacheService
128+
```
129+
130+
Auto-wiring is opt-in: it only activates when no explicit `initArguments` are passed to `getInstance()`. Parameters that don't match any registered mapping are skipped.
131+
132+
### Circular Dependency Protection
133+
134+
The container detects circular dependencies and throws `Wheels.DI.CircularDependency` with a clear message showing the resolution chain, rather than causing a stack overflow.
135+
136+
## Environment-Specific Services
137+
138+
Just like `config/settings.cfm`, you can create environment-specific service overrides:
139+
140+
```
141+
config/
142+
services.cfm # Base registrations
143+
development/
144+
services.cfm # Override for development
145+
testing/
146+
services.cfm # Override for testing
147+
production/
148+
services.cfm # Override for production
149+
```
150+
151+
Environment-specific services are loaded after the base file, so they can override registrations:
152+
153+
```cfm
154+
// config/testing/services.cfm
155+
var di = injector();
156+
di.bind("IPaymentGateway").to("app.lib.MockPaymentGateway").asSingleton();
157+
di.map("emailService").to("app.lib.MockEmailService").asSingleton();
158+
```
159+
160+
## Introspection API
161+
162+
The container provides methods to inspect its state:
163+
164+
```cfm
165+
var di = injector();
166+
di.getMappings(); // struct of all name → componentPath mappings
167+
di.containsInstance("emailService"); // true if registered
168+
di.isSingleton("emailService"); // true if marked as singleton
169+
di.isRequestScoped("currentUser"); // true if marked as request-scoped
170+
```
171+
172+
## Examples
173+
174+
### Full Application Setup
175+
176+
```cfm
177+
// config/services.cfm
178+
<cfscript>
179+
var di = injector();
180+
181+
// Infrastructure
182+
di.map("mailer").to("app.lib.PostmarkMailer").asSingleton();
183+
di.map("cache").to("app.lib.RedisCache").asSingleton();
184+
di.map("logger").to("app.lib.AppLogger").asSingleton();
185+
186+
// Request-scoped
187+
di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped();
188+
di.map("auditLog").to("app.lib.AuditLogger").asRequestScoped();
189+
190+
// Business logic (auto-wired)
191+
di.map("orderService").to("app.lib.OrderService");
192+
di.map("invoiceService").to("app.lib.InvoiceService");
193+
</cfscript>
194+
```
195+
196+
```cfm
197+
// app/controllers/Orders.cfc
198+
component extends="Controller" {
199+
function config() {
200+
inject("orderService, currentUser");
201+
filters(through="authenticate");
202+
}
203+
204+
function create() {
205+
var order = model("Order").create(params.order);
206+
this.orderService.processNewOrder(order, this.currentUser);
207+
redirectTo(route="order", key=order.id);
208+
}
209+
}
210+
```

vendor/wheels/Controller.cfc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ component output="false" displayName="Controller" extends="wheels.Global"{
5151
variables.$class.formats.existingTemplates = "";
5252
variables.$class.formats.nonExistingTemplates = "";
5353

54+
// Storage for declared service injections (populated by inject() in config)
55+
variables.$class.services = [];
56+
5457
$setFlashStorage($get("flashStorage"));
5558
$setFlashAppend($get("flashAppend"));
5659

@@ -114,6 +117,10 @@ component output="false" displayName="Controller" extends="wheels.Global"{
114117
executeArgs = local.executeArgs
115118
);
116119
variables.params = arguments.params;
120+
121+
// Resolve any services declared via inject() in config()
122+
$resolveInjectedServices();
123+
117124
return this;
118125
}
119126

0 commit comments

Comments
 (0)