← Concepts & practices
Pattern Boundaries and contracts

Configuration as a boundary

Validate once, trust inward.

An image processor needs a port, database URL, public origin, email mode, and a production secret. Read those raw environment strings in every route and a missing value becomes a late, partial failure. Parse them at startup, name the errors, and pass the application one value whose shape and lifetime are explicit.

The judgment to keep

Configuration is untrusted input. Read it at the outer edge, validate required values and ranges, redact diagnostics, and pass a typed value inward. Static startup settings and dynamic runtime policy are different boundaries.

TypeScriptGo One image processor · three configuration failures and one clean start.
Start with the outside world

Environment variables are not application values yet.

Operating systems and deployment platforms hand an application a bag of optional strings. PORT might be absent, non-numeric, or outside the platform range. An email mode might be misspelled. A database URL can be required in production but intentionally replaced by a test dependency.

If each handler reads the bag itself, every caller invents a default and every log risks printing the wrong object. The process may start successfully and fail only when a route or worker reaches the unusual path.

Make startup the boundary: raw values enter once, validated settings leave once.

Read the scattered readsTypeScript · a fallback can hide a deployment error
config.ts · raw environment
// Raw environment values are strings and can be absent. Reading them in every handler spreads policy.
export function unsafePort(env: RawEnv): number {
	return Number(env.PORT) || 3000;
}

export function unsafeEmailMode(env: RawEnv): string {
	return env.EMAIL_MODE ?? 'console';
}

export function unsafeDatabaseUrl(env: RawEnv): string {
	return env.DATABASE_URL ?? 'sqlite://./local.db';
}

Number(env.PORT) || 3000 turns “web” into a working-looking local port. A missing database becomes SQLite, and an unknown email mode passes through as a string no caller has agreed to handle. The code is short; the policy is invisible.

Name the stages

Separate raw input, validation, runtime config, and public config.

These stages have different responsibilities. Keeping them separate prevents a secret from leaking into browser code or an unchecked string from reaching a client.

01

Raw input

Record<string, string | undefined>; untrusted, optional, string-shaped.

02

Parser

Names keys, normalizes whitespace, checks ranges, and collects issues.

03

Runtime value

Typed, read-only settings passed from the composition root to routes and workers.

04

Public projection

Only browser-safe fields; secrets become a presence flag or stay server-only.

Who owns which configuration decision?
DecisionOwnerEvidence
Required in production?Startup parserNamed issue and non-zero boot failure
Can tests replace it?Composition rootExplicit test config or dependency override
Can it change live?Runtime policy ownerRefresh, cache, fallback, and audit rules
Can the browser see it?Public projectionAllow-list, never a secret-bearing environment dump
Read the Go boundaryGo · map strings become a typed struct
config.go · typed startup config
var digitsOnly = regexp.MustCompile(`^[0-9]+$`)

func addIssue(issues *[]Issue, key, message string) {
	*issues = append(*issues, Issue{Key: key, Message: message})
}

func parseConfig(env RawEnv) ConfigResult {
	issues := []Issue{}
	environment := env["NODE_ENV"]
	if environment != "development" && environment != "test" && environment != "production" {
		addIssue(&issues, "NODE_ENV", "must be development, test, or production")
		environment = "development"
	}
	// Digits only: Atoi would also accept a sign such as "+8080".
	port, err := strconv.Atoi(env["PORT"])
	if !digitsOnly.MatchString(env["PORT"]) || err != nil || port < 1 || port > 65535 {
		addIssue(&issues, "PORT", "must be an integer from 1 through 65535")
		port = 0
	}
	emailMode := env["EMAIL_MODE"]
	if emailMode != "console" && emailMode != "smtp" {
		addIssue(&issues, "EMAIL_MODE", "must be console or smtp")
		emailMode = "console"
	}
	databaseURL := strings.TrimSpace(env["DATABASE_URL"])
	if databaseURL == "" {
		addIssue(&issues, "DATABASE_URL", "is required")
	}
	publicOrigin := strings.TrimSpace(env["PUBLIC_ORIGIN"])
	if publicOrigin == "" {
		addIssue(&issues, "PUBLIC_ORIGIN", "is required")
	}
	emailAPIKey := strings.TrimSpace(env["EMAIL_API_KEY"])
	if environment == "production" && emailMode == "smtp" && emailAPIKey == "" {
		addIssue(&issues, "EMAIL_API_KEY", "is required when production email mode is smtp")
	}
	if len(issues) > 0 {
		return ConfigResult{Issues: issues}
	}
	return ConfigResult{Config: &Config{Environment: environment, Port: port, DatabaseURL: databaseURL, PublicOrigin: publicOrigin, EmailMode: emailMode, EmailAPIKey: emailAPIKey}}
}

Go’s package can keep parsing helpers private and return a Config only after validation. The struct is not a permission to log every field: redaction remains an application policy.

Run startup

See the difference between a late fallback and an early rejection.

Choose a configuration design and an environment state. Follow the value from raw strings to process boot. A production secret, bad port, or unknown mode should be visible before the first request.

Image processor

Choose how the process discovers its settings.

Runs a local startup model
Read environment values everywhereValid production settings
01Read raw strings at the outer boundary
02Route handlers, jobs, and components each read raw strings.
03Normalize defaults and validate ranges
04A later call discovers the problem under live traffic.
Read point

Route handlers, jobs, and components each read raw strings.

Secret policy

No explicit redaction policy is guaranteed.

The process starts anyway, and the problem waits for live traffic. Every required value has a known shape.

