Skip to main content

Coding Standards

Asset360 v3 follows strict coding standards to ensure code quality, maintainability, and consistency across the codebase.

General Principles

Clean Code Always

Every line of code should follow clean code principles:

  • Meaningful names: Variables, functions, and classes should clearly express their purpose
  • Small functions: Functions should do one thing well
  • Single Responsibility: Each module/class has one reason to change
  • Don't Repeat Yourself (DRY): Avoid code duplication
  • Comments explain why, not what: Code should be self-explanatory

JSDoc Documentation Required

All public methods MUST have JSDoc comments:

/**
* Creates a new fund with validation and default fee configuration
*
* @param data - Fund creation data including name, code, and organization ID
* @returns Promise resolving to the created fund entity
* @throws Error if fund code already exists or validation fails
*
* @example
* ```typescript
* const fund = await fundService.createFund({
* name: "Growth Fund",
* code: "GRF01",
* organizationId: 'org-1',
* startDate: new Date(),
* });
* ```
*/
async createFund(data: CreateFundRequest): Promise<FundEntity> {
// Implementation
}

Domain-Driven Design

Each domain resides in its own folder in worker/services/ with a barrel file (index.ts) defining its public API:

GOLDEN RULES:

  1. Cross-domain imports MUST use barrel files (index.ts)
  2. Cross-domain dependencies MUST be injected via constructor
// ❌ FORBIDDEN - Direct import from internal files
import { FundService } from "../fund/service";
import { FundEntity } from "../fund/domain";

// ✅ CORRECT - Import from barrel file
import type { FundService, FundEntity } from "../fund";

export class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService?: FundService, // Injected dependency
) {}
}

ESLint automatically enforces barrel imports - direct imports from domain.ts, service.ts, or repository.ts will fail the linter.

See Barrel Files and Domain Isolation for details.

TypeScript Best Practices

No any Type

Never use any type. Use proper types or unknown:

// ❌ BAD
function process(data: any) {
return data.value;
}

// ✅ GOOD
function process(data: ProcessInput) {
return data.value;
}

// ✅ ACCEPTABLE for truly unknown data
function process(data: unknown) {
if (typeof data === "object" && data !== null && "value" in data) {
return (data as { value: string }).value;
}
throw new Error("Invalid data");
}

Explicit Type Annotations

Annotate function parameters and return types:

// ❌ BAD
function calculateFee(amount, rate) {
return amount * rate;
}

// ✅ GOOD
function calculateFee(amount: number, rate: number): number {
return amount * rate;
}

Use const for Immutable Data

// ❌ BAD
let PI = 3.14159;
PI = 3.14; // Oops, changed it!

// ✅ GOOD
const PI = 3.14159;

Use Interfaces for Object Shapes

// ✅ GOOD
interface FundEntity {
id: number;
name: string;
code: string;
organizationId: number;
}

// Also good for function types
interface CalculateFee {
(amount: number, rate: number): number;
}

Avoid Type Assertions

Type assertions (as) should be rare:

// ❌ BAD - hiding type issues
const value = data as SomeType;

// ✅ GOOD - validate and narrow
function isSomeType(data: unknown): data is SomeType {
return typeof data === "object" && data !== null && "property" in data;
}

if (isSomeType(data)) {
// TypeScript knows data is SomeType here
console.log(data.property);
}

Code Organization

File Structure

Each domain follows this structure:

worker/services/{domain}/
├── domain.ts # Types, interfaces, entities
├── repository.ts # Data access layer
├── service.ts # Business logic layer
└── index.ts # Public exports

Import Order

Organize imports in this order:

// 1. Node.js built-ins
import { randomUUID } from "crypto";

// 2. External packages
import { eq, and } from "drizzle-orm";

// 3. Internal shared modules
import { Money } from "@shared/money";
import { applyBpsToMinor } from "@shared/financial-calculations";

// 4. Internal modules (relative imports)
import type { FundEntity, CreateFundRequest } from "./domain";
import { FundRepository } from "./repository";

