Ajouter @lightweight-sdk/service-registry (packages/common) #2

Merged
kyuno merged 4 commits from feat/service-registry-package into main 2026-09-18 20:56:44 +02:00
8 changed files with 109 additions and 144 deletions
Showing only changes of commit 7d11dd1426 - Show all commits

View file

@ -13,7 +13,9 @@ pnpm add @lightweight-sdk/service-registry
```ts ```ts
import { RegisterService, Service } from '@lightweight-sdk/service-registry'; import { RegisterService, Service } from '@lightweight-sdk/service-registry';
@RegisterService() export const LOGGER_UID = 'logger';
@RegisterService(LOGGER_UID)
class LoggerService { class LoggerService {
public log(message: string): void { public log(message: string): void {
console.log(message); console.log(message);
@ -21,7 +23,7 @@ class LoggerService {
} }
class ReportGenerator { class ReportGenerator {
@Service(LoggerService) @Service<LoggerService>(LOGGER_UID)
private readonly logger!: LoggerService; private readonly logger!: LoggerService;
public run(): void { public run(): void {
@ -32,18 +34,19 @@ class ReportGenerator {
## Concepts ## Concepts
- **`ServiceRepository`** — singleton central qui stocke une instance unique de chaque service, indexée par un uid généré automatiquement (`crypto.randomUUID()`) à l'enregistrement. Accès direct rarement nécessaire : `RegisterService`/`Service` couvrent l'usage courant. - **`ServiceRepository`** — singleton central qui stocke une instance unique de chaque service, indexée par l'uid choisi par le développeur à l'enregistrement. Accès direct rarement nécessaire : `RegisterService`/`Service` couvrent l'usage courant.
- **`@RegisterService()`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre dans le `ServiceRepository`. - **`@RegisterService(uid)`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre dans le `ServiceRepository` sous `uid`. **C'est au développeur de choisir `uid` et de garantir son unicité** — la registry refuse tout doublon.
- **`@Service(TargetClass)`** — décorateur de propriété. Injecte l'instance partagée de `TargetClass` dans le membre décoré. La résolution est **paresseuse** (au premier accès à la propriété, pas à la décoration) : peu importe que la classe consommatrice soit évaluée avant ou après l'enregistrement du service. Après le premier accès, la propriété est remplacée par la valeur résolue (pas de nouvelle recherche ensuite). - **`@Service<T>(uid)`** — décorateur de propriété. Injecte l'instance enregistrée sous `uid` dans le membre décoré. `uid` seul ne porte pas le type : annoter la propriété (ou passer le paramètre générique `<T>`) pour un typage correct. La résolution est **paresseuse** (au premier accès à la propriété, pas à la décoration) : peu importe que la classe consommatrice soit évaluée avant ou après l'enregistrement du service. Après le premier accès, la propriété est remplacée par la valeur résolue (pas de nouvelle recherche ensuite).
## Contraintes ## Contraintes
- Une classe décorée par `@RegisterService()` doit avoir un constructeur sans argument obligatoire — ses propres dépendances doivent elles-mêmes être injectées via `@Service`, pas passées au constructeur. - Une classe décorée par `@RegisterService(uid)` doit avoir un constructeur sans argument obligatoire — ses propres dépendances doivent elles-mêmes être injectées via `@Service`, pas passées au constructeur.
- `uid` doit être unique dans toute l'application — exporter une constante partagée (comme `LOGGER_UID` ci-dessus) entre le point d'enregistrement et les points d'injection évite les fautes de frappe.
- `experimentalDecorators: true` doit être activé côté consommateur (déjà le cas dans ce monorepo via `tsconfig.base.json`). - `experimentalDecorators: true` doit être activé côté consommateur (déjà le cas dans ce monorepo via `tsconfig.base.json`).
## API ## API
- `RegisterService()` — décorateur de classe. - `RegisterService(uid)` — décorateur de classe.
- `Service(target)` — décorateur de propriété. - `Service<T>(uid)` — décorateur de propriété.
- `ServiceRepository` — singleton (`getInstance()`, `registerClass`, `resolveClass`, `resolve`, `getUidForClass`, `hasClass`). `resetForTesting()` est réservé aux tests. - `ServiceRepository` — singleton (`getInstance()`, `register(uid, target)`, `resolve(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests.
- `ServiceConstructor<T>`, `ServiceUid` — types associés. - `ServiceConstructor<T>`, `ServiceUid` — types associés.

View file

@ -7,7 +7,7 @@ beforeEach(() => {
describe('public API (barrel export)', () => { describe('public API (barrel export)', () => {
it('registers and injects a service end to end through the public exports', () => { it('registers and injects a service end to end through the public exports', () => {
@RegisterService() @RegisterService('clock')
class ClockService { class ClockService {
public now(): number { public now(): number {
return 1234; return 1234;
@ -15,7 +15,7 @@ describe('public API (barrel export)', () => {
} }
class Consumer { class Consumer {
@Service(ClockService) @Service<ClockService>('clock')
public readonly clock!: ClockService; public readonly clock!: ClockService;
} }

View file

@ -7,8 +7,8 @@ beforeEach(() => {
}); });
describe('RegisterService', () => { describe('RegisterService', () => {
it('registers the decorated class in the global repository', () => { it('registers the decorated class under the given uid', () => {
@RegisterService() @RegisterService('greeter')
class GreeterService { class GreeterService {
public greet(name: string): string { public greet(name: string): string {
return `Hello, ${name}!`; return `Hello, ${name}!`;
@ -16,26 +16,27 @@ describe('RegisterService', () => {
} }
const repository = ServiceRepository.getInstance(); const repository = ServiceRepository.getInstance();
expect(repository.hasClass(GreeterService)).toBe(true); expect(repository.has('greeter')).toBe(true);
expect(repository.resolveClass(GreeterService)).toBeInstanceOf(GreeterService); expect(repository.resolve<GreeterService>('greeter')).toBeInstanceOf(GreeterService);
}); });
it('shares a single instance across every resolution', () => { it('shares a single instance across every resolution', () => {
@RegisterService() @RegisterService('state')
class StateService { class StateService {
public value = 0; public value = 0;
} }
const repository = ServiceRepository.getInstance(); const repository = ServiceRepository.getInstance();
repository.resolveClass(StateService).value = 42; repository.resolve<StateService>('state').value = 42;
expect(repository.resolveClass(StateService).value).toBe(42); expect(repository.resolve<StateService>('state').value).toBe(42);
}); });
it('rethrows if the class is registered twice', () => { it('rethrows if the uid is already registered', () => {
class DuplicateService {} class ServiceA {}
RegisterService()(DuplicateService); class ServiceB {}
RegisterService('duplicate')(ServiceA);
expect(() => RegisterService()(DuplicateService)).toThrow(/already registered/); expect(() => RegisterService('duplicate')(ServiceB)).toThrow(/already registered/);
}); });
}); });

View file

@ -1,19 +1,23 @@
import { ServiceRepository } from './service-repository'; import { ServiceRepository } from './service-repository';
import type { ServiceConstructor } from './types'; import type { ServiceConstructor, ServiceUid } from './types';
const LOG_PREFIX = '[lightweight-sdk:service-registry]'; const LOG_PREFIX = '[lightweight-sdk:service-registry]';
/** /**
* Class decorator that instantiates the decorated class and registers the * Class decorator that instantiates the decorated class and registers the
* resulting instance in the global {@link ServiceRepository}, making it * resulting instance in the global {@link ServiceRepository} under `uid`,
* resolvable from anywhere via {@link Service}. The decorated class must * making it resolvable from anywhere via {@link Service}. The caller picks
* have a no-argument constructor — any dependencies it needs should * `uid` and is responsible for keeping it unique across the application.
* themselves be injected with {@link Service} rather than passed as * The decorated class must have a no-argument constructor — any
* constructor parameters. * dependencies it needs should themselves be injected with {@link Service}
* rather than passed as constructor parameters.
* @param uid the unique identifier this service will be registered and resolved under
* @returns a class decorator * @returns a class decorator
* @example * @example
* ```ts * ```ts
* ⁣@RegisterService() * export const LOGGER_UID = 'logger';
*
* ⁣@RegisterService(LOGGER_UID)
* export class LoggerService { * export class LoggerService {
* public log(message: string): void { * public log(message: string): void {
* console.log(message); * console.log(message);
@ -21,12 +25,12 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
* } * }
* ``` * ```
*/ */
export function RegisterService(): <T>(target: ServiceConstructor<T>) => void { export function RegisterService(uid: ServiceUid): <T>(target: ServiceConstructor<T>) => void {
return <T>(target: ServiceConstructor<T>): void => { return <T>(target: ServiceConstructor<T>): void => {
try { try {
ServiceRepository.getInstance().registerClass(target); ServiceRepository.getInstance().register(uid, target);
} catch (error) { } catch (error) {
console.error(`${LOG_PREFIX} Failed to register service "${target.name}".`, error); console.error(`${LOG_PREFIX} Failed to register service "${uid}" (${target.name}).`, error);
throw error; throw error;
} }
}; };

View file

@ -14,57 +14,42 @@ describe('ServiceRepository', () => {
expect(ServiceRepository.getInstance()).toBe(ServiceRepository.getInstance()); expect(ServiceRepository.getInstance()).toBe(ServiceRepository.getInstance());
}); });
it('registers a class and resolves the same instance by class reference', () => { it('registers a class and resolves the same instance by its uid', () => {
const repository = ServiceRepository.getInstance(); const repository = ServiceRepository.getInstance();
repository.registerClass(Counter); repository.register('counter', Counter);
const first = repository.resolveClass(Counter); const first = repository.resolve<Counter>('counter');
const second = repository.resolveClass(Counter); const second = repository.resolve<Counter>('counter');
expect(first).toBeInstanceOf(Counter); expect(first).toBeInstanceOf(Counter);
expect(first).toBe(second); expect(first).toBe(second);
}); });
it('assigns a unique uid on registration and resolves the same instance by that uid', () => { it('reports whether a uid is registered', () => {
const repository = ServiceRepository.getInstance(); const repository = ServiceRepository.getInstance();
const uid = repository.registerClass(Counter); expect(repository.has('counter')).toBe(false);
repository.register('counter', Counter);
expect(typeof uid).toBe('string'); expect(repository.has('counter')).toBe(true);
expect(uid.length).toBeGreaterThan(0);
expect(repository.resolve(uid)).toBe(repository.resolveClass(Counter));
expect(repository.getUidForClass(Counter)).toBe(uid);
}); });
it('reports whether a class is registered', () => { it('throws when registering the same uid twice', () => {
const repository = ServiceRepository.getInstance(); const repository = ServiceRepository.getInstance();
repository.register('counter', Counter);
expect(repository.hasClass(Counter)).toBe(false); expect(() => repository.register('counter', Counter)).toThrow(/already registered/);
repository.registerClass(Counter);
expect(repository.hasClass(Counter)).toBe(true);
}); });
it('throws when registering the same class twice', () => { it('throws when resolving a uid that was never registered', () => {
const repository = ServiceRepository.getInstance();
repository.registerClass(Counter);
expect(() => repository.registerClass(Counter)).toThrow(/already registered/);
});
it('throws when resolving a class that was never registered', () => {
expect(() => ServiceRepository.getInstance().resolveClass(Counter)).toThrow(/not registered/);
});
it('throws when resolving an unknown uid', () => {
expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow( expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow(
/No service registered/, /No service registered/,
); );
}); });
it('starts fresh after resetForTesting', () => { it('starts fresh after resetForTesting', () => {
ServiceRepository.getInstance().registerClass(Counter); ServiceRepository.getInstance().register('counter', Counter);
ServiceRepository.resetForTesting(); ServiceRepository.resetForTesting();
expect(ServiceRepository.getInstance().hasClass(Counter)).toBe(false); expect(ServiceRepository.getInstance().has('counter')).toBe(false);
}); });
}); });

View file

@ -4,17 +4,16 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
/** /**
* Central singleton registry that holds the single shared instance of every * Central singleton registry that holds the single shared instance of every
* service registered via {@link RegisterService}, keyed by a unique id * service registered via {@link RegisterService}, keyed by the uid the
* generated at registration time. Consumers rarely call this class directly * developer assigned at registration time. Consumers rarely call this class
* — {@link RegisterService} and {@link Service} are the intended public API * directly — {@link RegisterService} and {@link Service} are the intended
* — but it is exported for advanced use cases (manual registration, uid * public API — but it is exported for advanced use cases (manual
* lookups, diagnostics). * registration, diagnostics).
*/ */
export class ServiceRepository { export class ServiceRepository {
private static instance: ServiceRepository | undefined; private static instance: ServiceRepository | undefined;
private instancesByUid = new Map<ServiceUid, unknown>(); private instances = new Map<ServiceUid, unknown>();
private uidsByConstructor = new WeakMap<ServiceConstructor, ServiceUid>();
/** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance}. */ /** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance}. */
private constructor() {} private constructor() {}
@ -41,75 +40,45 @@ export class ServiceRepository {
/** /**
* Instantiates `target` (its constructor must take no arguments) and * Instantiates `target` (its constructor must take no arguments) and
* registers the resulting instance under a freshly generated unique id. * registers the resulting instance under `uid`. Called by
* Called by {@link RegisterService} — most callers should use that * {@link RegisterService} — most callers should use that decorator
* decorator instead of calling this method directly. * instead of calling this method directly.
* @param uid the unique identifier the caller assigns to this service
* @param target the service class to instantiate and register * @param target the service class to instantiate and register
* @returns the uid assigned to this service * @throws {Error} if `uid` is already registered
* @throws {Error} if `target` is already registered
*/ */
public registerClass<T>(target: ServiceConstructor<T>): ServiceUid { public register<T>(uid: ServiceUid, target: ServiceConstructor<T>): void {
if (this.uidsByConstructor.has(target)) { if (this.instances.has(uid)) {
throw new Error(`Service "${target.name}" is already registered.`); throw new Error(`Service uid "${uid}" is already registered.`);
} }
const uid = globalThis.crypto.randomUUID();
const instance = new target(); const instance = new target();
this.instances.set(uid, instance);
this.instancesByUid.set(uid, instance); console.debug(`${LOG_PREFIX} Registered service "${uid}" (${target.name}).`);
this.uidsByConstructor.set(target, uid);
console.debug(`${LOG_PREFIX} Registered service "${target.name}" (${uid}).`);
return uid;
} }
/** /**
* Resolves the shared instance registered for `target`. Called by * Resolves the instance registered under `uid`. Called by {@link Service}
* {@link Service} — most callers should use that decorator instead of * — most callers should use that decorator instead of calling this
* calling this method directly. * method directly.
* @param target the service class previously registered via {@link RegisterService} * @param uid the service's unique identifier, as passed to {@link RegisterService}
* @returns the shared instance of `target`
* @throws {Error} if `target` was never registered
*/
public resolveClass<T>(target: ServiceConstructor<T>): T {
const uid = this.uidsByConstructor.get(target);
if (uid === undefined) {
throw new Error(
`Service "${target.name}" is not registered. Did you forget @RegisterService?`,
);
}
return this.resolve<T>(uid);
}
/**
* Resolves a registered service instance directly by its uid.
* @param uid the service's unique identifier, as returned by {@link ServiceRepository.registerClass}
* @returns the instance registered under `uid` * @returns the instance registered under `uid`
* @throws {Error} if no service is registered under `uid` * @throws {Error} if no service is registered under `uid`
*/ */
public resolve<T>(uid: ServiceUid): T { public resolve<T>(uid: ServiceUid): T {
if (!this.instancesByUid.has(uid)) { if (!this.instances.has(uid)) {
throw new Error(`No service registered with uid "${uid}".`); throw new Error(`No service registered with uid "${uid}". Did you forget @RegisterService?`);
} }
return this.instancesByUid.get(uid) as T; return this.instances.get(uid) as T;
} }
/** /**
* Looks up the uid assigned to a registered service class. * Checks whether a service is currently registered under `uid`.
* @param target the service class to look up * @param uid the unique identifier to check
* @returns the assigned uid, or `undefined` if `target` is not registered * @returns `true` if a service is registered under `uid`
*/ */
public getUidForClass(target: ServiceConstructor): ServiceUid | undefined { public has(uid: ServiceUid): boolean {
return this.uidsByConstructor.get(target); return this.instances.has(uid);
}
/**
* Checks whether a service class is currently registered.
* @param target the service class to check
* @returns `true` if `target` has been registered
*/
public hasClass(target: ServiceConstructor): boolean {
return this.uidsByConstructor.has(target);
} }
} }

View file

@ -8,8 +8,8 @@ beforeEach(() => {
}); });
describe('Service', () => { describe('Service', () => {
it('injects the shared instance registered for the target class', () => { it('injects the instance registered under the given uid', () => {
@RegisterService() @RegisterService('logger')
class LoggerService { class LoggerService {
public readonly messages: string[] = []; public readonly messages: string[] = [];
@ -19,7 +19,7 @@ describe('Service', () => {
} }
class ReportGenerator { class ReportGenerator {
@Service(LoggerService) @Service<LoggerService>('logger')
public readonly logger!: LoggerService; public readonly logger!: LoggerService;
} }
@ -27,34 +27,36 @@ describe('Service', () => {
generator.logger.log('hello'); generator.logger.log('hello');
expect(generator.logger).toBeInstanceOf(LoggerService); expect(generator.logger).toBeInstanceOf(LoggerService);
expect(ServiceRepository.getInstance().resolveClass(LoggerService).messages).toEqual(['hello']); expect(ServiceRepository.getInstance().resolve<LoggerService>('logger').messages).toEqual([
'hello',
]);
}); });
it('resolves lazily: decoration does not require the service to be registered yet', () => { it('resolves lazily: decoration does not require the uid to be registered yet', () => {
class LateService { class LateService {
public readonly ok = true; public readonly ok = true;
} }
class Consumer { class Consumer {
@Service(LateService) @Service<LateService>('late')
public readonly svc!: LateService; public readonly svc!: LateService;
} }
// Consumer (and its @Service decoration) already ran above, yet LateService // Consumer (and its @Service decoration) already ran above, yet "late"
// is only registered now — proves decoration itself never required it. // is only registered now — proves decoration itself never required it.
RegisterService()(LateService); RegisterService('late')(LateService);
expect(new Consumer().svc.ok).toBe(true); expect(new Consumer().svc.ok).toBe(true);
}); });
it('memoizes the resolved value on the instance after the first access', () => { it('memoizes the resolved value on the instance after the first access', () => {
@RegisterService() @RegisterService('logger')
class LoggerService { class LoggerService {
public log(_message: string): void {} public log(_message: string): void {}
} }
class Consumer { class Consumer {
@Service(LoggerService) @Service<LoggerService>('logger')
public readonly logger!: LoggerService; public readonly logger!: LoggerService;
} }
@ -68,13 +70,12 @@ describe('Service', () => {
expect(descriptor?.value).toBeInstanceOf(LoggerService); expect(descriptor?.value).toBeInstanceOf(LoggerService);
}); });
it('throws when the target service was never registered', () => { it('throws when the uid was never registered', () => {
class UnregisteredService {}
class Consumer { class Consumer {
@Service(UnregisteredService) @Service('unregistered')
public readonly dep!: UnregisteredService; public readonly dep!: unknown;
} }
expect(() => new Consumer().dep).toThrow(/not registered/); expect(() => new Consumer().dep).toThrow(/No service registered/);
}); });
}); });

View file

@ -1,12 +1,11 @@
import { ServiceRepository } from './service-repository'; import { ServiceRepository } from './service-repository';
import type { ServiceConstructor } from './types'; import type { ServiceUid } from './types';
const LOG_PREFIX = '[lightweight-sdk:service-registry]'; const LOG_PREFIX = '[lightweight-sdk:service-registry]';
/** /**
* Property decorator that injects the shared instance of `target` * Property decorator that injects the instance registered under `uid`
* (previously registered via {@link RegisterService}) into the decorated * (via {@link RegisterService}) into the decorated class member.
* class member.
* *
* Resolution is lazy and memoized: the service is looked up in the * Resolution is lazy and memoized: the service is looked up in the
* {@link ServiceRepository} the first time the property is read, not at * {@link ServiceRepository} the first time the property is read, not at
@ -14,12 +13,15 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
* between a service and its consumers. After the first read, the property * between a service and its consumers. After the first read, the property
* is replaced by the resolved value on the instance, so later accesses skip * is replaced by the resolved value on the instance, so later accesses skip
* the repository lookup entirely. * the repository lookup entirely.
* @param target the service class to inject, previously registered with {@link RegisterService} *
* `uid` alone does not carry the service's type — annotate the property (or
* pass an explicit type argument) so the member has the right type.
* @param uid the unique identifier passed to {@link RegisterService} for the target service
* @returns a property decorator * @returns a property decorator
* @example * @example
* ```ts * ```ts
* class ReportGenerator { * class ReportGenerator {
* ⁣@Service(LoggerService) * ⁣@Service<LoggerService>(LOGGER_UID)
* private readonly logger!: LoggerService; * private readonly logger!: LoggerService;
* *
* public run(): void { * public run(): void {
@ -29,7 +31,7 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
* ``` * ```
*/ */
export function Service<T>( export function Service<T>(
target: ServiceConstructor<T>, uid: ServiceUid,
): (prototype: object, propertyKey: string | symbol) => void { ): (prototype: object, propertyKey: string | symbol) => void {
return (prototype: object, propertyKey: string | symbol): void => { return (prototype: object, propertyKey: string | symbol): void => {
Object.defineProperty(prototype, propertyKey, { Object.defineProperty(prototype, propertyKey, {
@ -38,10 +40,10 @@ export function Service<T>(
get(this: object): T { get(this: object): T {
let resolved: T; let resolved: T;
try { try {
resolved = ServiceRepository.getInstance().resolveClass(target); resolved = ServiceRepository.getInstance().resolve<T>(uid);
} catch (error) { } catch (error) {
console.error( console.error(
`${LOG_PREFIX} Failed to inject service "${target.name}" into member "${String(propertyKey)}".`, `${LOG_PREFIX} Failed to inject service "${uid}" into member "${String(propertyKey)}".`,
error, error,
); );
throw error; throw error;