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 './serviceProvider.js';
export * from './types.js'; export * from './types.js';
export * from './decorators.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 { export class ServiceProvider implements IServiceProvider {
private descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>>; private readonly _descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>>;
private singletonInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map(); private readonly _singletonInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map();
private scopedInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map(); private readonly _scopedInstances: Map<ServiceIdentifier<unknown>, unknown> = new Map();
constructor( constructor(
descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>>, descriptors: Map<ServiceIdentifier<unknown>, ServiceDescriptor<unknown>>,
parent?: ServiceProvider, parent?: ServiceProvider,
) { ) {
this.descriptors = descriptors; this._descriptors = descriptors;
if (parent) { if (parent) {
parent.singletonInstances.forEach((value, key) => { parent._singletonInstances.forEach((value, key) => {
this.singletonInstances.set(key, value); this._singletonInstances.set(key, value);
}); });
} }
@@ -43,7 +43,7 @@ export class ServiceProvider implements IServiceProvider {
} }
getService<T>(serviceType: ServiceIdentifier<T>): T { getService<T>(serviceType: ServiceIdentifier<T>): T {
const descriptor = this.descriptors.get(serviceType); const descriptor = this._descriptors.get(serviceType);
if (!descriptor) { if (!descriptor) {
throw new Error(`Service of type ${serviceType.toString()} is not registered.`); throw new Error(`Service of type ${serviceType.toString()} is not registered.`);
@@ -53,12 +53,12 @@ export class ServiceProvider implements IServiceProvider {
} }
createScope(): IServiceScope { createScope(): IServiceScope {
const scopedProvider = new ServiceProvider(this.descriptors, this); const scopedProvider = new ServiceProvider(this._descriptors, this);
return new ServiceScope(scopedProvider); return new ServiceScope(scopedProvider);
} }
dispose(): void { dispose(): void {
this.scopedInstances.clear(); this._scopedInstances.clear();
} }
private resolveService<T>(descriptor: ServiceDescriptor<T>): T { private resolveService<T>(descriptor: ServiceDescriptor<T>): T {
@@ -66,19 +66,19 @@ export class ServiceProvider implements IServiceProvider {
switch (lifetime) { switch (lifetime) {
case 'singleton': { case 'singleton': {
if (this.singletonInstances.has(serviceType)) { if (this._singletonInstances.has(serviceType)) {
return this.singletonInstances.get(serviceType) as T; return this._singletonInstances.get(serviceType) as T;
} }
const singletonInstance = factory ? factory(this) : this.createInstance(implementationType); const singletonInstance = factory ? factory(this) : this.createInstance(implementationType);
this.singletonInstances.set(serviceType, singletonInstance); this._singletonInstances.set(serviceType, singletonInstance);
return singletonInstance; return singletonInstance;
} }
case 'scoped': { case 'scoped': {
if (this.scopedInstances.has(serviceType)) { if (this._scopedInstances.has(serviceType)) {
return this.scopedInstances.get(serviceType) as T; return this._scopedInstances.get(serviceType) as T;
} }
const scopedInstance = factory ? factory(this) : this.createInstance(implementationType); const scopedInstance = factory ? factory(this) : this.createInstance(implementationType);
this.scopedInstances.set(serviceType, scopedInstance); this._scopedInstances.set(serviceType, scopedInstance);
return scopedInstance; return scopedInstance;
} }
case 'transient': case 'transient':
+1 -1
View File
@@ -25,7 +25,7 @@ export interface IServiceCollection {
addScoped<T>(serviceType: ServiceIdentifier<T>, factory: ServiceFactory<T>): IServiceCollection; addScoped<T>(serviceType: ServiceIdentifier<T>, factory: ServiceFactory<T>): IServiceCollection;
addTransient<T>(serviceType: ServiceIdentifier<T>, implementationType: Constructor<T>): IServiceCollection; addTransient<T>(serviceType: ServiceIdentifier<T>, implementationType: Constructor<T>): IServiceCollection;
addTransient<T>(serviceType: ServiceIdentifier<T>, factory: ServiceFactory<T>): IServiceCollection; addTransient<T>(serviceType: ServiceIdentifier<T>, factory: ServiceFactory<T>): IServiceCollection;
buildServiceProvider(): IServiceProvider; build(): IServiceProvider;
} }
export interface 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 { injectable, ServiceProvider } from '../src';
import { createServiceIdentifier, ServiceLifetime } from '../src/types'; import { createServiceIdentifier, ServiceLifetime } from '../src/types';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { inject } from '../src'; import { inject } from '../src';
describe('ServiceProvider', () => { 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; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider(new Map()); const serviceProvider = new ServiceProvider(new Map());
@@ -15,7 +15,7 @@ describe('ServiceProvider', () => {
}).toThrowError(); }).toThrowError();
}); });
it('it should resolve singleton service', () => { it('should resolve singleton service', () => {
type IService = object; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider( const serviceProvider = new ServiceProvider(
@@ -37,7 +37,7 @@ describe('ServiceProvider', () => {
expect(service1).toBe(service2); expect(service1).toBe(service2);
}); });
it('it should resolve singleton service using factory', () => { it('should resolve singleton service using factory', () => {
type IService = object; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider( const serviceProvider = new ServiceProvider(
@@ -60,7 +60,7 @@ describe('ServiceProvider', () => {
expect(service1).toBe(service2); expect(service1).toBe(service2);
}); });
it('it should resolve scoped service', () => { it('should resolve scoped service', () => {
type IService = object; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider( const serviceProvider = new ServiceProvider(
@@ -85,7 +85,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2); expect(service1).not.toBe(service2);
}); });
it('it should resolve scoped service using factory', () => { it('should resolve scoped service using factory', () => {
type IService = object; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider( const serviceProvider = new ServiceProvider(
@@ -111,7 +111,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2); expect(service1).not.toBe(service2);
}); });
it('it should resolve transient service', () => { it('should resolve transient service', () => {
type IService = object; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider( const serviceProvider = new ServiceProvider(
@@ -133,7 +133,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2); expect(service1).not.toBe(service2);
}); });
it('it should resolve transient service using factory', () => { it('should resolve transient service using factory', () => {
type IService = object; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
const serviceProvider = new ServiceProvider( const serviceProvider = new ServiceProvider(
@@ -156,7 +156,7 @@ describe('ServiceProvider', () => {
expect(service1).not.toBe(service2); expect(service1).not.toBe(service2);
}); });
it('it should resolve same singleton instance across scopes', () => { it('should resolve same singleton instance across scopes', () => {
type IService = { type IService = {
id: string; id: string;
}; };
@@ -193,7 +193,7 @@ describe('ServiceProvider', () => {
expect(service1.id).toBe(service2.id); expect(service1.id).toBe(service2.id);
}); });
it('it should resolve solve injected dependencies', () => { it('should resolve solve injected dependencies', () => {
interface IUserRepository { interface IUserRepository {
getUser(): string; getUser(): string;
} }
@@ -253,7 +253,7 @@ describe('ServiceProvider', () => {
expect(user).toBe('John Doe'); 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; type IService = object;
const serviceIdentifier = createServiceIdentifier<IService>(); const serviceIdentifier = createServiceIdentifier<IService>();
@@ -276,7 +276,7 @@ describe('ServiceProvider', () => {
}).toThrowError(); }).toThrowError();
}); });
test('it should resolve same scoped instance in the same scope', () => { it('should resolve same scoped instance in the same scope', () => {
type IService = { type IService = {
id: string; id: string;
}; };
@@ -312,7 +312,7 @@ describe('ServiceProvider', () => {
expect(service1).toBe(service2); 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 = { type IService = {
id: string; id: string;
}; };
@@ -349,7 +349,7 @@ describe('ServiceProvider', () => {
expect(initialService).not.toBe(newService); 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 = { type IService = {
id: string; id: string;
}; };
@@ -388,7 +388,7 @@ describe('ServiceProvider', () => {
expect(initialService).not.toBe(newService); 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 = { type IService = {
id: string; id: string;
}; };
@@ -427,7 +427,7 @@ describe('ServiceProvider', () => {
expect(initialService).toBe(newService); 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 = { type IService = {
id: string; id: string;
}; };
@@ -470,7 +470,7 @@ describe('ServiceProvider', () => {
expect(initialService2).toBe(newService2); 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 { interface IUserRepository {
getUser(): string; getUser(): string;
} }