Skip to content

Scopes

Scopes control the lifecycle and sharing behavior of dependency instances. @noneforge/ioc supports five scopes.

Scope Overview

ScopeInstancesCachingUse Case
singletonOne per containerYesShared services, config
transientNew every timeNoStateless utilities
requestOne per request IDYes (per request)HTTP request context
prototypeNew with shared prototypeNoObject factories
scopedOne per custom scopeYes (per scope)Custom lifecycles

Singleton Scope

Default scope. Creates one instance per container and reuses it for all requests.

typescript
import { Injectable, inject } from '@noneforge/ioc';

@Injectable({ scope: 'singleton' })
class ConfigService {
  private settings = { debug: false };

  setDebug(value: boolean) {
    this.settings.debug = value;
  }

  isDebug() {
    return this.settings.debug;
  }
}

@Injectable()
class ServiceA {
  private config = inject(ConfigService);
}

@Injectable()
class ServiceB {
  private config = inject(ConfigService);
}

// ServiceA and ServiceB share the same ConfigService instance
const serviceA = container.get(ServiceA);
const serviceB = container.get(ServiceB);

serviceA.config.setDebug(true);
console.log(serviceB.config.isDebug()); // true - same instance!

When to Use Singleton

  • Configuration services
  • Database connection pools
  • Caches
  • Loggers
  • Any service that should be shared

Transient Scope

Creates a new instance for every resolution request.

typescript
import { Injectable, inject, InjectionToken } from '@noneforge/ioc';

@Injectable({ scope: 'transient' })
class RequestIdGenerator {
  readonly id = Math.random().toString(36).substring(7);
}

@Injectable()
class RequestHandler {
  private idGen = inject(RequestIdGenerator);

  getId() {
    return this.idGen.id;
  }
}

// Each resolution creates new instance
const handler1 = container.get(RequestHandler);
const handler2 = container.get(RequestHandler);

console.log(handler1.getId()); // 'abc123'
console.log(handler2.getId()); // 'xyz789' - different!

When to Use Transient

  • Stateless utilities
  • Objects that should not be shared
  • When each consumer needs its own instance
  • Factory-like behavior

Request Scope

Creates one instance per request ID. Useful for HTTP request-scoped data.

typescript
import { Injectable, inject, InjectionToken } from '@noneforge/ioc';

@Injectable({ scope: 'request' })
class RequestContext {
  userId?: string;
  startTime = Date.now();

  setUser(id: string) {
    this.userId = id;
  }
}

@Injectable()
class UserService {
  private ctx = inject(RequestContext);

  getCurrentUser() {
    return this.ctx.userId;
  }
}

@Injectable()
class AuditService {
  private ctx = inject(RequestContext);

  getRequestDuration() {
    return Date.now() - this.ctx.startTime;
  }
}

Using Request Scope

Pass requestId when resolving:

typescript
// Create unique ID for this request
const requestId = Symbol('request-1');

// All resolutions with same requestId share instances
const userService = container.get(UserService, { requestId });
const auditService = container.get(AuditService, { requestId });

// Both share the same RequestContext

HTTP Middleware Example

typescript
import express from 'express';

const app = express();

app.use((req, res, next) => {
  // Create request-scoped container or use requestId
  const requestId = Symbol(`request-${Date.now()}`);
  req.requestId = requestId;

  // Attach container reference
  req.resolve = <T>(token: Token<T>) => container.get(token, { requestId });

  next();
});

app.get('/users/:id', (req, res) => {
  const userService = req.resolve(UserService);
  const user = userService.getUser(req.params.id);
  res.json(user);
});

When to Use Request Scope

  • HTTP request context
  • User session data
  • Database transactions
  • Any request-specific state

Prototype Scope

Creates a new instance each time, but instances share the same prototype. Useful for object factories.

typescript
import { Injectable } from '@noneforge/ioc';

@Injectable({ scope: 'prototype' })
class GameEntity {
  x = 0;
  y = 0;

  move(dx: number, dy: number) {
    this.x += dx;
    this.y += dy;
  }
}

// Each resolution creates new instance
const entity1 = container.get(GameEntity);
const entity2 = container.get(GameEntity);

