feat: implement service collection

This commit is contained in:
Stevan Freeborn
2025-05-12 22:23:31 -05:00
parent eda06cc9d4
commit 540d8c82eb
6 changed files with 204 additions and 33 deletions
+1
View File
@@ -3,3 +3,4 @@ import "reflect-metadata";
export * from './serviceProvider.js';
export * from './types.js';
export * from './decorators.js';
export * from './serviceCollection.js';
+73
View File
@@ -0,0 +1,73 @@
import { ServiceProvider } from './serviceProvider.js';
import type {
Constructor,
IServiceCollection,
IServiceProvider,
ServiceDescriptor,
ServiceFactory,
ServiceIdentifier,
ServiceLifetime,
} from './types.js';
export class ServiceCollection implements IServiceCollection {
private readonly _descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>> =
new Map();
public addSingleton<T>(
serviceType: ServiceIdentifier<T>,
implementationOrFactory: ServiceFactory<T> | Constructor<T>,
): IServiceCollection {
return this.add(serviceType, implementationOrFactory, 'singleton');
}
public addScoped<T>(
serviceType: ServiceIdentifier<T>,
implementationOrFactory: ServiceFactory<T> | Constructor<T>,
): IServiceCollection {
return this.add(serviceType, implementationOrFactory, 'scoped');
}
public addTransient<T>(
serviceType: ServiceIdentifier<T>,
implementationOrFactory: ServiceFactory<T> | Constructor<T>,
): IServiceCollection {
return this.add(serviceType, implementationOrFactory, 'transient');
}
public build(): IServiceProvider {
return new ServiceProvider(this._descriptors);
}
private add<T>(
serviceType: ServiceIdentifier<T>,
implementationOrFactory: ServiceFactory<T> | Constructor<T>,
lifetime: ServiceLifetime,
): IServiceCollection {
if (typeof implementationOrFactory === 'function' && this.isConstructor(implementationOrFactory) === false) {
const descriptor: ServiceDescriptor<T> = {
serviceType,
implementationType: Object as unknown as Constructor<T>,
lifetime,
factory: implementationOrFactory as ServiceFactory<T>,
};
this._descriptors.set(serviceType, descriptor);
} else {
const descriptor: ServiceDescriptor<T> = {
serviceType,
implementationType: implementationOrFactory as Constructor<T>,
lifetime,
};
this._descriptors.set(serviceType, descriptor);
}
return this;
}
private isConstructor(func: unknown): func is Constructor<unknown> {
return typeof func === 'function' &&
!!func.prototype &&
func.prototype.constructor === func;
}
}
+15 -15
View File
@@ -19,19 +19,19 @@ export class ServiceScope implements IServiceScope {
}
export class ServiceProvider implements IServiceProvider {
private descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>>;
private singletonInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map();
private scopedInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map();
private readonly _descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>>;
private readonly _singletonInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map();
private readonly _scopedInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map();
constructor(
descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>>,
parent?: ServiceProvider,
) {
this.descriptors = descriptors;
this._descriptors = descriptors;
if (parent) {
parent.singletonInstances.forEach((value, key) => {
this.singletonInstances.set(key, value);
parent._singletonInstances.forEach((value, key) => {
this._singletonInstances.set(key, value);
});
}
@@ -43,7 +43,7 @@ export class ServiceProvider implements IServiceProvider {
}
getService<T>(serviceType: ServiceIdentifier<T>): T {
const descriptor = this.descriptors.get(serviceType);
const descriptor = this._descriptors.get(serviceType);
if (!descriptor) {
throw new Error(`Service of type ${serviceType.toString()} is not registered.`);
@@ -53,12 +53,12 @@ export class ServiceProvider implements IServiceProvider {
}
createScope(): IServiceScope {
const scopedProvider = new ServiceProvider(this.descriptors, this);
const scopedProvider = new ServiceProvider(this._descriptors, this);
return new ServiceScope(scopedProvider);
}
dispose(): void {
this.scopedInstances.clear();
this._scopedInstances.clear();
}
private resolveService<T>(descriptor: ServiceDescriptor<T>): T {
@@ -66,19 +66,19 @@ export class ServiceProvider implements IServiceProvider {
switch (lifetime) {
case 'singleton': {
if (this.singletonInstances.has(serviceType)) {
return this.singletonInstances.get(serviceType) as T;
if (this._singletonInstances.has(serviceType)) {
return this._singletonInstances.get(serviceType) as T;
}
const singletonInstance = factory ? factory(this) : this.createInstance(implementationType);
this.singletonInstances.set(serviceType, singletonInstance);
this._singletonInstances.set(serviceType, singletonInstance);
return singletonInstance;
}
case 'scoped': {
if (this.scopedInstances.has(serviceType)) {
return this.scopedInstances.get(serviceType) as T;
if (this._scopedInstances.has(serviceType)) {
return this._scopedInstances.get(serviceType) as T;
}
const scopedInstance = factory ? factory(this) : this.createInstance(implementationType);
this.scopedInstances.set(serviceType, scopedInstance);
this._scopedInstances.set(serviceType, scopedInstance);
return scopedInstance;
}
case 'transient':
+1 -1
View File
@@ -25,7 +25,7 @@ export interface IServiceCollection {
addScoped<T>(serviceType: ServiceIdentifier<T>, factory: ServiceFactory<T>): IServiceCollection;
addTransient<T>(serviceType: ServiceIdentifier<T>, implementationType: Constructor<T>): IServiceCollection;
addTransient<T>(serviceType: ServiceIdentifier<T>, factory: ServiceFactory<T>): IServiceCollection;
buildServiceProvider(): IServiceProvider;
build(): IServiceProvider;
}
export interface IServiceProvider {
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { createServiceIdentifier, ServiceCollection } from '../src';
describe('ServiceCollection', () => {
it('should add a singleton service', () => {
class TestService {}
const serviceIdentifier = createServiceIdentifier();
const collection = new ServiceCollection();
collection.addSingleton(serviceIdentifier, TestService);
const provider = collection.build();
const serviceInstance1 = provider.getService(serviceIdentifier);
const serviceInstance2 = provider.getService(serviceIdentifier);
expect(serviceInstance1).toBe(serviceInstance2);
});
it('should add a scoped service', () => {
class TestService {}
const serviceIdentifier = createServiceIdentifier();
const collection = new ServiceCollection();
collection.addScoped(serviceIdentifier, TestService);
const provider = collection.build();
const scope1 = provider.createScope();
const scope2 = provider.createScope();
const serviceInstance1 = scope1.serviceProvider.getService(serviceIdentifier);
const serviceInstance2 = scope2.serviceProvider.getService(serviceIdentifier);
expect(serviceInstance1).not.toBe(serviceInstance2);
});
it('should add a transient service', () => {
class TestService {}
const serviceIdentifier = createServiceIdentifier();
const collection = new ServiceCollection();
collection.addTransient(serviceIdentifier, TestService);
const provider = collection.build();
const serviceInstance1 = provider.getService(serviceIdentifier);
const serviceInstance2 = provider.getService(serviceIdentifier);
expect(serviceInstance1).not.toBe(serviceInstance2);
});
it('should add a single service with a factory', () => {
class TestService {}
const serviceIdentifier = createServiceIdentifier();
const collection = new ServiceCollection();
collection.addSingleton(serviceIdentifier, () => new TestService());
const provider = collection.build();
const serviceInstance1 = provider.getService(serviceIdentifier);
const serviceInstance2 = provider.getService(serviceIdentifier);
expect(serviceInstance1).toBe(serviceInstance2);
});
it('should add a scoped service with a factory', () => {
class TestService {}
const serviceIdentifier = createServiceIdentifier();
const collection = new ServiceCollection();
collection.addScoped(serviceIdentifier, () => new TestService());
const provider = collection.build();
const scope1 = provider.createScope();
const scope2 = provider.createScope();
const serviceInstance1 = scope1.serviceProvider.getService(serviceIdentifier);
const serviceInstance2 = scope2.serviceProvider.getService(serviceIdentifier);
expect(serviceInstance1).not.toBe(serviceInstance2);
});
it('should add a transient service with a factory', () => {
class TestService {}
const serviceIdentifier = createServiceIdentifier();
const collection = new ServiceCollection();
collection.addTransient(serviceIdentifier, () => new TestService());
const provider = collection.build();
const serviceInstance1 = provider.getService(serviceIdentifier);
const serviceInstance2 = provider.getService(serviceIdentifier);
expect(serviceInstance1).not.toBe(serviceInstance2);
});
});
+17 -17
View File
@@ -1,11 +1,11 @@
import { describe, expect, it, test } from 'vitest';
import { describe, expect, it } from 'vitest';
import { injectable, ServiceProvider } from '../src';
import { createServiceIdentifier, ServiceLifetime } from '../src/types';
import { randomUUID } from 'crypto';
import { inject } from '../src';
describe('ServiceProvider', () => {
it('it should throw an error when service is not registered', () => {
it('should throw an error when service is not registered', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(new Map());
@@ -15,7 +15,7 @@ describe('ServiceProvider', () => {
}).toThrowError();
});
it('it should resolve singleton service', () => {
it('should resolve singleton service', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(
@@ -37,7 +37,7 @@ describe('ServiceProvider', () => {
expect(service1).toBe(service2);
});
it('it should resolve singleton service using factory', () => {
it('should resolve singleton service using factory', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(
@@ -60,7 +60,7 @@ describe('ServiceProvider', () => {
expect(service1).toBe(service2);
});
it('it should resolve scoped service', () => {
it('should resolve scoped service', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(
@@ -85,7 +85,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2);
});
it('it should resolve scoped service using factory', () => {
it('should resolve scoped service using factory', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(
@@ -111,7 +111,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2);
});
it('it should resolve transient service', () => {
it('should resolve transient service', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(
@@ -133,7 +133,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2);
});
it('it should resolve transient service using factory', () => {
it('should resolve transient service using factory', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(
@@ -156,7 +156,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2);
});
it('it should resolve same singleton instance across scopes', () => {
it('should resolve same singleton instance across scopes', () => {
type IService = {
id: string;
};
@@ -193,7 +193,7 @@ describe('ServiceProvider', () => {
expect(service1.id).toBe(service2.id);
});
it('it should resolve solve injected dependencies', () => {
it('should resolve solve injected dependencies', () => {
interface IUserRepository {
getUser(): string;
}
@@ -253,7 +253,7 @@ describe('ServiceProvider', () => {
expect(user).toBe('John Doe');
});
test('it should throw an error when service has invalid lifetime', () => {
it('should throw an error when service has invalid lifetime', () => {
type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>();
@@ -276,7 +276,7 @@ describe('ServiceProvider', () => {
}).toThrowError();
});
test('it should resolve same scoped instance in the same scope', () => {
it('should resolve same scoped instance in the same scope', () => {
type IService = {
id: string;
};
@@ -312,7 +312,7 @@ describe('ServiceProvider', () => {
expect(service1).toBe(service2);
});
test('it should clear the scoped instances on dispose from provider', () => {
it('should clear the scoped instances on dispose from provider', () => {
type IService = {
id: string;
};
@@ -349,7 +349,7 @@ describe('ServiceProvider', () => {
expect(initialService).not.toBe(newService);
});
test('it should clear the scoped instances on dispose from a scope', () => {
it('should clear the scoped instances on dispose from a scope', () => {
type IService = {
id: string;
};
@@ -388,7 +388,7 @@ describe('ServiceProvider', () => {
expect(initialService).not.toBe(newService);
});
test('it should not clear the scoped instances on dispose from a scope when the provider is disposed', () => {
it('should not clear the scoped instances on dispose from a scope when the provider is disposed', () => {
type IService = {
id: string;
};
@@ -427,7 +427,7 @@ describe('ServiceProvider', () => {
expect(initialService).toBe(newService);
});
test("it should not clear the scoped instances on dispose from another scope when one scope is disposed", () => {
it("should not clear the scoped instances on dispose from another scope when one scope is disposed", () => {
type IService = {
id: string;
};
@@ -470,7 +470,7 @@ describe('ServiceProvider', () => {
expect(initialService2).toBe(newService2);
});
test('it should throw error when dependency is not registered', () => {
it('should throw error when dependency is not registered', () => {
interface IUserRepository {
getUser(): string;
}