Skip to main content

Custom Config Loaders

Environment variables, CLI switches, and the optional file loader cover common deployments. A custom loader connects another source—such as a secrets manager, remote key/value API, or database—to the same configuration lifecycle.

Loader timing and contract​

A loader runs after the built-in env/argv pass and optional file loader, in the order it was registered. Core waits for every enabled loader, settles source precedence, and then processes schema-backed values through their native providers before onPostConfig callbacks run.

type ConfigLoader = (params: ConfigLoaderParams) => Promise<Partial<AbstractConfig>>;

A loader should return a partial module/key object. For schema-backed entries, return the schema's input representation. A codec or transform is applied once during finalization; do not return the already-decoded output unless that is also the schema input.

Register a loader during service construction or onPreInit. Registering from onBootstrap is too late for the current configuration pass.

import type {
AbstractConfig,
ConfigLoader,
TServiceParams,
} from "@digital-alchemy/core";

export function RemoteConfigService({ internal, lifecycle }: TServiceParams) {
lifecycle.onPreInit(() => {
internal.boilerplate.configuration.registerLoader(
async () => ({
my_app: {
DATABASE_URL: await fetchDatabaseUrl(),
// A TypeBox codec could receive its encoded string here.
RETRIES: "5",
},
}),
"remote",
);
});
}

Extending ConfigLoaderSource​

Declare a custom name when it should be accepted by registerLoader and configSources:

// src/config-loader.mts
declare module "@digital-alchemy/core" {
interface ConfigLoaderSource {
remote: true;
}
}

Then a bootstrap can disable it for a local run:

await MY_APP.bootstrap({
configSources: {
remote: false,
},
});

Absence from configSources leaves a registered custom loader enabled. configSources is a loader-channel toggle; it does not disable explicit bootstrap configuration or public output-shaped mutations.

Per-entry metadata​

Extend BaseConfig when the remote store needs a key or namespace for each entry:

// src/config-loader.mts
declare module "@digital-alchemy/core" {
interface ConfigLoaderSource {
remote: true;
}

interface BaseConfig {
remote?: {
key: string;
namespace?: string;
};
}
}

Schema metadata still belongs on the schema itself:

import { Type } from "typebox";

export const MY_APP = CreateApplication({
name: "my_app",
configuration: {
DATABASE_URL: {
schema: Type.String({ description: "Primary database URL" }),
remote: { key: "db-connection-string", namespace: "shared" },
source: ["remote"],
},
API_KEY: {
schema: Type.String(),
remote: { key: "api-key" },
source: ["remote"],
},
},
services: {},
});

source: ["remote"] prevents env, argv, and file loader values from replacing the key. It does not prevent an explicit bootstrap seed or a later output-shaped set()/merge() call.

Scanning definitions​

params.configs is a map from module names to their raw config definitions. Scan it for your extension metadata:

const remoteMetadataLoader: ConfigLoader = async ({ configs, internal }) => {
const result: Partial<AbstractConfig> = {};

for (const [moduleName, moduleConfigs] of configs.entries()) {
for (const [key, entry] of Object.entries(moduleConfigs)) {
if (!entry.remote) continue;

const value = await fetchRemote(entry.remote.key, entry.remote.namespace);
internal.utils.object.set(result, `${moduleName}.${key}`, value);
}
}

return result;
};

The raw schema value retains provider identity. Do not serialize it as part of a remote manifest. If a scanner or help command needs metadata, use internal.boilerplate.configuration.getMetadata(), which returns a safe view without executing default factories or provider callbacks.

Parallel loading​

For independent remote requests, collect promises and await them together:

const parallelRemoteLoader: ConfigLoader = async ({ configs, internal }) => {
const result: Partial<AbstractConfig> = {};
const waiting: Promise<void>[] = [];

for (const [moduleName, moduleConfigs] of configs.entries()) {
for (const [key, entry] of Object.entries(moduleConfigs)) {
if (!entry.remote) continue;

waiting.push(
(async () => {
const value = await fetchRemote(entry.remote.key, entry.remote.namespace);
internal.utils.object.set(result, `${moduleName}.${key}`, value);
})(),
);
}
}

await Promise.all(waiting);
return result;
};

Later custom loaders see raw previews from earlier loaders. They should not rely on a transform having already run.

Error handling​

Throw a BootstrapException for a fatal remote failure so bootstrap stops with a contextual error:

import type { TContext } from "@digital-alchemy/core";
import { BootstrapException } from "@digital-alchemy/core";

async function loadRemoteValue(context: TContext) {
return fetchRemote("database-url").catch(() => {
throw new BootstrapException(
context,
"REMOTE_CONFIG_FETCH_FAILED",
"Could not load remote configuration.",
);
});
}

Provider failures from the final schema pass use the sanitized CONFIG_SCHEMA_* causes. Do not echo secrets or raw input values in a custom error message.

Development and production​

A local run can disable the remote loader and provide explicit schema input:

await MY_APP.bootstrap({
configSources: {
remote: false,
file: false,
},
configuration: {
my_app: {
DATABASE_URL: "postgres://localhost/myapp_dev",
API_KEY: "dev-test-key",
},
},
});

In production, enable the remote loader and disable file or argv channels as appropriate:

await MY_APP.bootstrap({
configSources: {
file: false,
argv: false,
remote: true,
},
});

The loader code stays the same. Source restrictions, provider validation, and final output processing remain in the core lifecycle.

For built-in source precedence, see Config Sourcing. For provider input/output and construction previews, see Schema-backed Configuration.