Skip to content

Commit 8c24dfe

Browse files
committed
implemented namespaced config
1 parent 243e4f4 commit 8c24dfe

File tree

5 files changed

+397
-10
lines changed

5 files changed

+397
-10
lines changed

src/config.module.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
* - Validation runs BEFORE any other provider resolves.
2424
* - If validation fails, ConfigValidationError is thrown and the app
2525
* never finishes booting.
26+
* - A shared namespace registry (Set<string>) is exported so that
27+
* NamespacedConfig.asProvider() can detect duplicate namespaces.
2628
*
2729
* Contents:
2830
* - ConfigModuleAsyncOptions — interface for registerAsync options
@@ -33,7 +35,7 @@ import { DynamicModule, Global, Module } from "@nestjs/common";
3335
import type { Provider } from "@nestjs/common";
3436
import type { z } from "zod";
3537

36-
import { CONFIG_VALUES_TOKEN } from "@/constants";
38+
import { CONFIG_VALUES_TOKEN, NAMESPACE_REGISTRY_TOKEN } from "@/constants";
3739
import type { ConfigDefinition } from "@/define-config";
3840
import { ConfigService } from "@/config.service";
3941

@@ -145,14 +147,24 @@ export class ConfigModule {
145147
useValue: parsedConfig,
146148
};
147149

150+
// Namespace registry — a shared Set<string> that NamespacedConfig.asProvider()
151+
// injects to detect duplicate namespace registrations at module init time.
152+
// A fresh Set is created per ConfigModule instance.
153+
const namespaceRegistryProvider: Provider = {
154+
provide: NAMESPACE_REGISTRY_TOKEN,
155+
useValue: new Set<string>(),
156+
};
157+
148158
return {
149159
module: ConfigModule,
150160
providers: [
151-
configValuesProvider, // must be listed before ConfigService (which depends on it)
161+
configValuesProvider, // parsed config values — must come before ConfigService
162+
namespaceRegistryProvider, // registry for namespace duplicate detection
152163
ConfigService,
153164
],
154-
// Export ConfigService so the importing module can inject it
155-
exports: [ConfigService],
165+
// Export both ConfigService and the registry so feature modules' asProvider()
166+
// can inject NAMESPACE_REGISTRY_TOKEN
167+
exports: [ConfigService, NAMESPACE_REGISTRY_TOKEN],
156168
// Non-global: only available in the importing module unless re-exported
157169
global: false,
158170
};
@@ -201,12 +213,18 @@ export class ConfigModule {
201213
inject: (options.inject ?? []) as never[],
202214
};
203215

216+
// Namespace registry shared with feature modules' asProvider() factories
217+
const namespaceRegistryProvider: Provider = {
218+
provide: NAMESPACE_REGISTRY_TOKEN,
219+
useValue: new Set<string>(),
220+
};
221+
204222
return {
205223
module: ConfigModule,
206224
// Make the imported modules available so the factory can resolve them
207225
imports: options.imports ?? [],
208-
providers: [configValuesProvider, ConfigService],
209-
exports: [ConfigService],
226+
providers: [configValuesProvider, namespaceRegistryProvider, ConfigService],
227+
exports: [ConfigService, NAMESPACE_REGISTRY_TOKEN],
210228
global: false,
211229
};
212230
}

src/constants.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,62 @@
66
* These tokens are the "glue" between ConfigModule providers and the
77
* services that consume them. They are NOT part of the public API —
88
* consumers should never inject these tokens directly; they should use
9-
* ConfigService instead.
9+
* ConfigService or @InjectConfig() instead.
1010
*
1111
* Contents:
12-
* - CONFIG_VALUES_TOKEN — token for the parsed, frozen config object
12+
* - CONFIG_VALUES_TOKEN — token for the root parsed config object
13+
* - getNamespaceToken() — generates a unique DI token per namespace
14+
* - NAMESPACE_REGISTRY_TOKEN — token for the set of registered namespace names
1315
*/
1416

1517
// ─────────────────────────────────────────────────────────────────────────────
16-
// DI Tokens
18+
// Root config token
1719
// ─────────────────────────────────────────────────────────────────────────────
1820

1921
/**
20-
* Injection token for the parsed and validated config object.
22+
* Injection token for the parsed and validated root config object.
2123
*
2224
* ConfigModule registers the result of `definition.parse(process.env)` under
2325
* this token. ConfigService then injects it to serve typed `get()` calls.
2426
*
2527
* Consumers should never inject this token directly — always use ConfigService.
2628
*/
2729
export const CONFIG_VALUES_TOKEN = "CONFIG_KIT_VALUES" as const;
30+
31+
// ─────────────────────────────────────────────────────────────────────────────
32+
// Namespace tokens
33+
// ─────────────────────────────────────────────────────────────────────────────
34+
35+
/**
36+
* Generates a unique, stable DI token for a given namespace string.
37+
*
38+
* Each namespace gets its own token so that NestJS can inject the correct
39+
* validated config slice into each feature module independently.
40+
*
41+
* The token is a plain string prefixed with `CONFIG_KIT_NS:` to avoid any
42+
* accidental collision with other DI tokens in the consuming app.
43+
*
44+
* @param namespace - The namespace name passed to `defineNamespace()`.
45+
* @returns A unique DI token string for that namespace.
46+
*
47+
* @example
48+
* ```typescript
49+
* getNamespaceToken('database') // → 'CONFIG_KIT_NS:database'
50+
* getNamespaceToken('auth') // → 'CONFIG_KIT_NS:auth'
51+
* ```
52+
*/
53+
export function getNamespaceToken(namespace: string): string {
54+
return `CONFIG_KIT_NS:${namespace}`;
55+
}
56+
57+
/**
58+
* Injection token for the namespace registry.
59+
*
60+
* ConfigModule stores a `Set<string>` of all registered namespace names under
61+
* this token at startup. When a new NamespacedConfig is added, it checks this
62+
* registry and throws if the namespace has already been registered — preventing
63+
* silent duplicate registrations that would produce unpredictable behavior.
64+
*
65+
* This token is internal; consumers never interact with it directly.
66+
*/
67+
export const NAMESPACE_REGISTRY_TOKEN = "CONFIG_KIT_NS_REGISTRY" as const;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @file inject-config.decorator.ts
3+
* @description
4+
* Parameter decorator for injecting a typed namespace config slice into
5+
* NestJS constructors.
6+
*
7+
* Usage:
8+
* ```typescript
9+
* constructor(
10+
* @InjectConfig('auth') private cfg: z.output<typeof authConfig.definition.schema>
11+
* ) {}
12+
* ```
13+
*
14+
* Under the hood it is just `@Inject(getNamespaceToken(namespace))` — a thin
15+
* wrapper so consumers never have to know about the internal token format.
16+
*
17+
* Contents:
18+
* - InjectConfig() — parameter decorator factory
19+
*/
20+
21+
import { Inject } from "@nestjs/common";
22+
23+
import { getNamespaceToken } from "@/constants";
24+
25+
// ─────────────────────────────────────────────────────────────────────────────
26+
// @InjectConfig
27+
// ─────────────────────────────────────────────────────────────────────────────
28+
29+
/**
30+
* Parameter decorator that injects the validated config slice for a namespace.
31+
*
32+
* Must be used in constructors of NestJS providers (services, controllers, etc.)
33+
* inside a module that has imported ConfigModule and added the corresponding
34+
* `NamespacedConfig.asProvider()` to its `providers` array.
35+
*
36+
* The injected value is a frozen, fully-typed object — the Zod output of the
37+
* schema passed to `defineNamespace()`. No `string | undefined` values.
38+
*
39+
* @param namespace - The namespace name used in `defineNamespace(namespace, schema)`.
40+
* Must match exactly (case-sensitive).
41+
* @returns A NestJS `@Inject()` parameter decorator bound to the namespace token.
42+
*
43+
* @example
44+
* ```typescript
45+
* // auth/auth.config.ts
46+
* export const authConfig = defineNamespace('auth', z.object({
47+
* JWT_SECRET: z.string().min(32),
48+
* JWT_EXPIRES_IN: z.string().default('7d'),
49+
* }));
50+
*
51+
* // auth/auth.module.ts
52+
* @Module({ providers: [authConfig.asProvider(), AuthService] })
53+
* export class AuthModule {}
54+
*
55+
* // auth/auth.service.ts
56+
* @Injectable()
57+
* export class AuthService {
58+
* constructor(
59+
* // Injects the validated { JWT_SECRET: string, JWT_EXPIRES_IN: string } object
60+
* @InjectConfig('auth') private cfg: z.output<typeof authConfig.definition.schema>
61+
* ) {}
62+
*
63+
* getSecret(): string {
64+
* return this.cfg.JWT_SECRET; // string — never undefined
65+
* }
66+
* }
67+
* ```
68+
*/
69+
export function InjectConfig(namespace: string): ParameterDecorator {
70+
// Resolve the namespace to its unique DI token and delegate to NestJS @Inject
71+
return Inject(getNamespaceToken(namespace));
72+
}

0 commit comments

Comments
 (0)