Skip to main content

Domain-Driven Design Approach

Asset360 v3 strictly follows Domain-Driven Design (DDD) principles to create a maintainable, scalable, and testable codebase.

What is Domain-Driven Design?

Domain-Driven Design is a software development approach that focuses on:

  1. Modeling the business domain accurately in code
  2. Creating a ubiquitous language shared by developers and domain experts
  3. Defining bounded contexts with clear boundaries
  4. Isolating domain logic from infrastructure concerns

Our Domain Model

Identified Domains

Asset360 v3 is organized into the following domains:

Core Domains

  1. Fund (worker/services/fund/)

    • Central domain managing investment funds
    • Handles fund lifecycle, fees, NAV calculations
    • Dependencies: None (foundational domain)
  2. Accounting (worker/services/accounting/)

    • Double-entry accounting system
    • Chart of accounts, journal entries, financial reports
    • Dependencies: None (foundational domain)
  3. Investor (worker/services/investor/)

    • Investor registration and management
    • Unit purchases, redemptions, holdings
    • Dependencies: None at domain level (uses Fund via coordinator)

Supporting Domains

  1. Bank Account (worker/services/bank-account/)

    • Bank account management and transactions
    • Interest calculations
    • Dependencies: Fund, Accounting
  2. FDR (worker/services/fdr/)

    • Fixed Deposit Receipt management
    • Maturity calculations, interest accrual
    • Dependencies: Fund
  3. Portfolio (worker/services/portfolio/)

    • Equity (portfolio/equity/): Equity securities management
    • Bond (portfolio/bond/): Bond securities management
    • Market data integration, valuations
    • Dependencies: Fund
  4. Organization (worker/services/organization/)

    • Multi-tenant organization management
    • Organization hierarchy
    • Dependencies: None (foundational domain)
  5. Auth (worker/services/auth/)

    • Authentication and authorization
    • User management, roles, permissions
    • OAuth2 integration
    • Dependencies: Organization

Domain Structure

Each domain follows a consistent structure:

worker/services/{domain}/
├── domain.ts # Domain entities and types
├── repository.ts # Data access layer
├── service.ts # Business logic layer
└── index.ts # Public API (barrel file) ⭐

index.ts - Public API (Barrel File)

The index.ts file serves as the public API for each domain, exposing only what other domains should access:

  • Enforces encapsulation: Internal implementation details remain private
  • Defines boundaries: Clear separation between public and private APIs
  • Enables refactoring: Internal changes don't affect consumers
  • ESLint-enforced: Cross-domain imports must use the barrel file
// Example from fund/index.ts
export type {
CreateFundRequest,
FundEntity,
FundFeesJson,
UpdateFundRequest,
} from "./domain";

export { FundService } from "./service";
export { FundRepository } from "./repository";

domain.ts - Domain Layer

Contains:

  • Entities: Core business objects (e.g., FundEntity, InvestorDto)
  • Value Objects: Immutable domain concepts (e.g., Money, FundFeesJson)
  • Domain Types: Enums and type unions (e.g., BankAccountType)
  • Command/Request Types: Input structures for operations
// Example from fund/domain.ts
export interface FundEntity {
id: number;
name: string;
code: string;
organizationId: number;
startDate: Date;
fees: FundFeesJson | null;
eodDate: Date | null;
}

export interface CreateFundRequest {
name: string;
code: string;
organizationId: number;
startDate: Date;
fees?: FundFeesJson;
}

repository.ts - Data Access Layer

Contains:

  • Database queries using Drizzle ORM
  • CRUD operations
  • Complex queries specific to the domain
  • No business logic
// Example repository pattern
export class FundRepository {
constructor(private db: ProductionDBClient) {}

async create(data: CreateFundRequest): Promise<FundEntity> {
const [fund] = await this.db
.insert(fundsTable)
.values({...})
.returning();
return fund;
}

async findById(id: number): Promise<FundEntity | null> {
const [fund] = await this.db
.select()
.from(fundsTable)
.where(eq(fundsTable.id, id));
return fund || null;
}
}

service.ts - Business Logic Layer

Contains:

  • Business rules and validations
  • Domain operations
  • Orchestration of repository calls
  • Calculations and transformations
// Example service pattern
export class FundService {
constructor(private repository: FundRepository) {}

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

// Business logic
const fundData = {
...data,
fees: data.fees || this.createDefaultBangladeshFees(),
};

// Delegate to repository
return this.repository.create(fundData);
}