Watch for A fallback that keeps the process alive can turn a deployment mistake into a partial outage.

The controls model startup policy; they do not read this machine’s environment or expose secrets.
Practice the boundary

Choose a configuration policy with a lifetime.

Decide whether the setting is static startup input, an explicit test dependency, or a dynamic runtime policy.

PORT is required in production but absent. What should startup do?
Where should a route get the database URL?
A test needs a fake email sender. Which override is safest?
A feature flag changes every few minutes. Does startup parsing alone solve it?
Feedback stays on this page; it is not saved.
Own the value

Make the composition root the only place that knows the raw shape.

The image processor’s parser returns AppConfig: a number port, known environment and email mode, required URLs, and a nullable key whose presence is enough for diagnostics. Routes do not parse numbers. Workers do not choose a database fallback. Client code receives an allow-listed public projection.

Test the boundary with missing, blank, malformed, out-of-range, and unknown values. Test both the accepted value and the rejected issue list. Also test that logs and public config never contain secrets.

Raw environment reads are strings, omissions, and scattered fallback policies.

TypeScriptReading
config.ts
// Raw environment values are strings and can be absent. Reading them in every handler spreads policy.
export function unsafePort(env: RawEnv): number {
	return Number(env.PORT) || 3000;
}

export function unsafeEmailMode(env: RawEnv): string {
	return env.EMAIL_MODE ?? 'console';
}

export function unsafeDatabaseUrl(env: RawEnv): string {
	return env.DATABASE_URL ?? 'sqlite://./local.db';
}
GoAlongside
config.go
func unsafePort(env RawEnv) int {
	port, _ := strconv.Atoi(env["PORT"])
	if port == 0 {
		return 3000
	}
	return port
}

func unsafeEmailMode(env RawEnv) string {
	if env["EMAIL_MODE"] == "" {
		return "console"
	}
	return env["EMAIL_MODE"]
}

func unsafeDatabaseURL(env RawEnv) string {
	if env["DATABASE_URL"] == "" {
		return "sqlite://./local.db"
	}
	return env["DATABASE_URL"]
}
Parser owns

Shape and requirements

Types, defaults, ranges, known alternatives, and the complete issue list.

Composition owns

Lifetime and overrides

Startup creation, test replacement, dependency wiring, and shutdown ownership.

Runtime policy owns

Live change

Refresh cadence, stale values, fallback behavior, rollout, and audit for dynamic settings.

Recognize it in UI code

A browser-safe projection is not the server environment.

Build frontends?Pass the smallest safe configuration projection to the page.

Where it already is in your components

Every import.meta.env value a component reads is configuration crossing into the browser. Once it is in the bundle, anyone who opens the page can read it.

When you have to own it

When a page needs an origin or a mode from configuration, own the projection it receives. The textbook components receive a public origin and an application facade. The wild components read raw environment values, invent a port fallback, and put an email key in the DOM. A build-time public variable is still public once it reaches the browser.

The browser receives only a safe public projection and calls an application facade.

ReactAlready in your code
textbook.tsx · public config
type PublicConfig = {
	publicOrigin: string;
	emailMode: 'console' | 'smtp';
};

type ImageApp = {
	createPreviewUrl(assetId: string): string;
};

export function PreviewCard({
	config,
	app,
	assetId
}: {
	config: PublicConfig;
	app: ImageApp;
	assetId: string;
}) {
	const preview = app.createPreviewUrl(assetId);
	return (
		<img
			src={preview}
			alt={`Preview from ${config.publicOrigin}`}
			data-email-mode={config.emailMode}
		/>
	);
}
Keep the boundary honest

Configuration bugs are usually lifetime or ownership bugs.

01

Production fallback

Defaulting a required setting can make a broken deployment look healthy until traffic reaches it.

02

Secret-shaped logs

Do not serialize the complete config for debugging. Log keys, safe summaries, and presence flags.

03

Global test mutation

Changing process.env after startup creates order-dependent tests. Pass an explicit config or dependency.

04

Dynamic flags

A value that changes live needs an owner for refresh, cache, stale reads, and audit; startup parsing alone is not that policy.

Configuration is not a singleton by definitionOne read can still produce multiple deliberate instances

Reading configuration once per process is a useful default, not a requirement that every test or tenant share a global object. A test can parse a fixture. A worker can receive a process-owned value. A request-specific policy can be a separate dependency with an explicit lifetime.

Make the call

Validate at startup when a setting defines whether the process can operate.

Keep a local default when it is intentional, safe, and documented for that environment. Reject startup when a missing or malformed value would produce false health, data loss, insecure behavior, or a partial outage. Keep dynamic configuration separate when its lifetime is shorter than the process.

Write the boundary in one place: raw keys in, typed value or named issues out, redacted diagnostics, and an explicit override path for tests.

Keep this questionUse it before adding another process.env read.

Is this a startup setting, a public projection, or a live policy—and who owns its lifetime?

Take the idea with you

Configuration is the first contract your process meets.

Read the outside world once; make the inside world speak in trustworthy values.

Connections to follow nextRelated lessons
Why
A missing or malformed setting should stop the process before traffic arrives, not fail one route later.
What
One startup parser turns raw environment strings into a typed config value or named issues with redacted diagnostics.
Constraint
Secrets never appear in errors, logs, or the browser projection.
Fallback
Only intentional, documented defaults; anything else rejects startup with the key name.
Reconsider when
A setting must change while the process runs, which makes it a live policy with its own owner.