Backend Standards Skill
CMDO ("Commando") architecture for Node.js/TypeScript backends with strict separation between infrastructure and domain concerns.
Architecture: CMDO (Controller Model DAL Operator)
Operator → Controller → Model Use Cases
↓ ↓ ↑
Config → [All layers] → Dependencies (injected by Controller)
↓
DAL
Key Distinction: Infrastructure vs Domain
| Layer | Knows About | Example |
|---|---|---|
| Operator | Infrastructure only | "Here's a DB connection and generic HTTP client" |
| Config | URLs and settings | paymentGatewayUrl: "https://api.stripe.com" |
| Controller | Domain concerns | "Use httpClient + paymentGatewayUrl to charge a card" |
| Model | Business logic only | "Calculate total, then call chargePayment()" |
Key Principle: Operator provides raw I/O capabilities (generic HTTP client, cache client, DB connection) with NO domain knowledge. Controller combines I/O + config to create domain-specific operations.
Layer Overview
| Layer | Path | Responsibility |
|---|---|---|
| Entry Point | src/index.ts | Bootstrap only (ONLY file with side effects) |
| Operator | src/operator/ | Raw I/O capabilities (DB, HTTP clients, cache), lifecycle management, state machine |
| Config | src/config/ | Environment parsing, validation, type-safe config |
| Controller | src/controller/ | Request/response handling, combines I/O + config for domain calls, creates Dependencies for Model |
| Model | src/model/ | Business logic (definitions + use-cases), receives Dependencies |
| DAL | src/dal/ | Data access, queries, mapping DB ↔ domain objects |
Entry Point: src/index.ts
Rules:
src/index.tsis the ONLY file that runs code on import (exception to the "index.ts exports only" rule for application entry points)- All other files export functions/types with NO side effects when imported
- NO logic beyond importing and starting the operator
- NO configuration loading, validation, or setup logic
// src/index.ts - Entry point (exception to index.ts rule for entry points)
import { createOperator } from "./operator";
import { loadConfig } from "./config";
const main = async (): Promise<void> => {
const config = loadConfig();
const operator = createOperator({ config });
await operator.start();
};
main().catch((error) => {
console.error("Failed to start operator:", error);
process.exit(1);
});
Layer 1: Operator
Provides raw I/O capabilities and orchestrates application lifecycle. NO domain knowledge - only infrastructure.
What Operator Does:
- Creates database connection pool (raw I/O capability)
- Creates generic HTTP client (for external calls - if needed)
- Creates cache client (if needed)
- Binds DAL functions with database client
- Creates Controller, passing raw I/O + DAL + config
- Manages lifecycle via state machine (IDLE → STARTING → RUNNING → STOPPING → STOPPED)
- Manages HTTP server for API endpoints
- Manages lifecycle probes on separate port (health/readiness for Kubernetes)
- Handles Unix signals for graceful shutdown (SIGTERM, SIGINT, SIGHUP)
- Initializes telemetry (logger, metrics) before other modules
What Operator Does NOT Do:
- NO domain-specific calls (e.g., "call payment gateway", "fetch user from auth service")
- NO business logic
- NO knowledge of what the raw I/O will be used for
Lifecycle State Machine:
IDLE → STARTING:PROBES → STARTING:DATABASE → STARTING:HTTP_SERVER → RUNNING
↓
STOPPED ← STOPPING:PROBES ← STOPPING:DATABASE ← STOPPING:HTTP_SERVER ←─┘
Unix Signal Handling:
| Signal | Source | Action |
|---|---|---|
SIGTERM | Kubernetes pod termination, kill command | Graceful shutdown |
SIGINT | Ctrl+C from terminal | Graceful shutdown |
SIGHUP | Terminal hangup | Graceful shutdown |
When a signal is received:
- Log the signal with info level
- Initiate graceful shutdown via
stop() - Wait for all connections to drain
- Exit with code 0 (success) or 1 (error)
Structure:
src/operator/
├── create_operator.ts # Main factory, state machine, lifecycle
├── create_database.ts # Database connection pool
├── create_http_server.ts # HTTP server wrapper
├── lifecycle_probes.ts # Health/readiness endpoints (separate port)
├── state_machine.ts # Generic state machine implementation
├── logger.ts # Pino logger with OpenTelemetry
├── metrics.ts # OpenTelemetry metrics
└── index.ts # Exports only
Layer 2: Config
Environment parsing, validation, type-safe config objects.
CRITICAL: Use dotenv for ALL environment variable access. Direct process.env access is FORBIDDEN outside the Config layer.
Environment Variable Rules:
- dotenv is mandatory: Always use
dotenv.config()insideloadConfig()(not at module level) - Config layer ONLY:
process.envaccess is ONLY allowed in src/config/ - Type-safe access: All other layers receive typed Config object
- Validation required: Validate required vars and throw if missing
- Default values: Provide sensible defaults for optional vars
- NO direct access elsewhere: NEVER use
process.envin Operator, Controller, Model, or DAL layers
What it does NOT contain: Business logic, database queries.
Structure:
src/config/
├── index.ts # loadConfig() function, Config type
└── validation.ts # Optional: validation helpers
Layer 3: Controller
Request/response handling, combines I/O + config for domain-specific operations, creates Dependencies object for Model.
Controller's Key Role:
- Receives raw I/O capabilities from Operator (generic httpClient, cache, etc.)
- Receives Config with URLs and settings
- Combines I/O + config to create domain-specific operations (e.g.,
httpClient + config.paymentUrl→chargePayment) - Creates Model Dependencies by stitching together DAL + domain operations
HTTP Handlers: Each file in http_handlers/ corresponds to an API namespace (e.g., /users, /orders) and exports a router. The create_controller.ts imports these routers and wires them together with the Dependencies object for Model use-cases.
Handler Naming: Use operationId from OpenAPI spec with handle prefix (e.g., createUser → handleCreateUser).
Health Check Endpoints: Lifecycle probes (/health, /readiness) are handled by Operator on a separate port, NOT in the Controller.
What it does NOT contain: Database queries, business logic (delegates to Model).
Structure:
src/controller/
├── http_handlers/ # One file per API namespace
│ ├── users.ts # Exports usersRouter
│ ├── orders.ts # Exports ordersRouter
│ └── index.ts # Re-exports all routers
├── create_controller.ts # Assembles routers, creates Dependencies for Model
└── index.ts # Exports only
Layer 4: Model
Business logic via definitions + use-cases. Model never imports from outside its module.
Definitions Rules:
- Use TypeScript types only (
typeorinterface) - NO Zod, Yup, io-ts, or similar validation libraries
- Validation belongs in the Controller layer (input) or Operator layer (middleware)
- Definitions are compile-time constructs, not runtime validators
Use Case Rules:
- One use-case per file
- Each use-case receives Dependencies as first argument
- Return discriminated unions for success/failure
// ✅ GOOD: Discriminated union results
type CreateUserResult =
| { readonly success: true; readonly user: User }
| { readonly success: false; readonly error: 'email_exists' | 'inval