backend-standards
CMDO architecture standards for Node.js/TypeScript backends with strict layer separation.
SKILL.mdplugin/fullstack-typescript/skills/components/backend/backend-standards/SKILL.md
GitHubBackend 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' | 'invalid_email' };
const createUser = async (
deps: Dependencies,
args: CreateUserArgs
): Promise<CreateUserResult> => {
// Business logic using only injected dependencies
};
What it does NOT contain: HTTP handling, direct database queries, external imports.
Structure:
src/model/
├── definitions/ # TypeScript types ONLY (no Zod/validation)
│ ├── user.ts
│ └── index.ts
├── use-cases/ # One function per file
│ ├── create_user.ts
│ ├── update_user.ts
│ └── index.ts
├── dependencies.ts # Dependencies interface
└── index.ts # Exports only
Layer 5: DAL
Data access functions that directly handle database queries. No repository pattern - use simple, focused functions instead.
DAL Rules:
- One function per file, named after the function (e.g.,
find_user_by_id.ts) - Each function receives its dependencies as the first argument
- No classes, no repository interfaces, no abstraction layers
- Direct database queries with proper parameterization
index.tsre-exports all functions- No assumed grouping - add subdirectories only if explicitly instructed
What it does NOT contain: Business logic, HTTP handling, repository abstractions.
Structure:
src/dal/
├── find_user_by_id.ts
├── insert_user.ts
├── update_user.ts
└── index.ts # Re-exports all functions
Telemetry (OpenTelemetry)
All observability follows OpenTelemetry standards. Telemetry is initialized by the Operator layer.
Logging
Use Pino with OpenTelemetry context injection.
Log Levels:
| Level | When to use |
|---|---|
debug |
Detailed debugging (disabled in production) |
info |
Normal operations, state changes, requests |
warn |
Recoverable issues, deprecations |
error |
Failures requiring attention |
When to Use Info Logging:
Log before and after every domain action or permanent state change:
- Database write operations (create, update, delete)
- User actions (login, logout, password change)
- Outgoing calls (HTTP requests to external services)
- State transitions (order placed, payment processed)
- Business events (subscription activated, invoice generated)
Required Log Fields:
| Field | Required | Description |
|---|---|---|
level |
Yes | Log level (debug, info, warn, error) |
time |
Yes | ISO 8601 timestamp |
component |
Yes | Source component (operator, controller, dal) |
msg |
Yes | Human-readable message |
traceId |
Auto | OpenTelemetry trace ID |
spanId |
Auto | OpenTelemetry span ID |
userId |
Context | User ID if authenticated |
requestId |
Context | Request correlation ID |
error |
On error | Error object with stack |
Logging by Layer: Loggers are created by the Operator layer and passed down. Each layer creates a child logger with its component name.
Security Rule: Never log sensitive data (passwords, tokens, credit cards, PII beyond IDs).
Metrics
Required Metrics:
| Metric | Type | Labels | Layer |
|---|---|---|---|
http.server.request.duration |
Histogram | method, route, status | Operator |
http.server.request.count |
Counter | method, route, status | Operator |
db.client.operation.duration |
Histogram | operation, table | DAL |
db.client.connection.pool.usage |
UpDownCounter | state (active/idle) | DAL |
business.operation.count |
Counter | operation, result | Model |
Metric Naming: Follow OpenTelemetry semantic conventions:
- Use
.as separator (e.g.,http.server.request.duration) - Prefix with namespace (
http,db,business) - Include unit in name if not obvious (e.g.,
duration,count,size)
Spans
Wrap business operations with spans using @opentelemetry/api.
Span Attributes: Use OpenTelemetry semantic conventions:
| Attribute | Type | Example |
|---|---|---|
http.method |
string | GET, POST |
http.route |
string | /api/users/:id |
http.status_code |
int | 200, 404 |
db.system |
string | postgresql |
db.operation |
string | SELECT, INSERT |
db.statement |
string | SQL query (sanitized) |
user.id |
string | User identifier |
Telemetry Rules
- Initialize in Operator: Telemetry is created in Operator before other modules
- Structured logs only: All logs must be JSON with required fields
- Include trace context: Use
withTraceContext()for all logs - No sensitive data: Never log passwords, tokens, or PII
- Appropriate levels: Use correct log level for each message
- Custom spans for business ops: Wrap use-cases with spans
- Standard metric names: Follow OpenTelemetry semantic conventions
- Layer-specific metrics: Each layer records its own metrics
File Naming
CRITICAL: Use lowercase_with_underscores for ALL filenames
✅ create_user.ts, find_user_by_id.ts, user_repository.ts
❌ createUser.ts (camelCase), CreateUser.ts (PascalCase), create-user.ts (kebab-case)
Intra-Module Imports
Inside a module, nothing should ever import from its own index.ts. All imports within a module must use relative paths. The barrel is the module's public API for external consumers only. For nested modules, the same barrel rules apply.
// Given this structure:
// controllers/
// ├── index.ts ← barrel: re-exports all routers
// ├── users/
// │ ├── index.ts
// │ └── users_router.ts
// └── health/
// ├── index.ts
// └── health_router.ts
// In controllers/users/users_router.ts:
// GOOD: relative path to sibling sub-module barrel
import { healthCheck } from '../health';
// BAD: importing from own module's barrel
import { healthCheck } from '@/controllers';
import { healthCheck } from '@/controllers/health';
Implementation Order
When implementing a new feature, follow this order to minimize rework and ensure clean architecture:
Step 1: Contract First (API Design)
Start in components/contract/:
- Define the endpoint in
openapi.yaml- request/response schemas - Generate types via the system CLI (
contract generate-types) - Import types into server via workspace package
This ensures types flow from contract → server → frontend.
Step 2: Model (Business Logic)
Start in src/model/:
- Add definitions - Types specific to this feature in
definitions/ - Create use-case - One file per use-case in
use-cases/ - Define Dependencies interface - What the use-case needs injected
- Write tests - Test use-case with mocked dependencies
Model is pure business logic with no external dependencies.
Step 3: DAL (Data Access)
In src/dal/:
- Add database functions - One function per file
- Map to domain types - Convert DB rows ↔ domain objects
- Use parameterized queries - Never string concatenation
Step 4: Controller (Wiring)
In src/controller/:
- Add HTTP handler - In appropriate
http_handlers/file - Create Dependencies - Wire DAL + external services
- Call use-case - Pass Dependencies and request data
- Map response - Convert use-case result to HTTP response
Step 5: Operator (If Needed)
In src/operator/:
Only modify if the feature requires:
- New external service clients
- New lifecycle events
- New infrastructure capabilities
Implementation Checklist
| Step | Location | What to Do |
|---|---|---|
| 1 | contract/openapi.yaml |
Define endpoint, request/response schemas |
| 2 | model/definitions/ |
Add feature-specific types |
| 3 | model/use-cases/ |
Implement business logic |
| 4 | model/dependencies.ts |
Extend Dependencies if needed |
| 5 | dal/ |
Add database functions |
| 6 | controller/http_handlers/ |
Add HTTP handler |
| 7 | controller/create_controller.ts |
Wire Dependencies |
| 8 | Tests | Test each layer independently |
Anti-Patterns to Avoid
- Starting with HTTP handler - Leads to business logic in controller
- Skipping contract - Types become inconsistent between layers
- DAL before Model - Data structure drives design instead of business needs
- Modifying Operator for features - Operator is infrastructure, not features
Config Schema
Server components require configuration from components/config/. The backend-scaffolding skill generates the initial server structure including a minimal config schema with port, host, and logLevel fields.
Summary Checklist
Before committing backend code, verify:
- Entry point (
src/index.ts) only imports and starts operator - Operator provides raw I/O only - no domain knowledge
- Operator handles Unix signals (SIGTERM, SIGINT, SIGHUP) for graceful shutdown
- Config is the only layer accessing
process.env - Controller combines I/O + config for domain operations
- Model receives Dependencies, never imports external modules
- DAL has one function per file, no repository pattern
- All filenames use
lowercase_with_underscores - Telemetry initialized in Operator before other modules
- All layers use structured logging with required fields
- No sensitive data in logs
- Implementation followed correct order: Contract → Model → DAL → Controller
Input / Output
This skill defines no input parameters or structured output.