Skip to main content

Creating scopeโ€‹

Out of the box, Clawject provides two scopes: singleton and transient, but sometimes you may need to define your own scopes.

For example, if you are developing an http server application, you may want to provide a separate instance of a particular bean or set of beans for each request. Clawject provides a mechanism for creating custom scopes for scenarios such as this.

To create a custom scope, you should implement the Scope interface.

In the following steps, we will implement request scope that is using AsyncLocalStorage#run to assign and retrieve httpRequestId.

Creating HttpExecutionContextโ€‹

First of all - let's define HttpExecutionContext class that will store unique id to each http request and allows us to retrieve getCurrentRequestId in the context of c callback call:

HttpExecutionContext.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export class HttpExecutionContext {
private static idSeq = 0;
private static asyncLocalStorage = new AsyncLocalStorage<number>();

static run<T>(callback: () => T): T {
return this.asyncLocalStorage.run(this.idSeq++, callback);
}

static getCurrentRequestId(): number {
//For simplicity - let's assume that AsyncLocalStorage always returns a value
return this.asyncLocalStorage.getStore()!;
}
}

Creating Scope implementationโ€‹

Now let's create RequestScope class that implements Scope interface:

RequestScope.ts
import { Scope, ObjectFactory, ObjectFactoryResult } from '@clawject/di';

export class RequestScope implements Scope {
static readonly instance = new RequestScope();
private constructor() {}

private beginCallbacks: (() => Promise<void>)[] = [];
private requestIdToNameToInstance = new Map<number, Map<string, any>>();
private destructionCallbacks = new Map<number, Map<string, () => void>>();

onScopeBegin(): Promise<void>
onScopeEnded(): Promise<void>
registerScopeBeginCallback(callback: () => Promise<void>): void
removeScopeBeginCallback(callback: () => Promise<void>): void
get(name: string, objectFactory: ObjectFactory): ObjectFactoryResult
remove(name: string): ObjectFactoryResult | null
registerDestructionCallback(name: string, callback: () => void): void
useProxy(): boolean
}

Now let's implement each method

onScopeBeginโ€‹

This method is not a part of Scope interface, so you can name and implement it as you wish.

This method will be called when the scope is about to start, and it should execute and await all beginning callbacks that were registered via Scope#registerScopeBeginCallback.

In our case, this method will be called at the start of each http request.

export class RequestScope implements Scope {
private beginCallbacks: (() => Promise<void>)[] = [];

async onScopeBegin(): Promise<void> {
await Promise.all(this.beginCallbacks.map(cb => cb()));
}
}

It's crucial to call all registered callbacks and await them all before the scope begins, because some beans can be async and may require some initialization before the scope begins.

onScopeEndedโ€‹

This method should be called when the scope is about to end. It should do a final cleanup and destroy all beans that were created during the scope's lifetime. This method should do the following:

  1. Execute and await all destruction callbacks that were registered via Scope#registerDestructionCallback
  2. Remove all beans that were created during the scope's lifetime from the underlying storage.
  3. Remove all destruction callbacks that were registered via Scope#registerDestructionCallback
import { HttpExecutionContext } from './HttpExecutionContext';

export class RequestScope implements Scope {
private requestIdToNameToInstance = new Map<number, Map<string, any>>();
private destructionCallbacks = new Map<number, Map<string, () => void>>();

async onScopeEnded(): Promise<void> {
const requestId = HttpExecutionContext.getCurrentRequestId();
const destructionCallbacks = Array.from(this.destructionCallbacks.get(requestId)?.values() ?? []);

await Promise.all(destructionCallbacks.map(cb => cb()));

this.requestIdToNameToInstance.delete(requestId);
this.destructionCallbacks.delete(requestId);
}
}

This method will be called at the end of each http request.

registerScopeBeginCallbackโ€‹

This method is a part of Scope interface, so you should implement it as it is defined in the interface.

This method should register a callback that will be executed when the scope begins, but it should not execute the callback. Note that the callback is unique for each created application context, and will be called only for application contexts that are using this scope.

export class RequestScope implements Scope {
private beginCallbacks: (() => Promise<void>)[] = [];

registerScopeBeginCallback(callback: () => Promise<void>): void {
this.beginCallbacks.push(callback);
}
}

removeScopeBeginCallbackโ€‹

This method is a part of Scope interface, so you should implement it as it is defined in the interface.

This method should remove a callback that was registered via Scope#registerScopeBeginCallback, it should not execute the callback. This method will be called only on application context shutdown.

