Skip to content
Back to skills

Backend

ASecurity

Backend development skill for microservices following Clean Architecture, CQRS with a transactional outbox, the repository/use-case pattern, and a Testing-Trophy strategy.

  • 8 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 23, 2026
developmentjavascripttypescriptgojavasqlnodeexpresstestinggitapi

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned September 23, 2026

npx -y skills add mnzralee/claude-multi-agent-architecture --skill backend --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Backend?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Backend
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/mnzralee-backend/badge)](https://www.skillsdirectory.com/skills/mnzralee-backend)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: backend
description: Backend development skill for microservices following Clean Architecture, CQRS with a transactional outbox, the repository/use-case pattern, and a Testing-Trophy strategy.
---

# Backend Development Skill

You are an expert backend developer. This skill provides comprehensive patterns for building enterprise-grade microservices following Clean Architecture principles.

The concrete examples below assume a TypeScript / Node.js / Express stack with Zod for validation and an ORM for persistence, because concrete code is easier to learn from than abstract description. The discipline itself is stack-agnostic: the layering, the dependency direction, the outbox pattern, the validation contract, and the testing strategy translate directly to any language and framework. Where you see a TypeScript idiom, read it as the general principle expressed in one concrete dialect.

---

## Architecture Overview

A typical monorepo layout for this style of system:

```
backend/                                 # Monorepo (e.g. Turborepo, Nx, pnpm workspaces)
├── apps/                                 # Microservices
│   ├── svc-admin/                        # Admin / back-office operations
│   ├── svc-auth/                         # Authentication
│   ├── svc-account/                      # Account management
│   ├── svc-billing/                      # Billing, balances, transfers
│   ├── svc-catalog/                      # Domain entity catalog
│   ├── svc-notification/                 # Notifications, messaging
│   └── svc-<name>/                       # Additional domain services
├── workers/                              # Background workers
│   ├── projector/                        # Event projection into read models
│   └── outbox-submitter/                 # CQRS command submission
├── packages/                             # Shared libraries
│   ├── core-http/                        # HTTP middleware, validation helpers
│   ├── core-db/                          # ORM client
│   ├── core-config/                      # Env-var schema validation
│   ├── core-logger/                      # Structured logging
│   ├── core-errors/                      # Standardized error classes
│   ├── core-events/                      # Event schema registry
│   ├── core-storage/                     # Document storage, file handling
│   ├── core-openapi/                     # OpenAPI validation
│   └── test-utils/                       # Test containers, data factories
└── db/                                   # The schema and migrations
```

Replace the service names with your own domain services. The point is the separation: thin services in `apps/`, asynchronous work in `workers/`, and shared concerns extracted into versioned `packages/` so no cross-cutting logic is copy-pasted between services.

> When a service grows too broad, decompose it along a clear seam rather than letting one service own unrelated concerns. A single service that handles both authentication and identity verification, for example, is usually better split into `svc-auth` and `svc-kyc`. When you split a service, deprecate the old one explicitly so consumers know where the responsibility moved.

---

## Local Development Setup

See `references/local-development-setup.md` for how to run a service locally, port-forward from the cluster, and the port assignment table.

---

## Internal Service Structure (Clean Architecture)

Every service should follow this structure. The dependency rule is the whole point: inner layers know nothing about outer layers. `domain/` depends on nothing. `application/` depends only on `domain/`. `infrastructure/` and `interface/` depend inward. Frameworks, databases, and HTTP live at the edges and are swappable.

```
apps/svc-{name}/src/
├── domain/                    # Core business logic (NO external dependencies)
│   ├── entities/              # Domain entities with behavior
│   ├── value-objects/         # Immutable domain concepts
│   ├── services/              # Domain services (pure logic)
│   ├── events/                # Domain events
│   └── errors/                # Domain-specific errors
│
├── application/               # Application layer (use cases)
│   ├── use-cases/             # Business use cases (feature-grouped)
│   │   ├── auth/              # e.g. login, register, refresh
│   │   └── user/             # e.g. get-profile, update-profile
│   ├── ports/                 # Interfaces (contracts)
│   │   ├── repositories/      # Repository interfaces
│   │   └── services/          # External service interfaces
│   ├── dto/                   # DTOs with schema validation
│   │   ├── request/           # Request DTOs
│   │   └── response/          # Response DTOs
│   └── mappers/               # Entity <-> DTO mappers
│
├── infrastructure/            # External adapters (implementations)
│   ├── repositories/          # ORM repository implementations
│   └── services/              # External service implementations
│
├── interface/                 # HTTP layer
│   ├── controllers/           # THIN controllers
│   ├── routes/                # HTTP routes
│   ├── middlewares/           # HTTP middlewares
│   └── validators/            # Request schemas
│
├── shared/                    # Cross-cutting
│   └── di/                    # Dependency injection
│
├── app.ts                     # App factory
└── index.ts                   # Entry point
```

---

## Core Patterns

### 1. CQRS with Transactional Outbox

When a write must produce a side effect in another system (publishing an event, calling an external service, submitting to a slow downstream), do not call that system inside the request. Instead, write the local state change and an outbox record in the same database transaction, then let a background worker drain the outbox. This guarantees the side effect happens exactly when the state change commits, and survives crashes.

```
WRITE PATH:
  API -> Use Case -> Repository -> db.$transaction([data, outboxCommand]) -> 202 Accepted
  Background: outbox-submitter -> external system / event bus

READ PATH:
  Events -> projector -> read models (e.g. PostgreSQL) -> API queries
```

### 2. Multi-Tenancy

If the system is multi-tenant, ALWAYS filter by the tenant identifier. A single missing filter leaks one tenant's data to another.

```typescript
await db.user.findMany({
  where: { tenantId: req.user!.tenantId, status: 'ACTIVE' },
});
```

### 3. Repository Pattern

The use case depends on an interface (a port). The implementation (an adapter) lives in `infrastructure/`. This keeps the ORM out of the business logic and makes the use case testable without a database.

```typescript
// Port (interface) -- application/ports/repositories
export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  create(user: User): Promise<User>;
}

// Adapter (implementation) -- infrastructure/repositories
export class OrmUserRepository implements IUserRepository {
  async findById(id: string): Promise<User | null> {
    const record = await db.user.findUnique({ where: { id } });
    return record ? UserMapper.toDomain(record) : null;
  }
}
```

### 4. Use Case Pattern

A use case orchestrates one piece of business intent. It validates business rules, builds domain entities, persists through a port, and returns a DTO. It contains no HTTP and no ORM.

```typescript
export class RegisterUseCase {
  constructor(
    private userRepo: IUserRepository,
    private hasher: IPasswordHasher,
  ) {}

  async execute(dto: RegisterRequestDTO): Promise<AuthResponseDTO> {
    // 1. Validate business rules
    const existing = await this.userRepo.findByEmail(dto.email);
    if (existing) throw new UserAlreadyExistsError();

    // 2. Create domain entity
    const user = User.create({ ...dto, passwordHash: await this.hasher.hash(dto.password) });

    // 3. Persist
    const saved = await this.userRepo.create(user);

    // 4. Return DTO
    return UserMapper.toResponseDTO(saved);
  }
}
```

### 5. DTO Validation with a Schema

Validate and coerce input at the boundary with a schema library (Zod shown here; the principle holds for any validator). The inferred type becomes the DTO, so the validation and the type never drift.

```typescript
// application/dto/request/register.dto.ts
export const registerSchema = z.object({
  email: z.string().email().transform(v => v.toLowerCase()),
  password: z.string().min(8).regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/),
  firstName: z.string().min(1).max(100),
  lastName: z.string().min(1).max(100),
});

export type RegisterRequestDTO = z.infer<typeof registerSchema>;
```

### 6. Thin Controllers

Controllers handle only HTTP concerns: read the request, call a use case, shape the response, forward errors. No business logic.

```typescript
export class AuthController {
  constructor(private registerUseCase: RegisterUseCase) {}

  register = async (req: Request, res: Response, next: NextFunction) => {
    try {
      // Validation done by middleware, logic in use case
      const result = await this.registerUseCase.execute(req.body);
      res.status(201).json({ success: true, data: result });
    } catch (error) {
      next(error);
    }
  };
}
```

### 7. Validation Middleware

Validation runs in middleware, before the controller. The controller can assume `req.body` is already valid.

```typescript
// interface/middlewares/validation.middleware.ts
import { validateBody, validateQuery, validateParams } from '@org/core-http';

// In routes
router.post('/register', validateBody(registerSchema), controller.register);
router.get('/users', validateQuery(paginationSchema), controller.listUsers);
router.get('/users/:id', validateParams(idSchema), controller.getUser);
```

### 8. Entity Mapper

Mappers translate between the three representations of an entity: the persistence record, the domain entity, and the response DTO. Each boundary gets an explicit mapping so a schema change cannot silently reshape the API.

```typescript
export class UserMapper {
  // Persistence record -> Domain
  static toDomain(record: UserRecord): User {
    return User.reconstitute({ id: record.id, email: Email.create(record.email), /* ... */ });
  }

  // Domain -> Persistence record
  static toPersistence(entity: User): UserRecord {
    return { id: entity.id, email: entity.email.value, /* ... */ };
  }

  // Domain -> Response DTO
  static toDTO(entity: User): UserProfileDTO {
    return { id: entity.id, email: entity.email.value, /* ... */ };
  }
}
```

---

## Creating a New Feature (Step by Step)

This walks one feature from the inside out: domain first, infrastructure last. Each step lives in its proper layer.

### Step 1: Define the Domain Entity (if new)

```typescript
// domain/entities/feature.entity.ts
export class Feature {
  private constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly tenantId: string,
    public readonly createdAt: Date,
  ) {}

  static create(props: { name: string; tenantId: string }): Feature {
    return new Feature(generateId(), props.name, props.tenantId, new Date());
  }

  static reconstitute(props: FeatureProps): Feature {
    return new Feature(props.id, props.name, props.tenantId, props.createdAt);
  }
}
```

### Step 2: Define the Repository Port

```typescript
// application/ports/repositories/feature.repository.port.ts
export interface IFeatureRepository {
  findById(id: string, tenantId: string): Promise<Feature | null>;
  findAll(tenantId: string, options: PaginationOptions): Promise<Feature[]>;
  create(feature: Feature): Promise<Feature>;
}
export const FEATURE_REPOSITORY = Symbol('FEATURE_REPOSITORY');
```

### Step 3: Define DTOs with a Schema

```typescript
// application/dto/request/create-feature.dto.ts
export const createFeatureSchema = z.object({
  name: z.string().min(2).max(100),
  description: z.string().max(500).optional(),
});
export type CreateFeatureDTO = z.infer<typeof createFeatureSchema>;

// application/dto/response/feature.dto.ts
export interface FeatureResponseDTO {
  id: string;
  name: string;
  createdAt: string;
}
```

### Step 4: Create the Use Case

```typescript
// application/use-cases/feature/create-feature.use-case.ts
@injectable()
export class CreateFeatureUseCase {
  constructor(@inject(FEATURE_REPOSITORY) private featureRepo: IFeatureRepository) {}

  async execute(dto: CreateFeatureDTO, tenantId: string): Promise<FeatureResponseDTO> {
    const feature = Feature.create({ name: dto.name, tenantId });
    const saved = await this.featureRepo.create(feature);
    return FeatureMapper.toDTO(saved);
  }
}
```

### Step 5: Implement the Repository

```typescript
// infrastructure/repositories/orm-feature.repository.ts
@injectable()
export class OrmFeatureRepository implements IFeatureRepository {
  async create(feature: Feature): Promise<Feature> {
    const record = await db.feature.create({
      data: FeatureMapper.toPersistence(feature),
    });
    return FeatureMapper.toDomain(record);
  }
}
```

### Step 6: Create the Controller (THIN)

```typescript
// interface/controllers/feature.controller.ts
@injectable()
export class FeatureController {
  constructor(@inject(CreateFeatureUseCase) private createUseCase: CreateFeatureUseCase) {}

  create = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
    try {
      const result = await this.createUseCase.execute(req.body, req.user!.tenantId);
      res.status(201).json({ success: true, data: result });
    } catch (error) {
      next(error);
    }
  };
}
```

### Step 7: Create the Routes

```typescript
// interface/routes/feature.routes.ts
import { validateBody } from '@org/core-http';

const router = Router();
const controller = container.get(FeatureController);

router.post('/',
  createAuthMiddleware({ jwtSecret: config.jwtSecret }),
  validateBody(createFeatureSchema),
  controller.create
);

export default router;
```

---

## Authentication & Authorization

Centralize auth middleware in a shared package so every service enforces it the same way. Read identity from the verified token, never from the request body.

```typescript
import { createAuthMiddleware, requireRoles, AuthenticatedRequest } from '@org/core-http';

const auth = createAuthMiddleware({ jwtSecret: config.jwtSecret });

// In routes
router.get('/protected', auth, controller.method);
router.post('/admin-only', auth, requireRoles(['ADMIN']), controller.adminMethod);

// In controller
async handler(req: AuthenticatedRequest, res: Response) {
  const userId = req.user!.sub;
  const tenantId = req.user!.tenantId;
  const roles = req.user!.roles;
}
```

---

## Error Handling

### Standard Response Format

Use one envelope for every response so clients can parse success and failure uniformly.

```typescript
// Success
{ success: true, data: T, meta?: { page, limit, total } }

// Error
{ success: false, error: { code: string, message: string, timestamp: string, details?: [] } }
```

### Error Classes (from a shared errors package)

```typescript
import {
  BadRequestError,
  ValidationError,
  UnauthorizedError,
  ForbiddenError,
  NotFoundError,
  ConflictError,
  BusinessRuleError,
  InsufficientBalanceError,
  RateLimitError,
  ServiceUnavailableError,
  InternalError,
} from '@org/core-errors';

// Usage in use cases
if (!user) throw new NotFoundError('User', userId);
if (balance < amount) throw new InsufficientBalanceError(balance, amount);
if (existing) throw new ConflictError('User', 'email', email);
```

A single error-handling middleware maps these classes to status codes, so use cases throw typed errors and never touch HTTP.

### Error Codes

| Code | Status | Usage |
|------|--------|-------|
| VALIDATION_ERROR | 400 | Invalid input |
| UNAUTHORIZED | 401 | Missing/invalid token |
| FORBIDDEN | 403 | Insufficient permissions |
| NOT_FOUND | 404 | Resource not found |
| CONFLICT | 409 | Duplicate resource |
| INSUFFICIENT_BALANCE | 400 | Not enough funds |

---

## Database Operations

```typescript
import { db } from '@org/core-db';

// ALWAYS filter by tenantId in a multi-tenant system
const users = await db.user.findMany({
  where: { tenantId, status: 'ACTIVE' },
  skip: (page - 1) * limit,
  take: limit,
});

// ALWAYS use transactions for multi-table operations
await db.$transaction(async (tx) => {
  const user = await tx.user.update({ /* ... */ });
  const account = await tx.account.create({ /* ... */ });
  await tx.outboxCommand.create({ type: 'CREATE_ACCOUNT', /* ... */ });
  return { user, account };
});
```

---

## CQRS Outbox Pattern

When a write triggers an asynchronous downstream effect, respond `202 Accepted` and queue an outbox command in the same transaction. The caller polls or subscribes for the result.

```typescript
const result = await db.$transaction(async (tx) => {
  // Update local state
  const account = await tx.account.update({ /* ... */ });

  // Queue downstream command
  const command = await tx.outboxCommand.create({
    data: {
      type: CommandType.TRANSFER_FUNDS,
      payload: { fromAccountId, toAccountId, amount: amount.toString() },
      status: OutboxStatus.PENDING,
      tenantId,
      createdBy: userId,
    },
  });

  return { commandId: command.id };
});

return res.status(202).json({
  success: true,
  data: { commandId: result.commandId, status: 'PENDING' },
});
```

---

## Configuration

Validate environment variables at startup with a schema. A misconfigured service should fail loudly on boot, not silently at request time.

```typescript
import { baseServiceSchema, createServiceConfig, z } from '@org/core-config';

// Extend the base schema for service-specific config
const authConfigSchema = baseServiceSchema.extend({
  EMAIL_API_KEY: z.string().min(1),
  OTP_EXPIRY_MINUTES: z.coerce.number().int().positive().default(10),
});

export const config = createServiceConfig(authConfigSchema);
export type AuthConfig = typeof config;
```

---

## Logging

```typescript
import { logger } from '@org/core-logger';

// Structured logging with context
logger.info({ userId, tenantId, action: 'login' }, 'User logged in');
logger.error({ error, userId }, 'Transfer failed');

// NEVER log sensitive data (passwords, tokens, PII)
```

Structured logs are queryable; string logs are not. Always attach the identifiers you would want to filter by later.

---

## Testing Patterns (Testing Trophy)

The Testing Trophy weights integration tests heaviest because they catch the bugs that actually ship: wiring between layers, real database behavior, real serialization. Unit tests cover pure logic; end-to-end tests cover only critical user journeys.

### Test Distribution

| Layer | % | Tool (example) | Tests |
|-------|---|----------------|-------|
| Unit | 10-15% | Vitest / Jest | Pure functions only |
| Integration | 65-75% | Vitest + Testcontainers | Real DB |
| Contract | 10-15% | Pact | API compatibility |
| E2E | 5-10% | Playwright | Critical user journeys only |

See `references/testing-trophy-examples.md` for worked Unit, Integration, and Contract test code examples.

### Test Naming Convention

```
*.unit.spec.ts          # Unit tests
*.integration.spec.ts   # Integration tests
*.contract.spec.ts      # Contract tests
*.pact.spec.ts          # Pact contract tests
```

### What NOT to Test with Unit Tests

- Controllers (they just call use cases)
- Repositories (they just call the ORM)
- Anything that requires mocking the database (write an integration test instead)

---

## Validation Checklist

Before completing ANY backend task:

**Architecture:**
- [ ] Domain logic in `domain/` layer (no I/O)
- [ ] Use cases in `application/` layer
- [ ] Repositories implement interfaces from `ports/`
- [ ] Controllers are THIN (delegate to use cases)

**Code Quality:**
- [ ] DTOs validated with a schema
- [ ] Entity mappers for persistence <-> domain <-> DTO
- [ ] Structured logging at key points
- [ ] No sensitive data in logs

**Security:**
- [ ] Auth middleware from the shared HTTP package
- [ ] tenantId filtering on ALL queries (multi-tenant systems)
- [ ] Input sanitization

**Data Integrity:**
- [ ] Transactions for multi-table operations
- [ ] Outbox pattern for asynchronous downstream effects
- [ ] Proper status codes (201 create, 202 async)

**Testing:**
- [ ] Unit tests for pure business logic
- [ ] Integration tests with a real database (Testcontainers)
- [ ] Contract tests if consuming another service's API

---

## Common Mistakes to Avoid

1. **Fat controllers**: move logic to use cases.
2. **Missing tenantId**: always filter by tenant in a multi-tenant system.
3. **Direct ORM calls in controllers**: go through repositories.
4. **Validation in controllers**: use schema middleware.
5. **Duplicated auth middleware**: import from the shared HTTP package.
6. **200 for async operations**: use 202 Accepted.
7. **Logging secrets**: never log passwords, tokens, or PII.
8. **Mocking databases**: use a disposable real database (Testcontainers) instead.

---

## Quick Reference

```typescript
// Imports (replace @org/* with your workspace package scope)
import { db } from '@org/core-db';
import { logger } from '@org/core-logger';
import { createAuthMiddleware, requireRoles, AuthenticatedRequest, validateBody } from '@org/core-http';
import { NotFoundError, ConflictError, InsufficientBalanceError } from '@org/core-errors';
import { baseServiceSchema, createServiceConfig, z } from '@org/core-config';
import { setupTestDatabase, UserFactory, AccountFactory } from '@org/test-utils';
import { CommandType, OutboxStatus } from '<your-orm-client>';

// Common models (replace with your domain)
db.user, db.account, db.transaction, db.outboxCommand
```

---

## Production-Grade Best Practices

These conventions are non-negotiable for new code. They emerge from a recurring pattern: choices that look harmless at small scale (a `number` field, a bare timestamp, a service-layer-only tenant filter) become silent data-corruption or data-leak bugs at production scale. Each rule below trades a little upfront discipline for a class of bug you will never have to debug.

Capture the rationale for major decisions in `docs/adrs/` (architecture decision records) so the team has a durable record of why, not just what.

### Money and Decimal Amounts

**Never use a floating-point `number` for monetary fields.** IEEE 754 doubles have 53 bits of integer precision; any amount above 2^53 minor units (~9 × 10^15) silently loses precision when crossing the JSON-parse boundary. This is the single most common money bug in JavaScript backends.

The required pattern mirrors the minor-unit conventions used by Stripe, Adyen, and major ledger systems: represent money as an integer count of the smallest unit, validate it as a string, and never let a float touch it.

```typescript
// DTO: regex-string validation (no float ever enters the system)
const transferSchema = z.object({
  amount: z.string().regex(
    /^\d+(\.\d{1,6})?$/,
    'Amount must be a positive decimal with up to 6 fractional digits',
  ),
  // ...
});

// Use case (application layer): parse at the boundary into a precise type
async execute(dto: TransferDTO): Promise<Result> {
  const amount = Money.from(dto.amount);
  if (!amount.isPositive()) {
    throw new InvalidAmountError(dto.amount, 'must be greater than zero');
  }
  // arithmetic only on the precise Money / BigInt type from here onward
}
```

Use a money value object backed by scaled `BigInt` (no floating-point on the hot path). Typical operations: `from`, `toString`, `toBigInt`, `isZero`, `isPositive`, `isGreaterThan`, `isLessThanOrEqual`, `add`, `subtract`. Outbox payloads and wire format serialize via `amount.toString()`: strings are the canonical wire format for money.

Pick the precision your domain requires (for example, currencies with 2 decimal places, or higher-precision domains needing 6 or more) and make it a single documented constant. The persistence column should match that precision exactly, for example a fixed-point `Decimal(36, 6)` for a 6-decimal domain. Never store money as `Float` (IEEE 754 lossy) and never as a plain integer if the domain has fractional units it cannot represent. The value object may use a higher internal scale (for example 18 decimals) to avoid rounding during multi-step calculations, while all persisted and wire-format values use the canonical domain precision.

Banker's rounding (`HALF_EVEN`) is the financial-systems consensus. Declare it once per service at startup:

```typescript
import Decimal from 'decimal.js';
Decimal.set({ rounding: Decimal.ROUND_HALF_EVEN });
```

### DTO <-> Schema Alignment

The validation contract (the request schema) and the persistence contract (the database schema) must stay in lockstep. Drift between them is silent: tests using DTO-shaped fixtures never surface a persistence-layer mismatch, so the bug only appears in production.

Rules for new code:

1. **Phantom DTO fields are forbidden.** Every field in a DTO must map to a column on the model the use case writes to. If you collect data the model cannot store, either add the column (with a migration) or drop the field from the DTO.
2. **Required-in-DTO implies required-in-model.** If the DTO marks a field non-optional, the persistence model must also be non-optional (or a CHECK constraint must enforce conditional non-null when the entity reaches the relevant state).
3. **Length constraints align.** A `max(N)` string in the schema should match the column's `VarChar(N)`. Prefer widening the model in a migration over tightening the DTO.
4. **Field names align.** No `deviceOs` in the DTO mapped to `osVersion` in the model. Use identical names. Renames are breaking changes that must be coordinated with API consumers.
5. **Enums stay in lockstep.** Import the persistence enum directly into the schema (for example via a native-enum validator) to eliminate manual transcription drift.

### Identifiers

- **UUID v4** is acceptable for existing tables. Do not migrate just to migrate.
- **UUID v7** (RFC 9562, May 2024) is the new-table default. It is time-ordered, which preserves index locality on high-write tables. Generate it at the database or application layer depending on your ORM's support.
- **Prefixed IDs** in the style of `cus_xxx` or `ord_xxx` are encouraged for human-debuggability when an ID will appear in logs and support tickets. The convention is `<short-type-code>_<base32-payload>`.

### Time and Timestamps

PostgreSQL guidance has been consistent for over a decade: **always use `TIMESTAMPTZ`, never bare `TIMESTAMP`.** Bare `TIMESTAMP` stores no timezone, which leaves distributed-system reconciliation ambiguous.

```
createdAt   TIMESTAMPTZ   default now()
updatedAt   TIMESTAMPTZ   on update now()
```

Store UTC at the boundary; render in the user's timezone in the UI. Never store local time anywhere.

### Tenant Isolation

Service-layer `tenantId` filtering (the pattern shown above) is industry-standard but is one forgotten `WHERE tenant_id = ?` away from a cross-tenant data leak. The defense-in-depth layer is **Postgres Row-Level Security (RLS)**, which makes accidental cross-tenant reads structurally impossible:

```sql
ALTER TABLE "Account" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "Account"
  USING (tenant_id::text = current_setting('app.current_tenant_id', true));
```

Set the session variable in middleware before each request handler runs.

### Sensitive Data

- **PII fields** (email, phone, date of birth, national identifiers, biometric hashes, full names): protect with column-level encryption (for example `pgcrypto`) or application-layer envelope encryption backed by a KMS. Never store PII plaintext at rest. Treat every new table as a chance to encrypt by default.
- **Secrets** (API keys, passwords, OAuth secrets): hash with **argon2id** (OWASP 2024 recommendation). Bcrypt is acceptable for legacy compatibility but not for new code. The field name should reflect that it stores a hash: `passwordHash`, not `password`.
- **GDPR Article 17 erasure**: soft delete (set `deletedAt`) plus scheduled anonymization (zero out PII, retain row shape for referential integrity, hard-delete after the retention window). Pure soft-delete leaves PII queryable and is non-compliant.

### CQRS Outbox Discipline

Every outbox `create(...)` call must be inside either:

- A `db.$transaction(...)` callback, or
- An atomic-transaction port callback (the port pattern that wraps the transaction internally).

If you have a custom lint rule, enforce this mechanically: an outbox write outside a transaction is a latent data-consistency bug that a lint rule catches at authoring time.

The outbox row should always include `tenantId`, `service` (the producing service name, for example `'svc-billing'`), `requestId` (deterministic, typically `${entityId}-${commandType}` so crash-retries are idempotent), `commandType`, and `payload`. A missing `tenantId`, `service`, or `requestId` will fail at runtime when the projector dispatches.

### Pre-commit and Pre-push Discipline

A three-tier verification ladder keeps the feedback loop fast while still catching everything before CI:

| Tier | Latency target | Scope |
|------|----------------|-------|
| Pre-commit | under 5s | `lint-staged` runs `eslint --fix` + `prettier --write` on changed files only |
| Pre-push | under 60s | `npm run verify` runs `lint`, `type-check`, and `test` workspace-wide |
| CI | under 10min | Full repo lint + type-check + test + build + security-scan + dependency audit |

Configure via your git-hooks tooling (for example Husky `pre-commit` and `pre-push`). `--no-verify` is reserved for genuine emergencies: fix the underlying error rather than bypass the gate.

Empty-test packages must pass with no tests (for example `--passWithNoTests`). Manual scripts that are not real tests must not match the test glob; rename them (for example `*.manual-script.ts`) or move them out of the test directory.

### Test-Runner Alignment

When a codebase migrates between test runners, mixing one runner with another's test syntax causes the transform to choke on ESM imports. Any package whose `*.spec.ts` files import from a given runner must declare that runner in its `package.json` test script (for example `"test": "vitest run --passWithNoTests"`). Keep the runner and the test syntax consistent per package.

### Local-CI Parity

The CI gate (lint, type-check, test, build, security-scan) and the local `npm run verify` script must match one-for-one. If a check fires in CI but not in `verify`, that gap costs the team a CI round-trip on every pull request. Keep the two in sync deliberately.

### Documentation Standards

- Capture new work as a narrative in a dated work record, for example `docs/workrecords/work-record-YYYY-MM-DD.md`. Append-only.
- Architectural decisions go into `docs/adrs/` as decision records or design studies.
- Durable industry research and reference material go into a dedicated docs folder (for example `docs/references/`).
- Each new module or service specification follows a shared spec template kept under `.claude/templates/`.

### Commit Discipline

- File-by-file commits. Each commit is one logical change.
- Conventional Commits format: `type(scope): subject`, with `type` in {feat, fix, refactor, chore, docs, test, perf, security, ci}.
- Imperative mood (`add`, not `added`).
- Subject line at most 72 characters; the body explains why, not what.
- Never commit secrets.
- Follow your team's policy on AI attribution in commit messages.
- Never use emojis in commit messages.

---

## Reference Files

This skill's on-demand reference material, kept out of the core workflow so it is not injected on every invocation:

- `references/local-development-setup.md` -- how to run a service locally, port-forward from the cluster, and the port assignment table. Read when setting up or troubleshooting the local dev environment.
- `references/testing-trophy-examples.md` -- worked Unit, Integration, and Contract test code examples. Read when writing the actual test files for a feature.

---

## Apply to: $ARGUMENTS

Files in this skill

  • SKILL.md31.8 KB
  • references/local-development-setup.md1.6 KB
  • references/testing-trophy-examples.md2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…