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:
- Cross-domain imports MUST use barrel files (
index.ts) - 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:
- ALWAYS assign Result to a variable:
const result = await service.method() - ALWAYS check
.isErr()before proceeding:if (result.isErr()) { ... } - Log errors with context: Include entity IDs, operation details
- Propagate errors up the call chain:
return err(result.error) - Methods returning
Promise<void>are suspicious: Should usually bePromise<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