export class RequestScope implements Scope {
private beginCallbacks: (() => Promise<void>)[] = [];

removeScopeBeginCallback(callback: () => Promise<void>): void {
this.beginCallbacks = this.beginCallbacks.filter(cb => cb !== callback);
}
}

getโ€‹

This method is a part of Scope interface, so you should implement it as it is defined in the interface.

This method should do the following:

  • Return an instance of a bean that is associated with the given name using ObjectFactory as a factory for creating the bean.
  • If the bean is not found in the underlying storage, it should create a new instance of the bean using the ObjectFactory and store it in the underlying storage.
  • If the bean is found in the underlying storage, it should return the instance of the bean from the underlying storage.
  • If after calling objectFactory.getObject() it receives a Promise, it should return a new promise that will resolve to the instance of the bean and store it in the underlying storage. When the promise resolves and get method will be called again with the same name - it should return the awaited value of the bean from the underlying storage. If a result of awaited value is also a Promiseโ€”it should store it and return it as a result of get method.
import { ObjectFactory, ObjectFactoryResult, Scope } from '@clawject/di';
import { HttpExecutionContext } from './HttpExecutionContext';

export class RequestScope implements Scope {
private requestIdToNameToInstance = new Map<number, Map<string, any>>();

get(name: string, objectFactory: ObjectFactory): ObjectFactoryResult {
const requestId = HttpExecutionContext.getCurrentRequestId();
let nameToInstance = this.requestIdToNameToInstance.get(requestId);
if (!nameToInstance) {
nameToInstance = new Map();
this.requestIdToNameToInstance.set(requestId, nameToInstance);
}

let object: any;

if (nameToInstance.has(name)) {
object = nameToInstance.get(name);
} else {
object = objectFactory.getObject();
if (object instanceof Promise) {
object = object.then(resolvedObject => {
nameToInstance!.set(name, resolvedObject);
return resolvedObject;
});
}
nameToInstance.set(name, object);
}

return object;
}
}

removeโ€‹

This method is a part of Scope interface, so you should implement it as it is defined in the interface.

This method should do the following:

  • Remove the bean associated with the given name from the underlying storage.
  • Return the instance of the bean that was removed from the underlying storage or null if the bean was not found in the underlying storage.
import { ObjectFactoryResult, Scope } from '@clawject/di';
import { HttpExecutionContext } from './HttpExecutionContext';

export class RequestScope implements Scope {
private requestIdToNameToInstance = new Map<number, Map<string, any>>();
private destructionCallbacks = new Map<number, Map<string, () => void>>();

remove(name: string): ObjectFactoryResult | null {
const requestId = HttpExecutionContext.getCurrentRequestId();

const instance = this.requestIdToNameToInstance.get(requestId)?.get(name);

this.destructionCallbacks.get(requestId)?.delete(name);
this.requestIdToNameToInstance.get(requestId)?.delete(name);

return instance ?? null;
}
}

registerDestructionCallbackโ€‹

This method is a part of Scope interface, so you should implement it as it is defined in the interface.

This method should register a callback that will be executed when the scope ends, but it should not execute the callback. This method will be called by a container when a bean is being destroyed.

import { HttpExecutionContext } from './HttpExecutionContext';

export class RequestScope implements Scope {
private destructionCallbacks = new Map<number, Map<string, () => void>>();

registerDestructionCallback(name: string, callback: () => void): void {
const requestId = HttpExecutionContext.getCurrentRequestId();
let nameToCallback = this.destructionCallbacks.get(requestId);
if (!nameToCallback) {
nameToCallback = new Map();
this.destructionCallbacks.set(requestId, nameToCallback);
}

nameToCallback.set(name, callback);
}
}

useProxyโ€‹

This method is a part of Scope interface, so you should implement it as it is defined in the interface. Note that this method is optional, and you can omit it, default implementation will return true.

This method should return true if the scope requires a proxy to be injected instead of the actual bean instance, and false otherwise. Note that in most of the cases you will need to inject proxies.

export class RequestScope implements Scope {
useProxy(): boolean {
return true;
}
}

Put the pieces togetherโ€‹

import { Scope, ObjectFactory, ObjectFactoryResult } from '@clawject/di';
import { HttpExecutionContext } from './HttpExecutionContext';

