| name | app-architecture |
| description | Create apps following contract-port architecture with composition roots. Use when creating new apps in apps/, scaffolding CLI tools, setting up dependency injection, or when the user asks about app structure, entrypoints, or platform-agnostic design. |
App Architecture
Apps mirror the package architecture - they should be platform-agnostic outside of the entrypoint where ports are assembled with platform-specific globals.
Core Principle
Assemble all ports at the top level (composition root), then feed them down to individual modules.
┌─────────────────────────────────────────────────────────────┐
│ Entry Point (cli.ts) │
│ Parse args, call composition root │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Composition Root │
│ (composition.ts) │
│ Inject platform globals → Create ports → Return │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Module A │ │ Module B │ │ Module C │
│ (no globals)│ │ (no globals)│ │ (no globals)│
└─────────────┘ └─────────────┘ └─────────────┘
File Structure
apps/{app-name}/
├── src/
│ ├── cli.ts # Entry point (thin) - only parses args, calls composition
│ ├── composition.ts # Composition root - assembles all ports
│ ├── types.ts # App-specific types
│ ├── errors.ts # Error classes (extend ConveauxError)
│ └── {domain}.ts # Domain modules - platform-agnostic
├── CLAUDE.md # Inline instructions for this app
├── README.md # User documentation
├── package.json
└── tsconfig.json
Composition Root Pattern
The composition root is the ONLY place where platform globals appear:
// composition.ts
import type { Env } from '@scope/contract-env';
import type { Logger } from '@scope/contract-logger';
import type { WallClock } from '@scope/contract-wall-clock';
import type { EphemeralScheduler } from '@scope/contract-ephemeral-scheduler';
import { createEnv, createShellEnvSource, createStaticEnvSource } from '@scope/port-env';
import { createEphemeralScheduler } from '@scope/port-ephemeral-scheduler';
import { createLogger, createJsonFormatter, createPrettyFormatter } from '@scope/port-logger';
import { createOutChannel } from '@scope/port-outchannel';
import { createWallClock } from '@scope/port-wall-clock';
// Define what deps the app needs
export interface RuntimeDeps {
readonly logger: Logger;
readonly clock: WallClock;
readonly env: Env;
readonly scheduler: EphemeralScheduler;
}
export interface RuntimeOptions {
readonly json?: boolean;
readonly verbose?: boolean;
}
// Factory that creates all deps - platform globals only appear HERE
export function createRuntimeDeps(options: RuntimeOptions = {}): RuntimeDeps {
// Inject platform globals into ports
const clock = createWallClock({ Date });
const scheduler = createEphemeralScheduler({
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
});
const logChannel = createOutChannel(process.stderr);
const formatter = options.json
? createJsonFormatter()
: createPrettyFormatter({ colors: process.stderr.isTTY ?? false });
const logger = createLogger({
Date,
channel: logChannel,
clock,
options: { formatter, minLevel: options.verbose ? 'debug' : 'info' },
});
const env = createEnv({
sources: [
createShellEnvSource(
{ getEnv: (name) => process.env[name] },
{ name: 'shell', priority: 100 }
),
createStaticEnvSource(
{ DEFAULT_TIMEOUT: '30' },
{ name: 'defaults', priority: 0 }
),
],
});
return { logger, clock, env, scheduler };
}
Entry Point Pattern
The entry point should be thin - just parse args and call composition:
// cli.ts
#!/usr/bin/env node
import type { Logger } from '@scope/contract-logger';
import { Command } from 'commander';
import { createRuntimeDeps } from './composition.js';
import { runApp } from './app.js';
import { createClient } from './client.js';
const program = new Command();
program
.name('my-app')
.action(async (options: CliOptions) => {
// 1. Create all deps at the top level
const deps = createRuntimeDeps({
json: options.json,
verbose: options.verbose,
});
const { logger, clock, env, scheduler } = deps;
// 2. Create app-specific services, passing deps down
const client = createClient({ logger, clock });
// 3. Run the app, passing deps down
const result = await runApp({ logger, clock, client, scheduler }, config);
console.log(JSON.stringify(result, null, 2));
process.exit(EXIT_CODES[result.status]);
});
program.parse();
Domain Module Pattern
Domain modules receive deps via injection - they never use globals:
// client.ts
import type { Logger } from '@scope/contract-logger';
import type { WallClock } from '@scope/contract-wall-clock';
// Define what deps THIS module needs (subset of RuntimeDeps)
export interface ClientDeps {
readonly logger: Logger;
readonly clock: WallClock;
}
// Interface for what this module provides
export interface Client {
fetch(url: string): Promise<Response>;
}
// Factory receives deps, returns implementation
export function createClient(deps: ClientDeps): Client {
const { logger, clock } = deps;
return {
async fetch(url: string) {
const startTime = clock.nowMs(); // Use injected clock, not Date.now()
logger.debug('Fetching', { url });
// ... implementation
const durationMs = clock.nowMs() - startTime;
logger.debug('Fetched', { url, durationMs });
return response;
}
};
}
Checklist for Creating Apps
Composition Root
- Single
composition.tsfile that creates all runtime deps - Platform globals (Date, setTimeout, process.env) only appear here
- Exports
RuntimeDepsinterface andcreateRuntimeDepsfactory - Options (json, verbose) are separate from deps
Entry Point
- Thin
cli.ts- only arg parsing and wiring - Calls
createRuntimeDeps()once at the top - Passes deps down to all modules
- Handles errors with proper exit codes
Domain Modules
- Each module defines its own
*Depsinterface - Deps interface only includes contracts the module needs
- No direct use of globals (Date.now, console, process.env)
- Factory pattern:
createX(deps): X
Types
-
types.tsfor shared app-specific types - Config types separate from deps types
- Exit codes as const object
Errors
-
errors.tswith classes extendingUserErrororRetryableError - Actionable error messages with guidance
- Use
getExitCode()for proper exit codes
Common Deps by Use Case
| Need | Contract | Port |
|---|---|---|
| Current time | contract-wall-clock |
port-wall-clock |
| Logging | contract-logger |
port-logger |
| Environment vars | contract-env |
port-env |
| Delays/timers | contract-ephemeral-scheduler |
port-ephemeral-scheduler |
| Writing output | contract-outchannel |
port-outchannel |
| Error handling | contract-control-flow |
port-control-flow |
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
Date.now() in module |
Hidden platform dependency | Inject WallClock |
setTimeout() in module |
Untestable timing | Inject EphemeralScheduler |
process.env.X in module |
Hidden env dependency | Inject Env |
console.log() in module |
Hidden output dependency | Inject Logger |
| Deps created inside module | Can't test with mocks | Pass deps from composition root |
| Module imports port directly | Bypasses DI | Import contract types only |