// 5. Type-only imports last
import type { ProductionDBClient } from "@worker/db/client";

Export Strategy

Always use named exports, never default exports:

// ❌ BAD
export default class FundService {}

// ✅ GOOD
export class FundService {}

// ✅ GOOD - re-export from index
// worker/services/fund/index.ts
export { FundService } from "./service";
export { FundRepository } from "./repository";
export type { FundEntity, CreateFundRequest } from "./domain";

Naming Conventions

Variables and Functions

Use camelCase:

const userName = "John Doe";
const accountBalance = 1000;

function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}

Classes and Interfaces

Use PascalCase:

class FundService {}
interface FundEntity {}
type CreateFundRequest = {};

Constants

Use UPPER_SNAKE_CASE for true constants:

const DAYS_PER_YEAR = 365;
const MONTHS_PER_YEAR = 12;
const BASIS_POINTS_SCALE = 10000;

Private Members

Use private keyword, not underscore prefix:

class FundService {
// ❌ BAD
private _repository: FundRepository;

// ✅ GOOD
private repository: FundRepository;

constructor(repository: FundRepository) {
this.repository = repository;
}
}

Boolean Variables

Use is, has, should, or can prefixes:

const isValid = true;
const hasPermission = false;
const shouldProcess = true;
const canDelete = false;

Function Guidelines

Small Functions

Functions should be small (ideally < 20 lines):

// ❌ BAD - too long, does too much
async function processOrder(order: Order) {
// 100 lines of code...
}

// ✅ GOOD - broken into smaller functions
async function processOrder(order: Order) {
await validateOrder(order);
const payment = await processPayment(order);
await updateInventory(order);
await sendConfirmation(order, payment);
}

async function validateOrder(order: Order) {
// Validation logic
}

async function processPayment(order: Order) {
// Payment logic
}

Single Responsibility

Each function should have one clear purpose:

// ❌ BAD - does multiple things
function processAndSendEmail(data: Data) {
const result = processData(data);
sendEmail(result);
logResult(result);
}

// ✅ GOOD - separate functions
function processData(data: Data): ProcessedData {
return transform(data);
}

function sendEmail(data: ProcessedData): void {
// Email logic
}

function logResult(data: ProcessedData): void {
// Logging logic
}

Parameter Count

Limit function parameters (max 3-4):

// ❌ BAD - too many parameters
function createAccount(
name: string,
email: string,
phone: string,
address: string,
city: string,
country: string,
) {}

// ✅ GOOD - use an object
interface CreateAccountData {
name: string;
email: string;
phone: string;
address: string;
city: string;
country: string;
}

function createAccount(data: CreateAccountData) {}

Return Early

Use early returns to reduce nesting:

// ❌ BAD - nested conditions
function processItem(item: Item | null) {
if (item) {
if (item.isValid) {
if (item.inStock) {
return process(item);
}
}
}
return null;
}

// ✅ GOOD - early returns
function processItem(item: Item | null) {
if (!item) return null;
if (!item.isValid) return null;
if (!item.inStock) return null;

return process(item);
}

Error Handling

Throw Meaningful Errors

// ❌ BAD
throw new Error("Error");

// ✅ GOOD
throw new Error(`Fund with code ${code} already exists`);

Don't Swallow Errors

// ❌ BAD
try {
await riskyOperation();
} catch (error) {
// Silent failure
}

// ✅ GOOD
try {
await riskyOperation();
} catch (error) {
console.error("Failed to perform risky operation:", error);
throw new Error(`Operation failed: ${error.message}`);
}

Prefer Neverthrow Results in Services

  • Domain services MUST return Result<Success, DomainError>; do not throw from happy-path flows.
  • Use helpers from worker/services/shared/domain-errors.ts (createValidationError, createNotFoundError, createAuthorizationError, createForbiddenError, createConflictError) to describe failures.
  • In TRPC routers and use cases, unwrap Results via executeResult() / .match() rather than _unsafeUnwrap().
  • When wrapping unknown errors, call toBusinessLogicError(error, "Fallback message") to preserve context without leaking internal details.
  • Tests should assert Results with explicit expectations (e.g. expect(result.isOk()).toBe(true) or custom helpers) instead of relying on thrown exceptions.