private validateFundCode(code: string): void {
if (!code || !/^[A-Z0-9-]{3,20}$/.test(code)) {
throw new Error("Fund code must be 3-20 alphanumeric characters");
}
}
}

Ubiquitous Language

We use domain-specific terminology consistently across code, documentation, and communication:

Financial Terms

  • NAV (Net Asset Value): Total assets minus liabilities
  • Unit: A share in the fund (like a mutual fund unit)
  • Purchase: Buying units (subscription)
  • Redemption: Selling units back to the fund
  • Holding: An investor's position in a fund
  • FDR: Fixed Deposit Receipt
  • EOD: End-of-Day processing

Accounting Terms

  • Chart of Accounts: The list of all accounts
  • Journal Entry: A record of a financial transaction
  • Debit/Credit: Double-entry accounting convention
  • Trial Balance: Summary of all account balances
  • Accrual: Recognition of revenue/expense before cash exchange

Domain Operations

  • Register (not "create"): Used for investor registration
  • Setup (not "create"): Used for fund initialization
  • Accrue (not "add"): Used for interest accrual
  • Settle (not "complete"): Used for transaction settlement

Bounded Contexts

Each domain represents a bounded context with clear boundaries:

Context Boundaries

Context Integration

Contexts integrate through:

  1. Shared Kernel: Common types in shared/ (Money, financial calculations)
  2. Service Dependencies: Injected via factory
  3. Coordinators: Orchestrate cross-context operations

Domain Isolation Rules

The Golden Rule

Files in one domain MUST NEVER directly import implementation files from another domain.

All cross-domain imports MUST use the domain's barrel file (index.ts):

// ❌ 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";

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

ESLint Enforcement

The no-restricted-imports ESLint rule automatically enforces this pattern:

# This will trigger an ESLint error:
import { FundService } from './fund/service';

# Error message:
# Import from domain barrel (index.ts) instead of internal files.
# Use "./fund" not "./fund/service" or "./fund/domain".

Why This Rule?

  1. Prevents circular dependencies
  2. Makes dependencies explicit
  3. Enables independent testing
  4. Allows future extraction to microservices
  5. Maintains clear boundaries

How to Cross Boundaries

Option 1: Dependency Injection (Preferred)

// Service declares dependency in constructor
class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService?: FundService,
private accountingService?: AggregateAccountingService,
) {}
}

// Factory wires dependencies
const bankAccountService = new BankAccountService(
bankAccountRepository,
fundService, // Injected
accountingService, // Injected
);

Option 2: Through Coordinators

// For complex multi-domain operations
class FundSetupCoordinator {
constructor(
private fundService: FundService,
private bankAccountService: BankAccountService,
private investorService: InvestorService,
private accountingService: AggregateAccountingService
) {}

async setupFund(request: FundSetupRequest) {
// Orchestrate cross-domain operation
const fund = await this.fundService.createFund({...});
const account = await this.bankAccountService.createBankAccount({...});
await this.ensureFundAccounts(fund.id);
// ...
}
}

Aggregates and Entities

Aggregate Roots

Each domain has one or more aggregate roots - entities that serve as entry points:

  • Fund Domain: FundEntity is the aggregate root
    • Contains: fees, EOD state, snapshots
  • Investor Domain: Investor is the aggregate root
    • Contains: holdings, transactions
  • Accounting Domain: Account and JournalEntry are both aggregate roots
    • Accounts contain balances
    • Journal entries contain lines

Entity Relationships

Entities reference other aggregates by ID only:

// ✅ CORRECT: Reference by ID
interface BankAccountEntity {
id: string;
fundId: number; // Reference to Fund aggregate
accountNumber: string;
// ...
}

// ❌ WRONG: Embedding entire entity
interface BankAccountEntity {
id: string;
fund: FundEntity; // Don't embed!
accountNumber: string;
}

Anti-Corruption Layer

The shared/ directory serves as our anti-corruption layer:

  • Financial calculations: Pure functions, no domain coupling
  • Money types: Value objects used across domains
  • Constants: Shared values (e.g., DAYS_PER_YEAR)
  • DSE API: External integration abstracted
// shared/money.ts - Shared value object
export interface Money {
amount: number; // in minor units (cents)
currency: string;
}

// Used across domains without coupling
import type { Money } from "@shared/money";

Benefits of This Approach

  1. Maintainability: Changes in one domain don't ripple through others
  2. Testability: Domains can be tested in isolation with mocked dependencies
  3. Scalability: Clear boundaries enable future microservices extraction
  4. Onboarding: New developers understand one domain at a time
  5. Parallel Development: Teams can work on different domains independently

See Also