MasterController

Dependency Injection

Singleton, scoped, and transient services — resolved on 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
// 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);
MethodLifetimeASP.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.

postsController.js
// 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.

javascript
// 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.db never 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(), else dispose(), else reset() called. Singletons are left alone.
Never share a DbContext across requests
A MasterRecord context is a unit of work: it tracks every entity it has loaded, and 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.

javascript
// 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.

outboxDispatcher.js
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 … */ });
Rule of thumb
Stateless, thread-safe collaborators (mailers, HTTP clients, caches) → addSingleton. Anything holding per-request state (DbContext, current tenant, unit of work) → addScoped. Cheap throw-away helpers → addTransient.