CRITICAL: Always Check Result Types (Avoid Silent Failures)

NEVER ignore Result types from service calls. This is the most common source of silent failures.

// ❌ CRITICAL ERROR - Silent failure (Result ignored)
async createBankAccount(data: CreateBankAccountData): Promise<Result<void, DomainError>> {
const bankAccount = await this.repository.create(data);

// DANGER: createAccount returns Result but we ignore it!
await this.accountingService.createAccount({
fundId: bankAccount.fundId,
code: `AST-BANK-${bankAccount.accountNumber}`,
name: `Bank Account - ${bankAccount.accountNumber}`,
type: "asset",
});

return ok(undefined); // Returns success even if ledger creation failed!
}

// ✅ CORRECT - Check Result and propagate errors
async createBankAccount(data: CreateBankAccountData): Promise<Result<void, DomainError>> {
const bankAccount = await this.repository.create(data);

const createAccountResult = await this.accountingService.createAccount({
fundId: bankAccount.fundId,
code: `AST-BANK-${bankAccount.accountNumber}`,
name: `Bank Account - ${bankAccount.accountNumber}`,
type: "asset",
});

if (createAccountResult.isErr()) {
logger.error(
"Failed to create bank account ledger account",
bankAccount.fundId,
bankAccount.id,
createAccountResult.error,
);
return err(createAccountResult.error);
}

logger.debug(
"Bank account ledger account created",
bankAccount.fundId,
bankAccount.id,
{ ledgerAccountId: createAccountResult.value.id },
);

return ok(undefined);
}

Key Rules:

  1. ALWAYS assign Result to a variable: const result = await service.method()
  2. ALWAYS check .isErr() before proceeding: if (result.isErr()) { ... }
  3. Log errors with context: Include entity IDs, operation details
  4. Propagate errors up the call chain: return err(result.error)
  5. Methods returning Promise<void> are suspicious: Should usually be Promise<Result<void, DomainError>>

Common Patterns to Avoid:

// ❌ WRONG - Ignoring Result in loop
for (const item of items) {
await service.processItem(item); // If processItem returns Result, this ignores it!
}

// ✅ CORRECT - Check each Result
for (const item of items) {
const result = await service.processItem(item);
if (result.isErr()) {
logger.error("Failed to process item", item.id, result.error);
return err(result.error);
}
}

// ❌ WRONG - Void method that calls Result-returning methods
async doSomething(): Promise<void> {
await this.service.operation(); // Ignores potential errors
}

// ✅ CORRECT - Return Result and check
async doSomething(): Promise<Result<void, DomainError>> {
const result = await this.service.operation();
if (result.isErr()) {
return err(result.error);
}
return ok(undefined);
}

Validate Early

async createFund(data: CreateFundRequest): Promise<FundEntity> {
// Validate first
this.validateFundName(data.name);
this.validateFundCode(data.code);
await this.validateUniqueCode(data.code);

// Then proceed with creation
return this.repository.create(data);
}

Comments

When to Comment

Comment the why, not the what:

// ❌ BAD - comments the obvious
// Loop through items
for (const item of items) {
// Add item price to total
total += item.price;
}

// ✅ GOOD - explains why
// Apply bulk discount only for orders over 100 items to encourage large purchases
if (items.length > 100) {
total *= 0.9;
}

JSDoc for Public APIs

All public methods need JSDoc:

/**
* Calculates daily interest for a bank account using simple interest formula
*
* Formula: Daily Interest = Balance × (Annual Rate / Days Per Year)
*
* @param account - Bank account entity with balance and interest rate
* @returns Daily interest amount in minor units (cents)
*/
private calculateDailyInterest(account: BankAccountEntity): number {
const dailyRate = percentageToDecimal(account.interestRate) / DAYS_PER_YEAR;
return Math.round(account.currentBalance * dailyRate);
}

