Skip to main content

Configuration Overview

Digital Alchemy configuration is module-scoped and type-safe. Each application or library declares entries alongside its services. An entry can use the legacy type form or a native schema form; both forms can coexist in one module.

import { Type } from "typebox";
import { CreateApplication } from "@digital-alchemy/core";

export const MY_APP = CreateApplication({
name: "my_app",
configuration: {
DATABASE_URL: {
schema: Type.String({ description: "Database connection URL" }),
},
PORT: {
schema: Type.Integer({ default: 3000 }),
},
// Legacy entries remain supported during migration.
LOG_LEVEL: {
type: "string",
default: "info",
},
},
services: {},
});

For the schema declaration contract, provider behavior, input/output types, and migration table, see Schema-backed Configuration.

How it works​

Declaration — CreateApplication and CreateLibrary collect configuration definitions. A definition provides exactly one of type or schema.

Collection — During wiring, each module registers its definitions with the configuration service. Legacy defaults and supported TypeBox root defaults may be visible while services are constructed; external sources have not settled yet.

Source loading — After PreInit, core loads enabled environment and CLI values, the optional config-file loader, and registered custom loaders. The order is documented in Config Sourcing; bootstrap configuration is an early seed, not an unconditional final override.

Finalization — Once every enabled loader settles, legacy values are coerced and schema values are processed through their native provider. Core publishes one complete processed snapshot and then runs onPostConfig callbacks. A schema failure prevents a partial processed snapshot from being published.

Access — Services read final values through config.<module_name>.<KEY> from onPostConfig or a later lifecycle stage:

export function ApiService({ config, lifecycle, logger }: TServiceParams) {
lifecycle.onPostConfig(() => {
const url: string = config.my_app.DATABASE_URL;
const port: number = config.my_app.PORT;
logger.info({ url, port }, "configuration loaded");
});
}

Module namespaces​

Configuration is grouped by module name: MY_APP is available at config.my_app.* and MY_LIB at config.my_lib.*. Namespaces organize keys and shape TInjectedConfig; they are not access-control boundaries. A service can read another loaded module's configuration when the module is part of the application's typed graph. Use service APIs when you need a stronger ownership boundary.

The boilerplate module provides its own legacy configuration at config.boilerplate.*:

KeyTypeDefault or source
boilerplate.LOG_LEVELstring enum"trace"
boilerplate.NODE_ENVstringprocess.env.NODE_ENV || "local"
boilerplate.IS_TESTbooleantrue when NODE_ENV starts with "test"
boilerplate.CONFIGstring--config / argv input for the file loader

The boilerplate.CONFIG path is a file-loader input. It is separate from dotenv loading through envFile or --env-file.

TInjectedConfig​

TInjectedConfig is the TypeScript type of the injected config object. It is computed from the loaded module declarations: one namespace per module and one property per entry. Legacy entries map from their type; schema entries map to the provider's output type.

For schemas with transforms or codecs, the injected output type can differ from the input accepted by bootstrap({ configuration }), TestRunner.configure(), and loaders. See Schema input and output.

Configuration updates​

internal.boilerplate.configuration.set(project, property, value) updates one live value and emits event_configuration_updated to configuration.onUpdate() subscribers. set() and merge() are output-shaped mutation APIs: they do not run native schema processing again. Use values that already satisfy the injected output type.

See also​