entity1.move(10, 20);
console.log(entity1.x, entity1.y); // 10, 20
console.log(entity2.x, entity2.y); // 0, 0 - different instance

// But they share the prototype
console.log(Object.getPrototypeOf(entity1) === Object.getPrototypeOf(entity2)); // true

Prototype vs Transient

AspectPrototypeTransient
Instance creationNew each timeNew each time
Prototype chainSharedIndependent
MemorySlightly more efficientStandard

Scoped (Custom Scope)

Creates instances within custom-defined scopes managed by ScopeManager.

typescript
import { Injectable, Container } from '@noneforge/ioc';

@Injectable({ scope: 'scoped' })
class TenantService {
  constructor(public tenantId: string) {}
}

// Create scopes for different tenants
const tenant1Scope = container.createScope('tenant-1');
const tenant2Scope = container.createScope('tenant-2');

// Each scope has its own instance
const service1 = container.get(TenantService, { scopeId: 'tenant-1' });
const service2 = container.get(TenantService, { scopeId: 'tenant-2' });

When to Use Custom Scopes

  • Multi-tenant applications
  • Feature-specific contexts
  • Complex lifecycle requirements

Setting Scope

Via @Injectable Decorator

typescript
@Injectable({ scope: 'transient' })
class TransientService {}

@Injectable({ scope: 'singleton' })
class SingletonService {}

@Injectable({ scope: 'request' })
class RequestScopedService {}

Via Provider Configuration

typescript
container.addProvider({
  provide: MyService,
  useClass: MyServiceImpl,
  scope: 'transient',
});

container.addProvider({
  provide: CONFIG,
  useValue: { debug: true },
  // Value providers are always effectively singleton
});

container.addProvider({
  provide: DB_CONNECTION,
  useFactory: () => createConnection(),
  scope: 'singleton',
});

Scope Hierarchy

Child containers inherit parent scopes:

typescript
const rootContainer = new Container([
  { provide: GlobalConfig, useClass: GlobalConfig, scope: 'singleton' },
]);

const childContainer = rootContainer.createChild([
  { provide: LocalConfig, useClass: LocalConfig, scope: 'singleton' },
]);

// Singleton in child doesn't affect parent
const rootGlobal = rootContainer.get(GlobalConfig);
const childGlobal = childContainer.get(GlobalConfig);

console.log(rootGlobal === childGlobal); // true - shared from parent

Scope and Lifecycle Hooks

Scoped instances receive lifecycle callbacks:

typescript
import { Injectable, OnInit, OnDestroy } from '@noneforge/ioc';

@Injectable({ scope: 'request' })
class RequestHandler implements OnInit, OnDestroy {
  async onInit() {
    console.log('Request handler initialized');
  }

  async onDestroy() {
    console.log('Request handler destroyed');
    // Called when scope/container is disposed
  }
}

// Dispose container to trigger onDestroy
await container.dispose();

Cache Statistics

Singleton instances are cached in EnhancedCache:

typescript
const stats = container.getStatistics();
console.log(stats);
// {
//   resolutions: 150,
//   creates: 25,
//   cacheHits: 125,
//   errors: 0
// }

Best Practices

  1. Default to singleton - Most services should be singletons
  2. Use transient for stateless services - When shared state is not needed
  3. Use request scope for HTTP - Per-request data isolation
  4. Be careful with singleton + transient mixing - Transient in singleton can cause issues
  5. Dispose containers properly - Call dispose() to clean up resources

Scope Mismatch Warning

Be careful when injecting transient services into singletons:

typescript
// WARNING: This can cause issues
@Injectable({ scope: 'singleton' })
class SingletonService {
  // TransientService is resolved once when SingletonService is created
  private transient = inject(TransientService);
}

// Better: Use lazy injection
@Injectable({ scope: 'singleton' })
class BetterSingletonService {
  private getTransient = injectLazy(TransientService);

  doWork() {
    // Each call gets fresh transient (via lazy resolution)
    const transient = this.getTransient.resolve();
  }
}

Runnable Example

See examples/scopes for a complete runnable example demonstrating all 5 scopes.

Next Steps

Released under the MIT License.