Coding Standards — Ghasi Melmastoon
Companion: NAMING.md · DEFINITION_OF_DONE.md · SERVICE_TEMPLATE.md · TESTING_STANDARDS.md · API_PATH_CONVENTIONS.md · ERROR_CODES.md
This document is the single, authoritative coding standard for every line of TypeScript shipped under ghasi-melmastoon. It distills the rules in .cursor/rules/00–95-*.mdc into one normative reference. Every PR is reviewed against it. AI tools (Cursor, Claude, Copilot) load it via the rules pack.
If this document and an .mdc rule disagree, this document wins. Update both in the same commit.
1. Language & toolchain (frozen)
| Concern | Choice | Notes |
|---|---|---|
| Runtime | Node 20 LTS (services + Electron main + tooling) | Pinned in package.json engines.node. |
| Language | TypeScript ≥ 5.4, strict: true | All other strict flags ON (see §2). |
| Module system | ESM ("type": "module") for new code | CommonJS only for legacy adapters that require it; document with a comment. |
| Package manager | pnpm 9.x | Workspace via pnpm-workspace.yaml. No npm, no yarn, no bun. |
| Monorepo orchestrator | Turborepo | Task graph in turbo.json. |
| Backend framework | NestJS 11 | Clean / hexagonal layers; see §6. |
| Web framework | Next.js (App Router) | Server Components default; Client Components opt-in. |
| Mobile framework | Expo (React Native, New Architecture, Hermes) | No bare workflow without ADR. |
| Desktop framework | Electron + Vite + React | Node 20 main, Chromium renderer, better-sqlite3, ONNX Runtime Node. Never Tauri. |
| Database | PostgreSQL 16 (Cloud SQL) + SQLite (desktop) + pgvector + Firestore (sync cursors) | One DB per service in cloud (RLS by tenant_id). |
| ORM / SQL | Drizzle (or thin pg) | No TypeORM, no Prisma in services. |
| Validation | Zod for DTOs, env, and event payloads | One source of truth per shape. |
| Test runner | Vitest (services + packages); Jest only where Nest-CLI insists | See TESTING_STANDARDS.md. |
| Linter | ESLint (flat config, eslint.config.mjs at repo root) | Includes import/no-restricted-paths to enforce layer boundaries. |
| Formatter | Prettier (workspace config at root) | No per-package overrides. |
| Commit lint | commitlint + Husky | Conventional Commits + MEL-\d+ ticket regex (see §15). |
Forbidden: Tauri, AWS, Azure, raw
fetchin components, raw OpenAI/Anthropic/Google SDK imports outsideai-orchestrator-service, raw lock-vendor SDKs outsidelock-integration-service, raw Stripe/PayPal SDKs outsidepayment-gateway-service,anyoutsideeslint-disablecomments with a written justification.
2. TypeScript compiler options (every tsconfig.json extends tsconfig.base.json)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"useUnknownInCatchVariables": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true
}
}
Per-package tsconfig.json only changes paths, outDir, rootDir, and references. Any flag override requires a comment with rationale.
3. Type discipline
- No
any. Useunknownand narrow. If you truly needany, add// eslint-disable-next-line @typescript-eslint/no-explicit-anywith a written justification on the next line. - No type assertions (
as) without a runtime check. Prefer Zod.parse()at the boundary, then trust the inferred type internally. - Branded IDs everywhere in domain + application layers.
stringIDs are forbidden once you cross intodomain/. See NAMING.md §Symbols. readonlyby default on aggregate state, value objects, DTOs, and config objects. Mutability is opt-in.- Discriminated unions over enums for state machines (e.g., reservation status, sync conflict outcome). Enums are allowed for closed sets persisted to the DB.
- Result types (
Result<T, DomainError>) for fallible domain operations. ThrowDomainErroronly at use-case boundaries; never inside aggregates. - No
Datein domain. UseInstant(UTCbigintmicro-seconds) andLocalDatevalue objects from@ghasi/domain-primitives. Reason: timezone bugs and JSON serialization drift. Moneyisbigintmicro-units. Nevernumber, neverdecimal. The wrapper enforces this at construction.Locale,Currency,Countryare typed enums sourced from@ghasi/domain-primitives.
4. Module structure (universal)
Every service, app, and package follows the same top-level shape:
src/
├── domain/ # Pure TS. No framework imports. Aggregates, VOs, ports, domain errors, domain events.
├── application/ # Use cases (commands + queries). Orchestrate ports. No infrastructure imports.
├── infrastructure/ # Adapters (DB, HTTP clients, Pub/Sub, file system, external SDKs). Implements ports.
├── presentation/ # Controllers, routes, GraphQL resolvers, BFF handlers, IPC handlers.
└── main.ts # Composition root. The only file allowed to wire the four layers together.
test/
├── unit/ # Mirror of src/ for unit specs. No I/O.
├── integration/ # Testcontainers + real adapters. tenant-isolation + outbox + inbox mandatory.
├── contract/ # Pact (HTTP) + JSON-Schema (events).
└── e2e/ # Playwright (web/desktop) or Detox (mobile).
Layer rules (enforced by ESLint import/no-restricted-paths):
domain/→ may import only fromdomain/and@ghasi/domain-primitives.application/→ may import fromdomain/andapplication/. Never frominfrastructure/orpresentation/.infrastructure/→ may import fromdomain/,application/, and itself.presentation/→ may import fromapplication/and itself. Never fromdomain/directly (use a use case).main.ts→ may import from anywhere.
A PR that violates a layer rule fails CI.
5. Imports & path aliases
- Path aliases configured in
tsconfig.base.json:@domain/*,@application/*,@infrastructure/*,@presentation/*,@test/*,@ghasi/*(workspace packages). - Imports are sorted by
eslint-plugin-import: built-in → external → internal-aliases (@ghasi/) → service-aliases (@domain/, …) → relative (./,../). - Relative imports may not cross more than two
... Cross a layer? Use a path alias. - Side-effect-only imports (
import './polyfills') are top of file with a leading comment. - Type-only imports use
import type { ... }. Mixed value+type imports useimport { x, type Y } from '...'.
6. Backend (NestJS) — module + dependency rules
- One Nest module per bounded context inside the service. Shared cross-context utilities live in
infrastructure/shared/and are imported, not subclassed. - Constructor injection only. No property injection.
- Providers are interface-keyed via Nest tokens (
const LOCK_PORT = Symbol('LockPort')). Concrete adapters bind at composition root. - Controllers are thin. They translate HTTP → command/query → DTO. Zero business logic.
- Pipes / guards / interceptors are global for cross-cutting concerns (tenant context, idempotency, rate limit, telemetry, auth). Per-route only with justification.
- Filters translate
DomainError→ HTTP error response with canonicalMELMASTOON.<DOMAIN>.<CODE>. See ERROR_CODES.md. main.tswires Helmet, CORS, OpenAPI, OTEL, graceful shutdown, ZodValidationPipe, and binds the composition root. Same shape across all 22 services.
7. Frontend code rules (web, mobile, desktop renderer)
Full rules in .cursor/rules/90-frontend.mdc. Headlines:
- No raw
fetch. Always typed clients from@ghasi/api-clients, wrapped by@ghasi/data-layer(TanStack Query) hooks. - No raw strings in JSX. Every user-visible string flows through
useTranslations()(@ghasi/i18n). The i18n-extract CI gate fails the build otherwise. - No physical CSS directions. Use
padding-inline-start,margin-inline-end,inset-inline-start. RTL/LTR parity is a CI gate. - No
style={{}}for production styling. Use Tailwind tokens or@ghasi/ui-melmastoonprimitives. Inlinestyleonly for token-driven dynamic values (e.g., progress width). - No conditional hooks. Lint enforces.
- Server Components default in Next.js. Add
'use client'only when you need state, effects, or browser APIs. Co-locate the boundary clearly. - All forms use
react-hook-form+ Zod resolver. Validation timing follows docs/frontend/13-forms-patterns.md (when added). - All async UI has explicit loading, empty, and error states from docs/frontend/12-empty-loading-error-states.md (when added). Never an unstyled spinner.
- Electron renderer never imports Node modules. The preload script is the only bridge; IPC channels are typed via
@ghasi/desktop-ipc-contracts.
8. Error handling
| Layer | Pattern |
|---|---|
domain/ | throw only DomainError subclasses; one class per failure mode; carry the canonical error code as a constant. |
application/ | Catch infrastructure errors at adapter boundary; map to DomainError. Never let raw Error escape a use case. |
infrastructure/ | Wrap third-party errors (StripeError, PgError, …) into adapter-specific exceptions; never let them leak to application/. |
presentation/ (HTTP) | Global Nest filter maps DomainError → status code + { error: { code, message, details, requestId } }. |
presentation/ (events) | Failures throw and rely on Pub/Sub retry + DLQ; document max-attempts in services/<name>/FAILURE_MODES.md. |
| Frontend | TanStack Query onError → toast (recoverable) or error boundary (fatal). Always display the canonical error code in the technical-detail expander. |
No silent catches. try/catch without re-throw or explicit handling is forbidden. Use // suppress: <reason> comment if truly intentional.
9. Logging
- Logger:
@ghasi/telemetryre-exports a pre-configuredpinologger; never instantiate your own. - Structured logs only. First arg is a context object, second is the message string.
- Mandatory fields on every log line:
trace_id,tenant_id,request_id,service,env. Wired by global interceptor. - PII bans: never log guest names, card PANs, CVVs, key credential codes, lock vendor secrets, JWTs, refresh tokens, or AI prompts that contain PII. The
pino-redactor config in@ghasi/telemetryenforces. - Levels:
error(alert),warn(degraded),info(lifecycle + business event),debug(developer-only, off in prod). Noconsole.*in shipped code.
10. Comments & documentation
- Don't narrate code. No
// increment counteror// import the module. - Explain non-obvious intent, trade-offs, and constraints the code can't convey: invariants, performance reasons, regulatory drivers, ADR references.
- Public exports (anything in a package's
index.ts) carry a TSDoc block: purpose, parameters, returns, throws, since-version, example. // TODOmust include a Jira ticket:// TODO(MEL-1234): explanation. Lint blocks bare TODOs.// FIXMEis forbidden in main; convert to a TODO with a ticket or fix it.
11. Concurrency, time & randomness
- Time: never call
Date.now()ornew Date()indomain/orapplication/. Inject aClockport. Tests useFakeClock. - UUIDs / ULIDs: never call
crypto.randomUUID()directly in domain. Inject anIdFactoryport. Tests useDeterministicIdFactory. - Random: never
Math.random()in domain. Inject anRNGport. - Async: every awaited operation has a timeout (default
5000 msfor outbound HTTP). No nakedawait fetch(...). - Concurrency on aggregates: optimistic locking via
versioncolumn. Conflicts throwMELMASTOON.GENERAL.CONCURRENT_MODIFICATIONand the use case retries up to 3 times.
12. Performance & memory rules
- No
JSON.parse(JSON.stringify(x))to clone. UsestructuredCloneor a typed mapper. - No N+1 queries. Repository methods accept a batch parameter when used inside a loop. The
query-countertest helper fails specs with > 1 query per use case unless explicitly allowed. - Indexes: every multi-tenant query plan must use a
tenant_id-leading index. EXPLAIN ANALYZE in repo specs is mandatory for read-heavy queries (@ghasi/test-utilsprovides a helper). - Streaming over buffering for files > 10 MB and exports > 10 K rows.
- Bundle budgets enforced per app — see docs/frontend/04-frontend-design-guidelines.md §11.
13. Internationalization in code
- All operator-facing and consumer-facing strings via
@ghasi/i18n. Keys follow<surface>.<feature>.<element>. - Locale-aware formatting through
@ghasi/i18n/format(formatMoney,formatDate,formatList). Never callIntl.*directly. - Per-locale tokens (font stacks, calendar) read from
theme-config-serviceat runtime; never hardcode.
14. Security
- All cross-tenant code paths must construct from a
TenantContextvalue object validated against the JWTtid. Mismatch throwsMELMASTOON.SECURITY.TENANT_MISMATCH. - All write endpoints enforce
Idempotency-Key(16–64 chars, ULID preferred); duplicates return the prior response. - All secrets resolved at startup via Secret Manager + KMS; never via plain env in production. Local dev uses
.env.local(gitignored). - All third-party HTTP clients pin TLS ≥ 1.2 and validate the cert chain.
- All file uploads stream through
file-storage-servicewith virus scan; never write to local disk except for OS-level temp. - Full security DoD in DEFINITION_OF_DONE.md §security. Mandatory
security-revieweragent runs for changes toiam-service,payment-gateway-service,billing-service,lock-integration-service, or anything handling secrets.
15. Git & commit hygiene
| Concern | Rule |
|---|---|
| Branch from | develop |
| Branch name | features/MEL-<n>-<kebab-slug> (feature) · fix/MEL-<n>-<slug> · chore/<slug> · docs/<slug> |
| Commit format | Conventional Commits (feat, fix, refactor, docs, test, chore, perf, ci, build) |
| Ticket prefix | Every commit subject (or footer) includes [MEL-<n>] matching the branch ticket. commitlint enforces. |
| Commit size | One logical change per commit; squash on merge if needed. |
| PR title | MEL-<n>: <Conventional summary> |
| PR body | Rendered from .github/PULL_REQUEST_TEMPLATE.md (mirrors the DoD checklist). |
| Force push | Disallowed on develop and main. Allowed on personal features/* only before review. |
| Rebase vs merge | Rebase from develop before opening PR. Merge into develop is squash. develop → main is merge-commit. |
16. Code review rubric
A reviewer checks (in order):
- Spec alignment — does the change match
services/<name>/*.mdand the linked story's AC? - Layer purity — any boundary violation? Run
pnpm lintif unsure. - Tests — do they exercise behavior, not implementation? Are tenant-isolation, outbox, inbox covered when relevant?
- Security — secrets, PII, RLS, idempotency, error codes.
- Observability — log fields, traces, metrics.
- Frontend — i18n, RTL, a11y, design-system primitives.
- Docs — relevant
*.mdupdated in the same PR. - DoD — every applicable box ticked.
If any of the first three fail, request changes before reading further. Don't pile cosmetic feedback on a structurally wrong PR.
17. Anti-patterns (auto-flagged by the code-reviewer subagent)
- "God service" controllers > 200 LOC.
- Use cases > 80 LOC (split or extract).
- Repository methods returning entities (return DTOs or aggregates only).
if (env === 'prod')branches in business code (use feature flags).- Implicit tenant context (always pass
TenantIdexplicitly). - "Helper" or "utils" files > 100 LOC (decompose).
- Direct DB queries in controllers.
Promise<any>returns.- Component files > 250 LOC (decompose).
- React component with both data fetching and rendering > 100 LOC (split into container + presentation).
- More than three nested ternaries in a single expression.
18. Cross-references
- .cursor/rules/00-core.mdc — universal rules.
- .cursor/rules/20-services.mdc — backend service layer rules.
- .cursor/rules/90-frontend.mdc — frontend rules.
- .cursor/rules/95-ai.mdc — AI integration rules.
- SERVICE_TEMPLATE.md — per-service skeleton.
- TESTING_STANDARDS.md — what "tests pass" means.
- API_PATH_CONVENTIONS.md — URL grammar.
- DEFINITION_OF_DONE.md — the merge checklist.
19. Versioning of this document
This file is part of the spec corpus and is treated as a contract. Any change requires:
- A PR labeled
coding-standards. - Sign-off from at least one engineer per surface (services, web, mobile, desktop) listed in
CODEOWNERS. - An ADR if the change loosens a rule.
- A migration note in
docs/CHANGELOG.md.
A change that tightens a rule applies only to new code; existing code may be flagged but is not retroactively blocked from merging unless the PR also touches it.