Skip to main content

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)

ConcernChoiceNotes
RuntimeNode 20 LTS (services + Electron main + tooling)Pinned in package.json engines.node.
LanguageTypeScript ≥ 5.4, strict: trueAll other strict flags ON (see §2).
Module systemESM ("type": "module") for new codeCommonJS only for legacy adapters that require it; document with a comment.
Package managerpnpm 9.xWorkspace via pnpm-workspace.yaml. No npm, no yarn, no bun.
Monorepo orchestratorTurborepoTask graph in turbo.json.
Backend frameworkNestJS 11Clean / hexagonal layers; see §6.
Web frameworkNext.js (App Router)Server Components default; Client Components opt-in.
Mobile frameworkExpo (React Native, New Architecture, Hermes)No bare workflow without ADR.
Desktop frameworkElectron + Vite + ReactNode 20 main, Chromium renderer, better-sqlite3, ONNX Runtime Node. Never Tauri.
DatabasePostgreSQL 16 (Cloud SQL) + SQLite (desktop) + pgvector + Firestore (sync cursors)One DB per service in cloud (RLS by tenant_id).
ORM / SQLDrizzle (or thin pg)No TypeORM, no Prisma in services.
ValidationZod for DTOs, env, and event payloadsOne source of truth per shape.
Test runnerVitest (services + packages); Jest only where Nest-CLI insistsSee TESTING_STANDARDS.md.
LinterESLint (flat config, eslint.config.mjs at repo root)Includes import/no-restricted-paths to enforce layer boundaries.
FormatterPrettier (workspace config at root)No per-package overrides.
Commit lintcommitlint + HuskyConventional Commits + MEL-\d+ ticket regex (see §15).

Forbidden: Tauri, AWS, Azure, raw fetch in components, raw OpenAI/Anthropic/Google SDK imports outside ai-orchestrator-service, raw lock-vendor SDKs outside lock-integration-service, raw Stripe/PayPal SDKs outside payment-gateway-service, any outside eslint-disable comments 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. Use unknown and narrow. If you truly need any, add // eslint-disable-next-line @typescript-eslint/no-explicit-any with 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. string IDs are forbidden once you cross into domain/. See NAMING.md §Symbols.
  • readonly by 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. Throw DomainError only at use-case boundaries; never inside aggregates.
  • No Date in domain. Use Instant (UTC bigint micro-seconds) and LocalDate value objects from @ghasi/domain-primitives. Reason: timezone bugs and JSON serialization drift.
  • Money is bigint micro-units. Never number, never decimal. The wrapper enforces this at construction.
  • Locale, Currency, Country are 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 from domain/ and @ghasi/domain-primitives.
  • application/ → may import from domain/ and application/. Never from infrastructure/ or presentation/.
  • infrastructure/ → may import from domain/, application/, and itself.
  • presentation/ → may import from application/ and itself. Never from domain/ 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 use import { 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 canonical MELMASTOON.<DOMAIN>.<CODE>. See ERROR_CODES.md.
  • main.ts wires 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-melmastoon primitives. Inline style only 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

LayerPattern
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.
FrontendTanStack 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/telemetry re-exports a pre-configured pino logger; 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/telemetry enforces.
  • Levels: error (alert), warn (degraded), info (lifecycle + business event), debug (developer-only, off in prod). No console.* in shipped code.

10. Comments & documentation

  • Don't narrate code. No // increment counter or // 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.
  • // TODO must include a Jira ticket: // TODO(MEL-1234): explanation. Lint blocks bare TODOs.
  • // FIXME is forbidden in main; convert to a TODO with a ticket or fix it.

11. Concurrency, time & randomness

  • Time: never call Date.now() or new Date() in domain/ or application/. Inject a Clock port. Tests use FakeClock.
  • UUIDs / ULIDs: never call crypto.randomUUID() directly in domain. Inject an IdFactory port. Tests use DeterministicIdFactory.
  • Random: never Math.random() in domain. Inject an RNG port.
  • Async: every awaited operation has a timeout (default 5000 ms for outbound HTTP). No naked await fetch(...).
  • Concurrency on aggregates: optimistic locking via version column. Conflicts throw MELMASTOON.GENERAL.CONCURRENT_MODIFICATION and the use case retries up to 3 times.

12. Performance & memory rules

  • No JSON.parse(JSON.stringify(x)) to clone. Use structuredClone or a typed mapper.
  • No N+1 queries. Repository methods accept a batch parameter when used inside a loop. The query-counter test 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-utils provides 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 call Intl.* directly.
  • Per-locale tokens (font stacks, calendar) read from theme-config-service at runtime; never hardcode.

14. Security

  • All cross-tenant code paths must construct from a TenantContext value object validated against the JWT tid. Mismatch throws MELMASTOON.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-service with virus scan; never write to local disk except for OS-level temp.
  • Full security DoD in DEFINITION_OF_DONE.md §security. Mandatory security-reviewer agent runs for changes to iam-service, payment-gateway-service, billing-service, lock-integration-service, or anything handling secrets.

15. Git & commit hygiene

ConcernRule
Branch fromdevelop
Branch namefeatures/MEL-<n>-<kebab-slug> (feature) · fix/MEL-<n>-<slug> · chore/<slug> · docs/<slug>
Commit formatConventional Commits (feat, fix, refactor, docs, test, chore, perf, ci, build)
Ticket prefixEvery commit subject (or footer) includes [MEL-<n>] matching the branch ticket. commitlint enforces.
Commit sizeOne logical change per commit; squash on merge if needed.
PR titleMEL-<n>: <Conventional summary>
PR bodyRendered from .github/PULL_REQUEST_TEMPLATE.md (mirrors the DoD checklist).
Force pushDisallowed on develop and main. Allowed on personal features/* only before review.
Rebase vs mergeRebase from develop before opening PR. Merge into develop is squash. developmain is merge-commit.

16. Code review rubric

A reviewer checks (in order):

  1. Spec alignment — does the change match services/<name>/*.md and the linked story's AC?
  2. Layer purity — any boundary violation? Run pnpm lint if unsure.
  3. Tests — do they exercise behavior, not implementation? Are tenant-isolation, outbox, inbox covered when relevant?
  4. Security — secrets, PII, RLS, idempotency, error codes.
  5. Observability — log fields, traces, metrics.
  6. Frontend — i18n, RTL, a11y, design-system primitives.
  7. Docs — relevant *.md updated in the same PR.
  8. 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 TenantId explicitly).
  • "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


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.