Ajouter @lightweight-sdk/service-registry (packages/common) #2
9 changed files with 105 additions and 80 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# @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
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ pnpm add @lightweight-sdk/service-registry
|
|||
## Usage
|
||||
|
||||
```ts
|
||||
import { RegisterService, Service } from '@lightweight-sdk/service-registry';
|
||||
import { RegisterService, Service, services } from '@lightweight-sdk/service-registry';
|
||||
|
||||
export const LOGGER_UID = 'logger';
|
||||
|
||||
|
|
@ -22,31 +22,38 @@ class LoggerService {
|
|||
}
|
||||
}
|
||||
|
||||
// Injection déclarative sur une propriété
|
||||
class ReportGenerator {
|
||||
@Service<LoggerService>(LOGGER_UID)
|
||||
@Service(LOGGER_UID)
|
||||
private readonly logger!: LoggerService;
|
||||
|
||||
public run(): void {
|
||||
this.logger.log('generating report');
|
||||
}
|
||||
}
|
||||
|
||||
// Résolution directe, n'importe où
|
||||
const logger = services.get<LoggerService>(LOGGER_UID);
|
||||
logger.log('hello');
|
||||
```
|
||||
|
||||
## 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.
|
||||
- **`@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<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).
|
||||
- **`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)`.
|
||||
- **`ServiceRepository`** — la classe singleton derrière `services` (accès direct rarement nécessaire, exportée pour le typage et les tests).
|
||||
- **`@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
|
||||
|
||||
- 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.
|
||||
- `experimentalDecorators: true` doit être activé côté consommateur (déjà le cas dans ce monorepo via `tsconfig.base.json`).
|
||||
|
||||
## API
|
||||
|
||||
- `services` — instance singleton prête à l'emploi (`get<T>(uid)`, `has(uid)`, `register(uid, target)`).
|
||||
- `RegisterService(uid)` — décorateur de classe.
|
||||
- `Service<T>(uid)` — décorateur de propriété.
|
||||
- `ServiceRepository` — singleton (`getInstance()`, `register(uid, target)`, `resolve(uid)`, `has(uid)`). `resetForTesting()` est réservé aux tests.
|
||||
- `Service(uid)` — décorateur de propriété.
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { RegisterService, Service, ServiceRepository } from './index';
|
||||
import { RegisterService, Service, ServiceRepository, services } from './index';
|
||||
|
||||
beforeEach(() => {
|
||||
ServiceRepository.resetForTesting();
|
||||
|
|
@ -15,10 +15,21 @@ describe('public API (barrel export)', () => {
|
|||
}
|
||||
|
||||
class Consumer {
|
||||
@Service<ClockService>('clock')
|
||||
@Service('clock')
|
||||
public readonly clock!: ClockService;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { RegisterService } from './register-service.decorator';
|
||||
export { Service } from './service.decorator';
|
||||
export { ServiceRepository } from './service-repository';
|
||||
export { ServiceRepository, services } from './service-repository';
|
||||
export { ServiceConstructor, ServiceUid } from './types';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { RegisterService } from './register-service.decorator';
|
||||
import { ServiceRepository } from './service-repository';
|
||||
import { ServiceRepository, services } from './service-repository';
|
||||
|
||||
beforeEach(() => {
|
||||
ServiceRepository.resetForTesting();
|
||||
|
|
@ -15,9 +15,8 @@ describe('RegisterService', () => {
|
|||
}
|
||||
}
|
||||
|
||||
const repository = ServiceRepository.getInstance();
|
||||
expect(repository.has('greeter')).toBe(true);
|
||||
expect(repository.resolve<GreeterService>('greeter')).toBeInstanceOf(GreeterService);
|
||||
expect(services.has('greeter')).toBe(true);
|
||||
expect(services.get<GreeterService>('greeter')).toBeInstanceOf(GreeterService);
|
||||
});
|
||||
|
||||
it('shares a single instance across every resolution', () => {
|
||||
|
|
@ -26,10 +25,9 @@ describe('RegisterService', () => {
|
|||
public value = 0;
|
||||
}
|
||||
|
||||
const repository = ServiceRepository.getInstance();
|
||||
repository.resolve<StateService>('state').value = 42;
|
||||
services.get<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', () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { ServiceRepository } from './service-repository';
|
||||
import { services } from './service-repository';
|
||||
import type { ServiceConstructor, ServiceUid } from './types';
|
||||
|
||||
const LOG_PREFIX = '[lightweight-sdk:service-registry]';
|
||||
|
||||
/**
|
||||
* Class decorator that instantiates the decorated class and registers the
|
||||
* resulting instance in the global {@link ServiceRepository} under `uid`,
|
||||
* making it resolvable from anywhere via {@link Service}. The caller picks
|
||||
* resulting instance in {@link services} under `uid`, making it resolvable
|
||||
* from anywhere via `services.get(uid)` or {@link Service}. The caller picks
|
||||
* `uid` and is responsible for keeping it unique across the application.
|
||||
* The decorated class must have a no-argument constructor — any
|
||||
* 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 {
|
||||
return <T>(target: ServiceConstructor<T>): void => {
|
||||
try {
|
||||
ServiceRepository.getInstance().register(uid, target);
|
||||
services.register(uid, target);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Failed to register service "${uid}" (${target.name}).`, error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ServiceRepository } from './service-repository';
|
||||
import { ServiceRepository, services } from './service-repository';
|
||||
|
||||
class Counter {
|
||||
public value = 0;
|
||||
|
|
@ -10,46 +10,41 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
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(services);
|
||||
});
|
||||
|
||||
it('registers a class and resolves the same instance by its uid', () => {
|
||||
const repository = ServiceRepository.getInstance();
|
||||
|
||||
repository.register('counter', Counter);
|
||||
const first = repository.resolve<Counter>('counter');
|
||||
const second = repository.resolve<Counter>('counter');
|
||||
services.register('counter', Counter);
|
||||
const first = services.get<Counter>('counter');
|
||||
const second = services.get<Counter>('counter');
|
||||
|
||||
expect(first).toBeInstanceOf(Counter);
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('reports whether a uid is registered', () => {
|
||||
const repository = ServiceRepository.getInstance();
|
||||
|
||||
expect(repository.has('counter')).toBe(false);
|
||||
repository.register('counter', Counter);
|
||||
expect(repository.has('counter')).toBe(true);
|
||||
expect(services.has('counter')).toBe(false);
|
||||
services.register('counter', Counter);
|
||||
expect(services.has('counter')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when registering the same uid twice', () => {
|
||||
const repository = ServiceRepository.getInstance();
|
||||
repository.register('counter', Counter);
|
||||
services.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', () => {
|
||||
expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow(
|
||||
/No service registered/,
|
||||
);
|
||||
expect(() => services.get('does-not-exist')).toThrow(/No service registered/);
|
||||
});
|
||||
|
||||
it('starts fresh after resetForTesting', () => {
|
||||
ServiceRepository.getInstance().register('counter', Counter);
|
||||
it('clears registered services after resetForTesting, without discarding the singleton', () => {
|
||||
services.register('counter', Counter);
|
||||
ServiceRepository.resetForTesting();
|
||||
|
||||
expect(ServiceRepository.getInstance().has('counter')).toBe(false);
|
||||
expect(services.has('counter')).toBe(false);
|
||||
expect(ServiceRepository.getInstance()).toBe(services);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,17 +5,16 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
|
|||
/**
|
||||
* Central singleton registry that holds the single shared instance of every
|
||||
* service registered via {@link RegisterService}, keyed by the uid the
|
||||
* developer assigned at registration time. Consumers rarely call this class
|
||||
* directly — {@link RegisterService} and {@link Service} are the intended
|
||||
* public API — but it is exported for advanced use cases (manual
|
||||
* registration, diagnostics).
|
||||
* developer assigned at registration time. Prefer the ready-to-use
|
||||
* {@link services} constant over calling {@link ServiceRepository.getInstance}
|
||||
* directly.
|
||||
*/
|
||||
export class ServiceRepository {
|
||||
private static instance: ServiceRepository | undefined;
|
||||
|
||||
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() {}
|
||||
|
||||
/**
|
||||
|
|
@ -30,12 +29,13 @@ export class ServiceRepository {
|
|||
}
|
||||
|
||||
/**
|
||||
* Discards the singleton instance so the next {@link ServiceRepository.getInstance}
|
||||
* call starts from an empty registry. Intended for resetting state between
|
||||
* test cases — application code should not call this.
|
||||
* Clears every registered service. The singleton instance itself is kept
|
||||
* (not discarded), so the {@link services} constant stays valid across
|
||||
* resets. Intended for resetting state between test cases — application
|
||||
* code should not call this.
|
||||
*/
|
||||
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}
|
||||
* — most callers should use that decorator instead of calling this
|
||||
* method directly.
|
||||
* Resolves the instance registered under `uid`.
|
||||
* @param uid the service's unique identifier, as passed to {@link RegisterService}
|
||||
* @returns the instance 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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { RegisterService } from './register-service.decorator';
|
||||
import { Service } from './service.decorator';
|
||||
import { ServiceRepository } from './service-repository';
|
||||
import { ServiceRepository, services } from './service-repository';
|
||||
|
||||
beforeEach(() => {
|
||||
ServiceRepository.resetForTesting();
|
||||
|
|
@ -19,7 +19,7 @@ describe('Service', () => {
|
|||
}
|
||||
|
||||
class ReportGenerator {
|
||||
@Service<LoggerService>('logger')
|
||||
@Service('logger')
|
||||
public readonly logger!: LoggerService;
|
||||
}
|
||||
|
||||
|
|
@ -27,9 +27,7 @@ describe('Service', () => {
|
|||
generator.logger.log('hello');
|
||||
|
||||
expect(generator.logger).toBeInstanceOf(LoggerService);
|
||||
expect(ServiceRepository.getInstance().resolve<LoggerService>('logger').messages).toEqual([
|
||||
'hello',
|
||||
]);
|
||||
expect(services.get<LoggerService>('logger').messages).toEqual(['hello']);
|
||||
});
|
||||
|
||||
it('resolves lazily: decoration does not require the uid to be registered yet', () => {
|
||||
|
|
@ -38,7 +36,7 @@ describe('Service', () => {
|
|||
}
|
||||
|
||||
class Consumer {
|
||||
@Service<LateService>('late')
|
||||
@Service('late')
|
||||
public readonly svc!: LateService;
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +54,7 @@ describe('Service', () => {
|
|||
}
|
||||
|
||||
class Consumer {
|
||||
@Service<LoggerService>('logger')
|
||||
@Service('logger')
|
||||
public readonly logger!: LoggerService;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,28 @@
|
|||
import { ServiceRepository } from './service-repository';
|
||||
import { services } from './service-repository';
|
||||
import type { ServiceUid } from './types';
|
||||
|
||||
const LOG_PREFIX = '[lightweight-sdk:service-registry]';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* {@link ServiceRepository} the first time the property is read, not at
|
||||
* decoration time — this avoids depending on module evaluation order
|
||||
* 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
|
||||
* the repository 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.
|
||||
* Resolution is lazy and memoized: the service is looked up in
|
||||
* {@link services} the first time the property is read, not at decoration
|
||||
* time — this avoids depending on module evaluation order 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 the repository
|
||||
* lookup entirely.
|
||||
* @param uid the unique identifier passed to {@link RegisterService} for the target service
|
||||
* @returns a property decorator
|
||||
* @example
|
||||
* ```ts
|
||||
* class ReportGenerator {
|
||||
* @Service<LoggerService>(LOGGER_UID)
|
||||
* @Service(LOGGER_UID)
|
||||
* private readonly logger!: LoggerService;
|
||||
*
|
||||
* public run(): void {
|
||||
|
|
@ -30,17 +31,17 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
|
|||
* }
|
||||
* ```
|
||||
*/
|
||||
export function Service<T>(
|
||||
export function Service(
|
||||
uid: ServiceUid,
|
||||
): (prototype: object, propertyKey: string | symbol) => void {
|
||||
return (prototype: object, propertyKey: string | symbol): void => {
|
||||
Object.defineProperty(prototype, propertyKey, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get(this: object): T {
|
||||
let resolved: T;
|
||||
get(this: object): unknown {
|
||||
let resolved: unknown;
|
||||
try {
|
||||
resolved = ServiceRepository.getInstance().resolve<T>(uid);
|
||||
resolved = services.get(uid);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`${LOG_PREFIX} Failed to inject service "${uid}" into member "${String(propertyKey)}".`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue