From e1e838a357f560cc7f553ea3148fadb4264576b7 Mon Sep 17 00:00:00 2001 From: Hasko Date: Fri, 26 Jun 2026 10:27:27 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(kernel):=20=E2=9C=A8=20Add=20environme?= =?UTF-8?q?nt=20variable=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/getting-started/application-startup.md | 26 ++++-- docs/getting-started/package-map.md | 7 +- docs/reference/dependency-injection.md | 4 +- docs/reference/kernel.md | 47 ++++++++++ example/src/index.ts | 12 ++- example/yarn.lock | 13 ++- package.json | 1 + src/Kernel.ts | 38 +++++++++ .../KernelEnvironmentVariablesOptions.ts | 4 + tests/kernel.test.mjs | 85 +++++++++++++++++++ tsup.config.ts | 1 + yarn.lock | 5 ++ 12 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 src/kernel/KernelEnvironmentVariablesOptions.ts diff --git a/docs/getting-started/application-startup.md b/docs/getting-started/application-startup.md index a6903fd..0b14b10 100644 --- a/docs/getting-started/application-startup.md +++ b/docs/getting-started/application-startup.md @@ -16,28 +16,40 @@ const kernel = new Kernel({ servicesYamlPath: 'config/container/services.yaml', }); +kernel.loadEnvironmentVariables(process.env.NODE_ENV || 'local'); + await kernel.dependencyInjection({ - containerBuild: process.env.CONTAINER_BUILD === 'true', + containerBuild: kernel.environment.NODE_ENV !== 'production', }); kernel.registerRoutes(GetUserByIdRoute); kernel.registerConsumers(...applicationConsumers); kernel.registerSchedulers(...recurringSchedulers); -const server = new ExpressKernelServer({ kernel, port: 3000 }); +const server = new ExpressKernelServer({ + kernel, + port: Number(kernel.environment.PORT ?? 3000), +}); kernel.registerShutdownHook(() => server.close()); await server.run(); await kernel.runConsumers(); await kernel.runSchedulers(); -kernel.logger.info('Application running on port 3000'); +kernel.logger.info( + `Application running on port ${kernel.environment.PORT ?? 3000}`, +); ``` -`CONTAINER_BUILD=true` regenerates `config/container/services.yaml`. Without it, -the generated YAML is loaded. +`loadEnvironmentVariables()` loads `.env.local` by default when `NODE_ENV` is +not set. Passing `test` loads `.env.test`; passing an empty string loads `.env`. + +`containerBuild: true` regenerates `config/container/services.yaml`. Production +runtimes should usually load the generated YAML instead of rebuilding from +`src`. -`new Kernel(...)` configures defaults for the runtime. `kernel.dependencyInjection(...)` -can override the DI-specific values for a particular boot. +`new Kernel(...)` configures defaults for the runtime. +`kernel.dependencyInjection(...)` can override the DI-specific values for a +particular boot. Call `kernel.shutdown()` from your process signal handlers to stop consumers, stop schedulers, close servers, flush logs and release connections registered diff --git a/docs/getting-started/package-map.md b/docs/getting-started/package-map.md index 531f6c7..d6a63a5 100644 --- a/docs/getting-started/package-map.md +++ b/docs/getting-started/package-map.md @@ -17,13 +17,14 @@ Bootstrap code chooses adapters. | Area | Import | Purpose | | ---------------------- | ----------------------------------------- | ------------------------------------------------------------------------ | | Kernel runtime | `@haskou/ddd-kernel` | Registers consumers, routes, schedulers, runtimes and shutdown hooks. | +| Environment variables | `@haskou/ddd-kernel` | Loads `.env.` files and exposes `Kernel.environment`. | | Dependency injection | `@haskou/ddd-kernel/dependency-injection` | Wraps `node-dependency-injection` and container YAML generation/loading. | | Lifecycle | `@haskou/ddd-kernel/lifecycle` | Runtime and initializer contracts. | | Kernel logger contract | `@haskou/ddd-kernel/contracts/kernel` | `KernelLogger` interface. | -`node-dependency-injection` and `fs-extra` are package dependencies because the -core DI implementation uses them directly. Applications do not install them -separately. +`dotenv`, `node-dependency-injection` and `fs-extra` are package dependencies +because the core environment and DI implementations use them directly. +Applications do not install them separately. ## Contracts And Domain diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index dfc8cd7..35af5b0 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -7,8 +7,10 @@ Most applications should configure DI through the kernel: ```ts const kernel = new Kernel(); +kernel.loadEnvironmentVariables(); + await kernel.dependencyInjection({ - containerBuild: process.env.NODE_ENV !== 'production', + containerBuild: kernel.environment.NODE_ENV !== 'production', }); ``` diff --git a/docs/reference/kernel.md b/docs/reference/kernel.md index 4d09492..8171e3f 100644 --- a/docs/reference/kernel.md +++ b/docs/reference/kernel.md @@ -44,6 +44,53 @@ const kernel = new Kernel({ `servicesYamlPath` and `sourceDirectory` can also be passed directly to `kernel.dependencyInjection(...)`; the method-level value wins. +## Environment Variables + +Load environment variables before dependency injection or adapters read their +`process.env` fallbacks: + +```ts +const kernel = new Kernel(); + +kernel.loadEnvironmentVariables(process.env.NODE_ENV || 'local'); +``` + +The default environment is `process.env.NODE_ENV || 'local'`, so calling +`kernel.loadEnvironmentVariables()` loads `.env.local` when `NODE_ENV` is not +set. + +| Call | Loaded file | +| ------------------------------------------------------------------- | ---------------------------------- | +| `kernel.loadEnvironmentVariables()` | `.env.${NODE_ENV}` or `.env.local` | +| `kernel.loadEnvironmentVariables('test')` | `.env.test` | +| `kernel.loadEnvironmentVariables('')` | `.env` | +| `kernel.loadEnvironmentVariables('local', { path: 'config/.env' })` | `config/.env` | + +Existing variables are not overwritten unless `override` is enabled: + +```ts +kernel.loadEnvironmentVariables('local', { override: true }); +``` + +`Kernel.environment` and `kernel.environment` expose `process.env` through one +kernel-owned access point: + +```ts +const port = Number(Kernel.environment.HTTP_PORT ?? 3000); +``` + +Applications can type known variables by augmenting `NodeJS.ProcessEnv`: + +```ts +declare global { + namespace NodeJS { + interface ProcessEnv { + HTTP_PORT?: string; + } + } +} +``` + ## Dependency Injection Most applications call this once during startup: diff --git a/example/src/index.ts b/example/src/index.ts index c872673..1f9941f 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -31,10 +31,14 @@ const kernel = new Kernel({ sourceDirectory: path.resolve(rootDirectory, 'src'), }); +// Environment variables are loaded before DI/adapters read their process.env +// fallbacks. With NODE_ENV unset this loads .env.local. +kernel.loadEnvironmentVariables(process.env.NODE_ENV || 'local'); + // In development the container is rebuilt from default exports under src/. // In production it can reuse the generated services.yaml file. await kernel.dependencyInjection({ - containerBuild: process.env.NODE_ENV !== 'production', + containerBuild: kernel.environment.NODE_ENV !== 'production', }); // Consumer middleware is registered once and runs around every domain event @@ -60,7 +64,7 @@ const requestLoggerMiddleware: RequestHandler = (request, response, next) => { const server = new ExpressKernelServer({ kernel, - port: Number(process.env.PORT ?? 3000), + port: Number(kernel.environment.PORT ?? 3000), }); // The Express adapter stays optional. HTTP middleware, hooks and error handlers @@ -84,7 +88,9 @@ server kernel.registerShutdownHook(() => server.close()); await server.run(); -kernel.logger.info(`Application running on port ${process.env.PORT ?? 3000}`); +kernel.logger.info( + `Application running on port ${kernel.environment.PORT ?? 3000}`, +); // Delegate OS signals to the kernel so consumers, schedulers, servers and logs // are stopped consistently. diff --git a/example/yarn.lock b/example/yarn.lock index ab8c18e..22e5f87 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -143,7 +143,11 @@ integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== "@haskou/ddd-kernel@file:..": - version "0.1.1" + version "1.0.3" + dependencies: + dotenv "^16.6.1" + fs-extra "^11.3.5" + node-dependency-injection "3.2.6" "@haskou/value-objects@^2.12.0": version "2.12.0" @@ -592,6 +596,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dotenv@^16.6.1: + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -840,7 +849,7 @@ fresh@~0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== -fs-extra@11.3.5: +fs-extra@11.3.5, fs-extra@^11.3.5: version "11.3.5" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.5.tgz#07a44eff40bea53e719909a532f91a23bf0769ff" integrity sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg== diff --git a/package.json b/package.json index 4257327..33eec84 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,7 @@ ], "license": "MIT", "dependencies": { + "dotenv": "^16.6.1", "fs-extra": "^11.3.5", "node-dependency-injection": "3.2.6" }, diff --git a/src/Kernel.ts b/src/Kernel.ts index 6a73cc8..92bba3d 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -1,3 +1,4 @@ +import dotenv, { type DotenvConfigOutput } from 'dotenv'; import path from 'node:path'; import type { Consumer } from './adapters/pubsub/index.js'; @@ -11,6 +12,7 @@ import type { ServiceClass } from './infrastructure/dependency-injection/index.j import type { Initializer, Runtime } from './infrastructure/lifecycle/index.js'; import type { Scheduler } from './infrastructure/scheduler/index.js'; import type { KernelDependencyInjectionOptions } from './kernel/KernelDependencyInjectionOptions.js'; +import type { KernelEnvironmentVariablesOptions } from './kernel/KernelEnvironmentVariablesOptions.js'; import type { KernelOptions } from './kernel/KernelOptions.js'; import type { ShutdownCandidate } from './kernel/ShutdownCandidate.js'; @@ -18,6 +20,7 @@ import { ConsoleKernelLogger } from './adapters/kernel/index.js'; import { DependencyInjection } from './infrastructure/dependency-injection/index.js'; export type { KernelDependencyInjectionOptions } from './kernel/KernelDependencyInjectionOptions.js'; +export type { KernelEnvironmentVariablesOptions } from './kernel/KernelEnvironmentVariablesOptions.js'; export type { KernelOptions } from './kernel/KernelOptions.js'; export class Kernel { @@ -59,6 +62,10 @@ export class Kernel { return Kernel.getActiveKernel().di; } + public static get environment(): NodeJS.ProcessEnv { + return process.env; + } + public static get logger(): KernelLogger { return Kernel.getActiveKernel().logger; } @@ -91,6 +98,26 @@ export class Kernel { return Kernel.state.activeKernel; } + private static getEnvironmentVariablesPath( + environment: string, + options: KernelEnvironmentVariablesOptions, + ): string { + return path.resolve( + Kernel.rootDirectory, + options.path ?? (environment ? `.env.${environment}` : '.env'), + ); + } + + public static loadEnvironmentVariables( + environment = process.env.NODE_ENV || 'local', + options: KernelEnvironmentVariablesOptions = {}, + ): DotenvConfigOutput { + return dotenv.config({ + override: options.override, + path: Kernel.getEnvironmentVariablesPath(environment, options), + }); + } + constructor(private readonly options: KernelOptions = {}) { this.loggerInstance = options.logger ?? new ConsoleKernelLogger(); this.dependencyInjectionInstance = options.di; @@ -159,6 +186,10 @@ export class Kernel { return this.dependencyInjectionInstance; } + public get environment(): NodeJS.ProcessEnv { + return Kernel.environment; + } + public get logger(): KernelLogger { return this.loggerInstance; } @@ -203,6 +234,13 @@ export class Kernel { Kernel.state.activeKernel = this; } + public loadEnvironmentVariables( + environment = process.env.NODE_ENV || 'local', + options: KernelEnvironmentVariablesOptions = {}, + ): DotenvConfigOutput { + return Kernel.loadEnvironmentVariables(environment, options); + } + public getRoutes(): ServiceClass[] { return this.routes; } diff --git a/src/kernel/KernelEnvironmentVariablesOptions.ts b/src/kernel/KernelEnvironmentVariablesOptions.ts new file mode 100644 index 0000000..f428636 --- /dev/null +++ b/src/kernel/KernelEnvironmentVariablesOptions.ts @@ -0,0 +1,4 @@ +export interface KernelEnvironmentVariablesOptions { + readonly override?: boolean; + readonly path?: string; +} diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index d5ec705..9ee37c0 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -367,3 +367,88 @@ test('creates a default active kernel for static getters', () => { test('creates kernels through the factory function', () => { assert.ok(createKernel() instanceof Kernel); }); + +test('loads local environment variables by default', async (context) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const previousDirectory = process.cwd(); + const previousNodeEnvironment = process.env.NODE_ENV; + const previousHttpPort = process.env.HTTP_PORT; + + delete process.env.NODE_ENV; + delete process.env.HTTP_PORT; + process.chdir(temporaryDirectory); + context.after(() => { + process.chdir(previousDirectory); + + if (previousNodeEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnvironment; + } + + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + }); + + await writeFile( + path.join(temporaryDirectory, '.env.local'), + 'HTTP_PORT=3001', + ); + + const kernel = new Kernel(); + const result = kernel.loadEnvironmentVariables(); + + assert.equal(result.parsed.HTTP_PORT, '3001'); + assert.equal(kernel.environment.HTTP_PORT, '3001'); + assert.equal(Kernel.environment.HTTP_PORT, '3001'); +}); + +test('loads named environment variables and supports override', async (context) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const previousDirectory = process.cwd(); + const previousHttpPort = process.env.HTTP_PORT; + + process.env.HTTP_PORT = '3000'; + process.chdir(temporaryDirectory); + context.after(() => { + process.chdir(previousDirectory); + + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + }); + + await writeFile(path.join(temporaryDirectory, '.env.test'), 'HTTP_PORT=3002'); + + Kernel.loadEnvironmentVariables('test'); + assert.equal(process.env.HTTP_PORT, '3000'); + + Kernel.loadEnvironmentVariables('test', { override: true }); + assert.equal(process.env.HTTP_PORT, '3002'); +}); + +test('loads environment variables from an explicit path', async (context) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const previousCustomValue = process.env.CUSTOM_ENV_VALUE; + const environmentPath = path.join(temporaryDirectory, 'custom.env'); + + delete process.env.CUSTOM_ENV_VALUE; + context.after(() => { + if (previousCustomValue === undefined) { + delete process.env.CUSTOM_ENV_VALUE; + } else { + process.env.CUSTOM_ENV_VALUE = previousCustomValue; + } + }); + + await writeFile(environmentPath, 'CUSTOM_ENV_VALUE=loaded'); + + Kernel.loadEnvironmentVariables('', { path: environmentPath }); + + assert.equal(Kernel.environment.CUSTOM_ENV_VALUE, 'loaded'); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 9fb592a..2a201cb 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ }, external: [ 'amqplib', + 'dotenv', 'express', 'fs-extra', 'mongodb', diff --git a/yarn.lock b/yarn.lock index d61fd14..06769b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1980,6 +1980,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dotenv@^16.6.1: + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" From fe4018ec2a0641484cd25b0d313efbbd6a750f96 Mon Sep 17 00:00:00 2001 From: Hasko Date: Fri, 26 Jun 2026 10:31:57 +0200 Subject: [PATCH 2/3] =?UTF-8?q?test(kernel):=20=E2=9C=85=20Cover=20environ?= =?UTF-8?q?ment=20file=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/kernel.test.mjs | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index 9ee37c0..20747dd 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -406,6 +406,38 @@ test('loads local environment variables by default', async (context) => { assert.equal(Kernel.environment.HTTP_PORT, '3001'); }); +test('loads environment variables from NODE_ENV by default', async (context) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const previousDirectory = process.cwd(); + const previousNodeEnvironment = process.env.NODE_ENV; + const previousHttpPort = process.env.HTTP_PORT; + + process.env.NODE_ENV = 'ci'; + delete process.env.HTTP_PORT; + process.chdir(temporaryDirectory); + context.after(() => { + process.chdir(previousDirectory); + + if (previousNodeEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnvironment; + } + + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + }); + + await writeFile(path.join(temporaryDirectory, '.env.ci'), 'HTTP_PORT=3003'); + + Kernel.loadEnvironmentVariables(); + + assert.equal(Kernel.environment.HTTP_PORT, '3003'); +}); + test('loads named environment variables and supports override', async (context) => { const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); const previousDirectory = process.cwd(); @@ -452,3 +484,30 @@ test('loads environment variables from an explicit path', async (context) => { assert.equal(Kernel.environment.CUSTOM_ENV_VALUE, 'loaded'); }); + +test('loads base environment file when environment name is empty', async (context) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const previousDirectory = process.cwd(); + const previousCustomValue = process.env.CUSTOM_ENV_VALUE; + + delete process.env.CUSTOM_ENV_VALUE; + process.chdir(temporaryDirectory); + context.after(() => { + process.chdir(previousDirectory); + + if (previousCustomValue === undefined) { + delete process.env.CUSTOM_ENV_VALUE; + } else { + process.env.CUSTOM_ENV_VALUE = previousCustomValue; + } + }); + + await writeFile( + path.join(temporaryDirectory, '.env'), + 'CUSTOM_ENV_VALUE=base', + ); + + Kernel.loadEnvironmentVariables(''); + + assert.equal(Kernel.environment.CUSTOM_ENV_VALUE, 'base'); +}); From 5f2de9cccbd76a7d7b3284ac011f73e890cab203 Mon Sep 17 00:00:00 2001 From: Hasko Date: Fri, 26 Jun 2026 10:35:38 +0200 Subject: [PATCH 3/3] =?UTF-8?q?test(kernel):=20=E2=9C=85=20Stabilize=20env?= =?UTF-8?q?ironment=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Kernel.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Kernel.ts b/src/Kernel.ts index 92bba3d..a6778f0 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -109,12 +109,14 @@ export class Kernel { } public static loadEnvironmentVariables( - environment = process.env.NODE_ENV || 'local', + environment?: string, options: KernelEnvironmentVariablesOptions = {}, ): DotenvConfigOutput { + const environmentName = environment ?? process.env.NODE_ENV ?? 'local'; + return dotenv.config({ override: options.override, - path: Kernel.getEnvironmentVariablesPath(environment, options), + path: Kernel.getEnvironmentVariablesPath(environmentName, options), }); } @@ -235,7 +237,7 @@ export class Kernel { } public loadEnvironmentVariables( - environment = process.env.NODE_ENV || 'local', + environment?: string, options: KernelEnvironmentVariablesOptions = {}, ): DotenvConfigOutput { return Kernel.loadEnvironmentVariables(environment, options);