Formatting

Use Prettier/ESLint

Code is automatically formatted by Prettier and linted by ESLint. Configuration is in:

  • eslint.config.mjs
  • .prettierrc (if exists)

Indentation

Use 2 spaces (configured in editor):

function example() {
if (condition) {
doSomething();
}
}

Line Length

Keep lines under 100 characters when possible.

Blank Lines

Use blank lines to separate logical sections:

async createFund(data: CreateFundRequest): Promise<FundEntity> {
// Validation section
this.validateFundName(data.name);
this.validateFundCode(data.code);
await this.validateUniqueCode(data.code);

// Data preparation section
const fundData = {
...data,
fees: data.fees || this.createDefaultBangladeshFees(),
};

// Persistence section
return this.repository.create(fundData);
}

Async/Await

Always Await Promises

// ❌ BAD
function getData() {
return api.fetch(); // Returns Promise, not data!
}

// ✅ GOOD
async function getData() {
return await api.fetch();
}

Error Handling with Async

async function processData() {
try {
const data = await fetchData();
return await transformData(data);
} catch (error) {
console.error("Processing failed:", error);
throw error;
}
}

Testing

Write Tests for Public Methods

Every public method should have tests:

describe("FundService", () => {
describe("createFund", () => {
it("creates fund with valid data", async () => {
const fund = await service.createFund(validData);
expect(fund.id).toBeDefined();
});

it("throws error for duplicate code", async () => {
await service.createFund(validData);
await expect(service.createFund(validData)).rejects.toThrow(
"already exists",
);
});
});
});

Performance Considerations

Avoid Premature Optimization

Write clear code first, optimize when needed.

Use Efficient Data Structures

// ❌ BAD - O(n) lookup
const items = [...];
const found = items.find(item => item.id === targetId);

// ✅ GOOD - O(1) lookup if used multiple times
const itemsMap = new Map(items.map(item => [item.id, item]));
const found = itemsMap.get(targetId);

Batch Database Operations

// ❌ BAD - multiple queries
for (const item of items) {
await db.insert(table).values(item);
}

// ✅ GOOD - single batch query
await db.insert(table).values(items);

Date Handling

ALL dates in the application use ISO 8601 strings - never use Date objects in domain entities, tRPC procedures, or database operations.

Correct Usage

// ✅ CORRECT - Use ISO 8601 strings throughout
const createdAt = "2025-10-28T14:39:44.726Z";
const startDate = "2025-01-01T00:00:00.000Z";
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();

// Domain entity interfaces
export interface UserEntity {
id: string;
email: string;
createdAt: string; // ISO 8601 string
updatedAt: string; // ISO 8601 string
}

// Repository mappers return strings
private mapUser(dbUser: any): UserEntity {
return {
id: dbUser.id,
email: dbUser.email,
createdAt: dbUser.createdAt || new Date().toISOString(),
updatedAt: dbUser.updatedAt || new Date().toISOString(),
};
}

// Date comparisons and calculations
const isExpired = new Date(session.expiresAt) < new Date();
const daysDiff = Math.floor((new Date(endDate).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24));

Incorrect Usage

// ❌ WRONG - Don't use Date objects in domain entities
export interface BadUserEntity {
createdAt: Date; // FORBIDDEN - use string
updatedAt: Date; // FORBIDDEN - use string
}

// ❌ WRONG - Don't use Date objects in tRPC procedures
const badProcedure = publicProcedure.query(() => {
return { createdAt: new Date() }; // FORBIDDEN - use string
});

Why ISO 8601 Strings?

  • Database consistency: Cloudflare D1 stores dates as strings
  • tRPC serialization: No need for superjson transformer
  • Frontend compatibility: Easy to parse with new Date(isoString)
  • Type safety: Clear distinction between date strings and other strings
  • Timezone handling: ISO 8601 includes timezone information

See Also