Simplifier : retirer le generique de @Service, ajouter services
All checks were successful
CI / build-lint-test (pull_request) Successful in 34s

- @Service(uid) n'a plus de parametre generique : un decorateur de
  propriete legacy ne peut de toute facon pas changer le type du champ
  qu'il decore, seule son annotation compte. Le generique n'apportait
  rien.
- Nouvelle constante exportee services = ServiceRepository.getInstance(),
  instance prete a l'emploi : services.get<T>(uid), services.has(uid),
  services.register(uid, Classe). C'est desormais le point d'entree
  recommande plutot que ServiceRepository.getInstance() directement.
- ServiceRepository.resolve() renomme en get() pour matcher
  services.get<T>(uid).
- resetForTesting() vide la map en place (Map.clear()) au lieu de
  jeter l'instance singleton, pour que la constante services capturee
  au chargement du module reste valide apres un reset dans les tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-18 14:31:42 +02:00
parent 7d11dd1426
commit 544614add7
9 changed files with 105 additions and 80 deletions

View file

@ -1,6 +1,6 @@
# @lightweight-sdk/service-registry # @lightweight-sdk/service-registry
Registre de services en singleton, avec enregistrement et injection par décorateurs. Package `common` : isomorphe, aucune dépendance DOM ou Node-only. Registre de services en singleton, avec enregistrement par décorateur et résolution directe. Package `common` : isomorphe, aucune dépendance DOM ou Node-only.
## Installation ## Installation
@ -11,7 +11,7 @@ pnpm add @lightweight-sdk/service-registry
## Usage ## Usage
```ts ```ts
import { RegisterService, Service } from '@lightweight-sdk/service-registry'; import { RegisterService, Service, services } from '@lightweight-sdk/service-registry';
export const LOGGER_UID = 'logger'; export const LOGGER_UID = 'logger';
@ -22,31 +22,38 @@ class LoggerService {
} }
} }
// Injection déclarative sur une propriété
class ReportGenerator { class ReportGenerator {
@Service<LoggerService>(LOGGER_UID) @Service(LOGGER_UID)
private readonly logger!: LoggerService; private readonly logger!: LoggerService;
public run(): void { public run(): void {
this.logger.log('generating report'); this.logger.log('generating report');
} }
} }
// Résolution directe, n'importe où
const logger = services.get<LoggerService>(LOGGER_UID);
logger.log('hello');
``` ```
## Concepts ## Concepts
- **`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. - **`services`** — constante globale, instance prête à l'emploi du `ServiceRepository`. C'est le point d'entrée à utiliser partout : `services.get<T>(uid)`, `services.has(uid)`, `services.register(uid, MaClasse)`.
- **`@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. - **`ServiceRepository`** — la classe singleton derrière `services` (accès direct rarement nécessaire, exportée pour le typage et les tests).
- **`@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). - **`@RegisterService(uid)`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre sous `uid`. **C'est au développeur de choisir `uid` et de garantir son unicité** — la registry refuse tout doublon.
- **`@Service(uid)`** — décorateur de propriété. Injecte l'instance enregistrée sous `uid` dans le membre décoré. Pas de typage générique sur le décorateur : un décorateur de propriété ne peut pas changer le type du champ qu'il décore, donc c'est l'annotation du champ (`private readonly logger!: LoggerService;`) qui le type, comme d'habitude. 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(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. - 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` ou `services.get`, 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. - `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
- `services` — instance singleton prête à l'emploi (`get<T>(uid)`, `has(uid)`, `register(uid, target)`).
- `RegisterService(uid)` — décorateur de classe. - `RegisterService(uid)` — décorateur de classe.
- `Service<T>(uid)` — décorateur de propriété. - `Service(uid)` — décorateur de propriété.
- `ServiceRepository` — singleton (`getInstance()`, `register(uid, target)`, `resolve(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests. - `ServiceRepository` — la classe singleton (`getInstance()`, `register(uid, target)`, `get<T>(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests.
- `ServiceConstructor<T>`, `ServiceUid` — types associés. - `ServiceConstructor<T>`, `ServiceUid` — types associés.

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { RegisterService, Service, ServiceRepository } from './index'; import { RegisterService, Service, ServiceRepository, services } from './index';
beforeEach(() => { beforeEach(() => {
ServiceRepository.resetForTesting(); ServiceRepository.resetForTesting();
@ -15,10 +15,21 @@ describe('public API (barrel export)', () => {
} }
class Consumer { class Consumer {
@Service<ClockService>('clock') @Service('clock')
public readonly clock!: ClockService; public readonly clock!: ClockService;
} }
expect(new Consumer().clock.now()).toBe(1234); expect(new Consumer().clock.now()).toBe(1234);
}); });
it('resolves a service directly through the services constant', () => {
@RegisterService('clock')
class ClockService {
public now(): number {
return 5678;
}
}
expect(services.get<ClockService>('clock').now()).toBe(5678);
});
}); });

View file

@ -1,4 +1,4 @@
export { RegisterService } from './register-service.decorator'; export { RegisterService } from './register-service.decorator';
export { Service } from './service.decorator'; export { Service } from './service.decorator';
export { ServiceRepository } from './service-repository'; export { ServiceRepository, services } from './service-repository';
export { ServiceConstructor, ServiceUid } from './types'; export { ServiceConstructor, ServiceUid } from './types';

View file

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { RegisterService } from './register-service.decorator'; import { RegisterService } from './register-service.decorator';
import { ServiceRepository } from './service-repository'; import { ServiceRepository, services } from './service-repository';
beforeEach(() => { beforeEach(() => {
ServiceRepository.resetForTesting(); ServiceRepository.resetForTesting();
@ -15,9 +15,8 @@ describe('RegisterService', () => {
} }
} }
const repository = ServiceRepository.getInstance(); expect(services.has('greeter')).toBe(true);
expect(repository.has('greeter')).toBe(true); expect(services.get<GreeterService>('greeter')).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', () => {
@ -26,10 +25,9 @@ describe('RegisterService', () => {
public value = 0; public value = 0;
} }
const repository = ServiceRepository.getInstance(); services.get<StateService>('state').value = 42;
repository.resolve<StateService>('state').value = 42;
expect(repository.resolve<StateService>('state').value).toBe(42); expect(services.get<StateService>('state').value).toBe(42);
}); });
it('rethrows if the uid is already registered', () => { it('rethrows if the uid is already registered', () => {

View file

@ -1,12 +1,12 @@
import { ServiceRepository } from './service-repository'; import { services } from './service-repository';
import type { ServiceConstructor, ServiceUid } 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} under `uid`, * resulting instance in {@link services} under `uid`, making it resolvable
* making it resolvable from anywhere via {@link Service}. The caller picks * from anywhere via `services.get(uid)` or {@link Service}. The caller picks
* `uid` and is responsible for keeping it unique across the application. * `uid` and is responsible for keeping it unique across the application.
* The decorated class must have a no-argument constructor — any * The decorated class must have a no-argument constructor — any
* dependencies it needs should themselves be injected with {@link Service} * dependencies it needs should themselves be injected with {@link Service}
@ -28,7 +28,7 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
export function RegisterService(uid: ServiceUid): <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().register(uid, target); services.register(uid, target);
} catch (error) { } catch (error) {
console.error(`${LOG_PREFIX} Failed to register service "${uid}" (${target.name}).`, error); console.error(`${LOG_PREFIX} Failed to register service "${uid}" (${target.name}).`, error);
throw error; throw error;

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { ServiceRepository } from './service-repository'; import { ServiceRepository, services } from './service-repository';
class Counter { class Counter {
public value = 0; public value = 0;
@ -10,46 +10,41 @@ beforeEach(() => {
}); });
describe('ServiceRepository', () => { describe('ServiceRepository', () => {
it('returns the same instance on every call to getInstance', () => { it('exposes a single instance via getInstance, matching the services constant', () => {
expect(ServiceRepository.getInstance()).toBe(ServiceRepository.getInstance()); expect(ServiceRepository.getInstance()).toBe(ServiceRepository.getInstance());
expect(ServiceRepository.getInstance()).toBe(services);
}); });
it('registers a class and resolves the same instance by its uid', () => { it('registers a class and resolves the same instance by its uid', () => {
const repository = ServiceRepository.getInstance(); services.register('counter', Counter);
const first = services.get<Counter>('counter');
repository.register('counter', Counter); const second = services.get<Counter>('counter');
const first = repository.resolve<Counter>('counter');
const second = repository.resolve<Counter>('counter');
expect(first).toBeInstanceOf(Counter); expect(first).toBeInstanceOf(Counter);
expect(first).toBe(second); expect(first).toBe(second);
}); });
it('reports whether a uid is registered', () => { it('reports whether a uid is registered', () => {
const repository = ServiceRepository.getInstance(); expect(services.has('counter')).toBe(false);
services.register('counter', Counter);
expect(repository.has('counter')).toBe(false); expect(services.has('counter')).toBe(true);
repository.register('counter', Counter);
expect(repository.has('counter')).toBe(true);
}); });
it('throws when registering the same uid twice', () => { it('throws when registering the same uid twice', () => {
const repository = ServiceRepository.getInstance(); services.register('counter', Counter);
repository.register('counter', Counter);
expect(() => repository.register('counter', Counter)).toThrow(/already registered/); expect(() => services.register('counter', Counter)).toThrow(/already registered/);
}); });
it('throws when resolving a uid that was never registered', () => { it('throws when resolving a uid that was never registered', () => {
expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow( expect(() => services.get('does-not-exist')).toThrow(/No service registered/);
/No service registered/,
);
}); });
it('starts fresh after resetForTesting', () => { it('clears registered services after resetForTesting, without discarding the singleton', () => {
ServiceRepository.getInstance().register('counter', Counter); services.register('counter', Counter);
ServiceRepository.resetForTesting(); ServiceRepository.resetForTesting();
expect(ServiceRepository.getInstance().has('counter')).toBe(false); expect(services.has('counter')).toBe(false);
expect(ServiceRepository.getInstance()).toBe(services);
}); });
}); });

View file

@ -5,17 +5,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 the uid the * service registered via {@link RegisterService}, keyed by the uid the
* developer assigned at registration time. Consumers rarely call this class * developer assigned at registration time. Prefer the ready-to-use
* directly — {@link RegisterService} and {@link Service} are the intended * {@link services} constant over calling {@link ServiceRepository.getInstance}
* public API — but it is exported for advanced use cases (manual * directly.
* registration, diagnostics).
*/ */
export class ServiceRepository { export class ServiceRepository {
private static instance: ServiceRepository | undefined; private static instance: ServiceRepository | undefined;
private instances = new Map<ServiceUid, unknown>(); private instances = new Map<ServiceUid, unknown>();
/** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance}. */ /** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance} or {@link services}. */
private constructor() {} private constructor() {}
/** /**
@ -30,12 +29,13 @@ export class ServiceRepository {
} }
/** /**
* Discards the singleton instance so the next {@link ServiceRepository.getInstance} * Clears every registered service. The singleton instance itself is kept
* call starts from an empty registry. Intended for resetting state between * (not discarded), so the {@link services} constant stays valid across
* test cases — application code should not call this. * resets. Intended for resetting state between test cases — application
* code should not call this.
*/ */
public static resetForTesting(): void { public static resetForTesting(): void {
ServiceRepository.instance = undefined; ServiceRepository.getInstance().instances.clear();
} }
/** /**
@ -59,14 +59,16 @@ export class ServiceRepository {
} }
/** /**
* Resolves the instance registered under `uid`. Called by {@link Service} * Resolves the instance registered under `uid`.
* — most callers should use that decorator instead of calling this
* method directly.
* @param uid the service's unique identifier, as passed to {@link RegisterService} * @param uid the service's unique identifier, as passed to {@link RegisterService}
* @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`
* @example
* ```ts
* const logger = services.get<LoggerService>(LOGGER_UID);
* ```
*/ */
public resolve<T>(uid: ServiceUid): T { public get<T>(uid: ServiceUid): T {
if (!this.instances.has(uid)) { if (!this.instances.has(uid)) {
throw new Error(`No service registered with uid "${uid}". Did you forget @RegisterService?`); throw new Error(`No service registered with uid "${uid}". Did you forget @RegisterService?`);
} }
@ -82,3 +84,16 @@ export class ServiceRepository {
return this.instances.has(uid); return this.instances.has(uid);
} }
} }
/**
* Ready-to-use singleton instance of {@link ServiceRepository}. Import this
* wherever a service needs to be registered or resolved, instead of calling
* {@link ServiceRepository.getInstance} directly.
* @example
* ```ts
* import { services } from '@lightweight-sdk/service-registry';
*
* const logger = services.get<LoggerService>(LOGGER_UID);
* ```
*/
export const services = ServiceRepository.getInstance();

View file

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { RegisterService } from './register-service.decorator'; import { RegisterService } from './register-service.decorator';
import { Service } from './service.decorator'; import { Service } from './service.decorator';
import { ServiceRepository } from './service-repository'; import { ServiceRepository, services } from './service-repository';
beforeEach(() => { beforeEach(() => {
ServiceRepository.resetForTesting(); ServiceRepository.resetForTesting();
@ -19,7 +19,7 @@ describe('Service', () => {
} }
class ReportGenerator { class ReportGenerator {
@Service<LoggerService>('logger') @Service('logger')
public readonly logger!: LoggerService; public readonly logger!: LoggerService;
} }
@ -27,9 +27,7 @@ describe('Service', () => {
generator.logger.log('hello'); generator.logger.log('hello');
expect(generator.logger).toBeInstanceOf(LoggerService); expect(generator.logger).toBeInstanceOf(LoggerService);
expect(ServiceRepository.getInstance().resolve<LoggerService>('logger').messages).toEqual([ expect(services.get<LoggerService>('logger').messages).toEqual(['hello']);
'hello',
]);
}); });
it('resolves lazily: decoration does not require the uid to be registered yet', () => { it('resolves lazily: decoration does not require the uid to be registered yet', () => {
@ -38,7 +36,7 @@ describe('Service', () => {
} }
class Consumer { class Consumer {
@Service<LateService>('late') @Service('late')
public readonly svc!: LateService; public readonly svc!: LateService;
} }
@ -56,7 +54,7 @@ describe('Service', () => {
} }
class Consumer { class Consumer {
@Service<LoggerService>('logger') @Service('logger')
public readonly logger!: LoggerService; public readonly logger!: LoggerService;
} }

View file

@ -1,27 +1,28 @@
import { ServiceRepository } from './service-repository'; import { services } from './service-repository';
import type { ServiceUid } 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 instance registered under `uid` * Property decorator that injects the instance registered under `uid`
* (via {@link RegisterService}) into the decorated class member. * (via {@link RegisterService}) into the decorated class member. No generic
* type parameter is needed here: a legacy property decorator cannot change
* the type of the field it decorates, so the field's own type annotation
* (`private readonly logger!: LoggerService;`) is what types the member —
* annotate it as usual.
* *
* Resolution is lazy and memoized: the service is looked up in the * Resolution is lazy and memoized: the service is looked up in
* {@link ServiceRepository} the first time the property is read, not at * {@link services} the first time the property is read, not at decoration
* decoration time — this avoids depending on module evaluation order * time — this avoids depending on module evaluation order between a service
* between a service and its consumers. After the first read, the property * and its consumers. After the first read, the property is replaced by the
* is replaced by the resolved value on the instance, so later accesses skip * resolved value on the instance, so later accesses skip the repository
* the repository lookup entirely. * lookup entirely.
*
* `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 * @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>(LOGGER_UID) * ⁣@Service(LOGGER_UID)
* private readonly logger!: LoggerService; * private readonly logger!: LoggerService;
* *
* public run(): void { * public run(): void {
@ -30,17 +31,17 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
* } * }
* ``` * ```
*/ */
export function Service<T>( export function Service(
uid: ServiceUid, 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, {
configurable: true, configurable: true,
enumerable: true, enumerable: true,
get(this: object): T { get(this: object): unknown {
let resolved: T; let resolved: unknown;
try { try {
resolved = ServiceRepository.getInstance().resolve<T>(uid); resolved = services.get(uid);
} catch (error) { } catch (error) {
console.error( console.error(
`${LOG_PREFIX} Failed to inject service "${uid}" into member "${String(propertyKey)}".`, `${LOG_PREFIX} Failed to inject service "${uid}" into member "${String(propertyKey)}".`,