export class RequestScope implements Scope {
private constructor() {}
static readonly instance = new RequestScope();

private beginCallbacks: (() => Promise<void>)[] = [];
private requestIdToNameToInstance = new Map<number, Map<string, any>>();
private destructionCallbacks = new Map<number, Map<string, () => void>>();

async onScopeBegin(): Promise<void> {
await Promise.all(this.beginCallbacks.map(cb => cb()));
}

async onScopeEnded(): Promise<void> {
const requestId = HttpExecutionContext.getCurrentRequestId();
const destructionCallbacks = Array.from(this.destructionCallbacks.get(requestId)?.values() ?? []);

await Promise.all(destructionCallbacks.map(cb => cb()));

this.requestIdToNameToInstance.delete(requestId);
this.destructionCallbacks.delete(requestId);
}

registerScopeBeginCallback(callback: () => Promise<void>): void {
this.beginCallbacks.push(callback);
}

removeScopeBeginCallback(callback: () => Promise<void>): void {
this.beginCallbacks = this.beginCallbacks.filter(cb => cb !== callback);
}

get(name: string, objectFactory: ObjectFactory): ObjectFactoryResult {
const requestId = HttpExecutionContext.getCurrentRequestId();
let nameToInstance = this.requestIdToNameToInstance.get(requestId);
if (!nameToInstance) {
nameToInstance = new Map();
this.requestIdToNameToInstance.set(requestId, nameToInstance);
}

let object: any;

if (nameToInstance.has(name)) {
object = nameToInstance.get(name);
} else {
object = objectFactory.getObject();
if (object instanceof Promise) {
object = object.then(resolvedObject => {
nameToInstance!.set(name, resolvedObject);
return resolvedObject;
});
}
nameToInstance.set(name, object);
}

return object;
}

remove(name: string): ObjectFactoryResult | null {
const requestId = HttpExecutionContext.getCurrentRequestId();

const instance = this.requestIdToNameToInstance.get(requestId)?.get(name);

this.destructionCallbacks.get(requestId)?.delete(name);
this.requestIdToNameToInstance.get(requestId)?.delete(name);

return instance ?? null;
}

registerDestructionCallback(name: string, callback: () => void): void {
const requestId = HttpExecutionContext.getCurrentRequestId();
let nameToCallback = this.destructionCallbacks.get(requestId);
if (!nameToCallback) {
nameToCallback = new Map();
this.destructionCallbacks.set(requestId, nameToCallback);
}

nameToCallback.set(name, callback);
}

useProxy(): boolean {
return true;
}
}

Creating the server codeโ€‹

Now let's create a simple http server that will use our custom scope.

To do this, we will use the http module from Node.js and the HttpExecutionContext class that we created earlier.

When a request is received, we will run the request handling code inside HttpExecutionContext's run method.

Before any request handling code is executed, we will call RequestScope#onScopeBegin to initialize the scoped beans, and await initialization.

After the request handling code is executed, we will call RequestScope#onScopeEnded to destroy the scoped beans, and await destruction.

main.ts
import http from 'node:http';
import { HttpExecutionContext } from './HTTPExecutionContext';
import { RequestScope } from './RequestScope';

http.createServer((req, res) => {
HttpExecutionContext.run(async () => {
await RequestScope.instance.onScopeBegin();
/* request-handling */
await RequestScope.instance.onScopeEnded();
});
}).listen(8080);

Registering the Scopeโ€‹

To make the Clawject container aware of your new scope, you need to register it through the registerScope method on a ScopeRegister class.

Note that you should register your scope before creating the container.

import { ScopeRegister } from '@clawject/di';
import { RequestScope } from './RequestScope';

ScopeRegister.registerScope('request', RequestScope.instance);

Using the Custom Scopeโ€‹

Now we have registered our custom scope, and we can use it in our beans.

Let's define RequestStorage class that can store arbitrary data for each http request:

RequestStorage.ts
export class RequestStorage {
private data = new Map<string, any>();

set(key: string, value: any): void {
this.data.set(key, value);
}

get(key: string): any {
return this.data.get(key);
}
}

Now let's define request bean that will be scoped to each http request:

Application.ts
import { Bean, ClawjectApplication, Scope } from '@clawject/di';
import { RequestStorage } from './RequestStorage';

@ClawjectApplication
export class Application {
@Scope('request') requestStorage = Bean(RequestStorage)
}

Now, when you inject RequestStorage into other beans (even singleton-scoped) โ€“ the proxy will be injected, and the actual instance of RequestStorage will be created and destroyed for each http request.

SomeService.ts
import { RequestStorage } from './RequestStorage';

export class SomeService {
constructor(private requestStorage: RequestStorage) {}

/* ... */
}