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
import { RegisterService, Service } from '@lightweight-sdk/service-registry';
@RegisterService()
export const LOGGER_UID = 'logger';
@RegisterService(LOGGER_UID)
class LoggerService {
public log(message: string): void {
console.log(message);
@ -21,7 +23,7 @@ class LoggerService {
}
class ReportGenerator {
@Service(LoggerService)
@Service<LoggerService>(LOGGER_UID)
private readonly logger!: LoggerService;
public run(): void {
@ -32,18 +34,19 @@ class ReportGenerator {
## 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.
- **`@RegisterService()`** — décorateur de classe. Instancie la classe décorée (constructeur sans argument obligatoire) et l'enregistre dans le `ServiceRepository`.
- **`@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).
- **`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).
## 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`).
## API
- `RegisterService()` — décorateur de classe.
- `Service(target)` — décorateur de propriété.
- `ServiceRepository` — singleton (`getInstance()`, `registerClass`, `resolveClass`, `resolve`, `getUidForClass`, `hasClass`). `resetForTesting()` est réservé aux tests.
- `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.
- `ServiceConstructor<T>`, `ServiceUid` — types associés.

View file

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

View file

@ -7,8 +7,8 @@ beforeEach(() => {
});
describe('RegisterService', () => {
it('registers the decorated class in the global repository', () => {
@RegisterService()
it('registers the decorated class under the given uid', () => {
@RegisterService('greeter')
class GreeterService {
public greet(name: string): string {
return `Hello, ${name}!`;
@ -16,26 +16,27 @@ describe('RegisterService', () => {
}
const repository = ServiceRepository.getInstance();
expect(repository.hasClass(GreeterService)).toBe(true);
expect(repository.resolveClass(GreeterService)).toBeInstanceOf(GreeterService);
expect(repository.has('greeter')).toBe(true);
expect(repository.resolve<GreeterService>('greeter')).toBeInstanceOf(GreeterService);
});
it('shares a single instance across every resolution', () => {
@RegisterService()
@RegisterService('state')
class StateService {
public value = 0;
}
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', () => {
class DuplicateService {}
RegisterService()(DuplicateService);
it('rethrows if the uid is already registered', () => {
class ServiceA {}
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 type { ServiceConstructor } from './types';
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}, making it
* resolvable from anywhere via {@link Service}. The decorated class must
* have a no-argument constructor — any dependencies it needs should
* themselves be injected with {@link Service} rather than passed as
* constructor parameters.
* resulting instance in the global {@link ServiceRepository} under `uid`,
* making it resolvable from anywhere via {@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}
* rather than passed as constructor parameters.
* @param uid the unique identifier this service will be registered and resolved under
* @returns a class decorator
* @example
* ```ts
* ⁣@RegisterService()
* export const LOGGER_UID = 'logger';
*
* ⁣@RegisterService(LOGGER_UID)
* export class LoggerService {
* public log(message: string): void {
* 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 => {
try {
ServiceRepository.getInstance().registerClass(target);
ServiceRepository.getInstance().register(uid, target);
} 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;
}
};

View file

@ -14,57 +14,42 @@ describe('ServiceRepository', () => {
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();
repository.registerClass(Counter);
const first = repository.resolveClass(Counter);
const second = repository.resolveClass(Counter);
repository.register('counter', Counter);
const first = repository.resolve<Counter>('counter');
const second = repository.resolve<Counter>('counter');
expect(first).toBeInstanceOf(Counter);
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 uid = repository.registerClass(Counter);
expect(typeof uid).toBe('string');
expect(uid.length).toBeGreaterThan(0);
expect(repository.resolve(uid)).toBe(repository.resolveClass(Counter));
expect(repository.getUidForClass(Counter)).toBe(uid);
expect(repository.has('counter')).toBe(false);
repository.register('counter', Counter);
expect(repository.has('counter')).toBe(true);
});
it('reports whether a class is registered', () => {
it('throws when registering the same uid twice', () => {
const repository = ServiceRepository.getInstance();
repository.register('counter', Counter);
expect(repository.hasClass(Counter)).toBe(false);
repository.registerClass(Counter);
expect(repository.hasClass(Counter)).toBe(true);
expect(() => repository.register('counter', Counter)).toThrow(/already registered/);
});
it('throws when registering the same class twice', () => {
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', () => {
it('throws when resolving a uid that was never registered', () => {
expect(() => ServiceRepository.getInstance().resolve('does-not-exist')).toThrow(
/No service registered/,
);
});
it('starts fresh after resetForTesting', () => {
ServiceRepository.getInstance().registerClass(Counter);
ServiceRepository.getInstance().register('counter', Counter);
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
* service registered via {@link RegisterService}, keyed by a unique id
* generated 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, uid
* lookups, diagnostics).
* 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).
*/
export class ServiceRepository {
private static instance: ServiceRepository | undefined;
private instancesByUid = new Map<ServiceUid, unknown>();
private uidsByConstructor = new WeakMap<ServiceConstructor, ServiceUid>();
private instances = new Map<ServiceUid, unknown>();
/** Private to enforce the singleton pattern — use {@link ServiceRepository.getInstance}. */
private constructor() {}
@ -41,75 +40,45 @@ export class ServiceRepository {
/**
* Instantiates `target` (its constructor must take no arguments) and
* registers the resulting instance under a freshly generated unique id.
* Called by {@link RegisterService} — most callers should use that
* decorator instead of calling this method directly.
* registers the resulting instance under `uid`. Called by
* {@link RegisterService} — most callers should use that decorator
* 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
* @returns the uid assigned to this service
* @throws {Error} if `target` is already registered
* @throws {Error} if `uid` is already registered
*/
public registerClass<T>(target: ServiceConstructor<T>): ServiceUid {
if (this.uidsByConstructor.has(target)) {
throw new Error(`Service "${target.name}" is already registered.`);
public register<T>(uid: ServiceUid, target: ServiceConstructor<T>): void {
if (this.instances.has(uid)) {
throw new Error(`Service uid "${uid}" is already registered.`);
}
const uid = globalThis.crypto.randomUUID();
const instance = new target();
this.instances.set(uid, instance);
this.instancesByUid.set(uid, instance);
this.uidsByConstructor.set(target, uid);
console.debug(`${LOG_PREFIX} Registered service "${target.name}" (${uid}).`);
return uid;
console.debug(`${LOG_PREFIX} Registered service "${uid}" (${target.name}).`);
}
/**
* Resolves the shared instance registered for `target`. Called by
* {@link Service} — most callers should use that decorator instead of
* calling this method directly.
* @param target the service class previously registered via {@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}
* Resolves the instance registered under `uid`. Called by {@link Service}
* — most callers should use that decorator instead of calling this
* method directly.
* @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`
*/
public resolve<T>(uid: ServiceUid): T {
if (!this.instancesByUid.has(uid)) {
throw new Error(`No service registered with uid "${uid}".`);
if (!this.instances.has(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.
* @param target the service class to look up
* @returns the assigned uid, or `undefined` if `target` is not registered
* Checks whether a service is currently registered under `uid`.
* @param uid the unique identifier to check
* @returns `true` if a service is registered under `uid`
*/
public getUidForClass(target: ServiceConstructor): ServiceUid | undefined {
return this.uidsByConstructor.get(target);
}
/**
* 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);
public has(uid: ServiceUid): boolean {
return this.instances.has(uid);
}
}

View file

@ -8,8 +8,8 @@ beforeEach(() => {
});
describe('Service', () => {
it('injects the shared instance registered for the target class', () => {
@RegisterService()
it('injects the instance registered under the given uid', () => {
@RegisterService('logger')
class LoggerService {
public readonly messages: string[] = [];
@ -19,7 +19,7 @@ describe('Service', () => {
}
class ReportGenerator {
@Service(LoggerService)
@Service<LoggerService>('logger')
public readonly logger!: LoggerService;
}
@ -27,34 +27,36 @@ describe('Service', () => {
generator.logger.log('hello');
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 {
public readonly ok = true;
}
class Consumer {
@Service(LateService)
@Service<LateService>('late')
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.
RegisterService()(LateService);
RegisterService('late')(LateService);
expect(new Consumer().svc.ok).toBe(true);
});
it('memoizes the resolved value on the instance after the first access', () => {
@RegisterService()
@RegisterService('logger')
class LoggerService {
public log(_message: string): void {}
}
class Consumer {
@Service(LoggerService)
@Service<LoggerService>('logger')
public readonly logger!: LoggerService;
}
@ -68,13 +70,12 @@ describe('Service', () => {
expect(descriptor?.value).toBeInstanceOf(LoggerService);
});
it('throws when the target service was never registered', () => {
class UnregisteredService {}
it('throws when the uid was never registered', () => {
class Consumer {
@Service(UnregisteredService)
public readonly dep!: UnregisteredService;
@Service('unregistered')
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 type { ServiceConstructor } from './types';
import type { ServiceUid } from './types';
const LOG_PREFIX = '[lightweight-sdk:service-registry]';
/**
* Property decorator that injects the shared instance of `target`
* (previously registered via {@link RegisterService}) into the decorated
* class member.
* Property decorator that injects the instance registered under `uid`
* (via {@link RegisterService}) into the decorated class member.
*
* Resolution is lazy and memoized: the service is looked up in the
* {@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
* is replaced by the resolved value on the instance, so later accesses skip
* 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
* @example
* ```ts
* class ReportGenerator {
* ⁣@Service(LoggerService)
* ⁣@Service<LoggerService>(LOGGER_UID)
* private readonly logger!: LoggerService;
*
* public run(): void {
@ -29,7 +31,7 @@ const LOG_PREFIX = '[lightweight-sdk:service-registry]';
* ```
*/
export function Service<T>(
target: ServiceConstructor<T>,
uid: ServiceUid,
): (prototype: object, propertyKey: string | symbol) => void {
return (prototype: object, propertyKey: string | symbol): void => {
Object.defineProperty(prototype, propertyKey, {
@ -38,10 +40,10 @@ export function Service<T>(
get(this: object): T {
let resolved: T;
try {
resolved = ServiceRepository.getInstance().resolveClass(target);
resolved = ServiceRepository.getInstance().resolve<T>(uid);
} catch (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,
);
throw error;