Dependency Injection
this, disposed with the request.MasterController ships a small DI container with the three ASP.NET Core lifetimes (AddSingleton / AddScoped / AddTransient). Every registered service is available in controllers and minimal-API handlers as this.<name>, each request runs in its own scope, and createScope() / useScope() give background jobs the same guarantees.
Registering services#
// server.js — after master.setupServer()
import master from 'mastercontroller';
import AppContext from './app/models/AppContext.js'; // a MasterRecord DbContext
import { Mailer } from './app/services/mailer.js';
import { RequestTimer } from './app/services/requestTimer.js';
const server = master.setupServer('http');
master.addSingleton('mailer', Mailer); // one instance for the whole app (new Mailer())
master.addScoped('db', AppContext); // one instance per request, built lazily, disposed on close
master.addTransient('timer', RequestTimer); // a new instance every time it is accessed
master.register('config', { region: 'eu' }); // an existing object/instance, app lifetime
await master.startMVC('app');
await master.start(server);
server.listen(3001);| Method | Lifetime | ASP.NET Core |
|---|---|---|
master.addSingleton(name, Class) | Constructed once at registration (new Class()); shared by the whole app. | AddSingleton |
master.addScoped(name, Class) | One instance per request (or per createScope()), built lazily on first use as new Class(scope), disposed when the scope ends. | AddScoped |
master.addTransient(name, Class) | A new instance on every access. | AddTransient |
master.register(name, instance) | Adds an existing object as an app-lifetime service. | AddSingleton(instance) |
Services on this#
Controllers and minimal-API handlers get every registered service as a lazy accessor — this.db, this.mailer, this.timer… A controller’s own members are never shadowed by a service of the same name.
// app/controllers/postsController.js
export default class PostsController {
async index() {
// this.db is the request-scoped AppContext — the same instance for the rest of this request
const posts = await this.db.Post.toList();
this.ok({ posts });
}
async create() {
const post = await this.db.Post.add(this.model);
await this.db.saveChanges();
await this.mailer.send(post.authorEmail, 'Published'); // singleton
this.created(`/posts/${post.id}`, post);
}
}Scoped services can depend on each other#
A scoped class is constructed with the scope it belongs to, so it can pull other scoped services and singletons off that object — the constructor-injection equivalent.
// A scoped service receives the scope it was built in, so it can read
// its own dependencies off it (other scoped services or singletons).
export class OrderService {
constructor(scope) {
this.db = scope.db; // the same scoped AppContext as the controller
this.mailer = scope.mailer; // singleton inherited by every scope
}
async place(order) {
await this.db.Order.add(order);
await this.db.saveChanges();
}
close() { /* optional: called when the scope is disposed */ }
}
master.addScoped('orders', OrderService);The request scope lifetime#
Since 2.11.1 the pipeline creates a real scope for every request with master.createScope():
- Scoped services are built lazily the first time an action (or filter, or another scoped service) touches them — a request that never reads
this.dbnever opens a context. - Inside the request the instance is cached — every access returns the same object, and it is invisible to other requests.
- When the response closes the scope is disposed: each instance the scope built gets its
close(), elsedispose(), elsereset()called. Singletons are left alone.
saveChanges() commits that change set. Exporting one context from a module and importing it everywhere means concurrent requests share one tracker — the classic source of lost writes and stale reads. Register it with master.addScoped('db', AppContext) and use this.db; see Change tracking for why.Scopes for background work#
Outside a request there is no request scope, so build one yourself — master.createScope() is the counterpart of IServiceScopeFactory.CreateScope(). useScope(fn) runs fn(scope) and disposes the scope afterwards, even if fn throws.
// Work outside a request: build a scope per run, dispose it afterwards.
await master.useScope(async (scope) => {
const stale = await scope.db.Session.where('s => s.expiresAt < $$', Date.now()).toList();
for (const s of stale) scope.db.Session.remove(s);
await scope.db.saveChanges();
});
// The explicit form — identical semantics
const scope = master.createScope();
try {
await scope.orders.place({ sku: 'A1', qty: 2 });
} finally {
await scope.dispose(); // closes anything the scope built (close() / dispose() / reset())
}Hosted services, periodic services, and the background queue already do this for you: a BackgroundService has this.useScope() / this.createScope(), and addPeriodicService / backgroundQueue.enqueue pass a fresh scope to each run.
import master, { BackgroundService } from 'mastercontroller';
class OutboxDispatcher extends BackgroundService {
async executeAsync(signal) {
while (!signal.aborted) {
await this.useScope(async (scope) => { // fresh scope per batch
const batch = await scope.db.Outbox.where('o => o.sentAt == $$', null).take(50).toList();
for (const msg of batch) { await send(msg); msg.sentAt = new Date().toISOString(); }
await scope.db.saveChanges();
});
await this.delay(2000, signal);
}
}
}
master.addHostedService(OutboxDispatcher);
master.addPeriodicService('cleanup', 60_000, async (scope) => { /* scope.db … */ });addSingleton. Anything holding per-request state (DbContext, current tenant, unit of work) → addScoped. Cheap throw-away helpers → addTransient.