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:
- Modeling the business domain accurately in code
- Creating a ubiquitous language shared by developers and domain experts
- Defining bounded contexts with clear boundaries
- Isolating domain logic from infrastructure concerns
Our Domain Model
Identified Domains
Asset360 v3 is organized into the following domains:
Core Domains
-
Fund (
worker/services/fund/)- Central domain managing investment funds
- Handles fund lifecycle, fees, NAV calculations
- Dependencies: None (foundational domain)
-
Accounting (
worker/services/accounting/)- Double-entry accounting system
- Chart of accounts, journal entries, financial reports
- Dependencies: None (foundational domain)
-
Investor (
worker/services/investor/)- Investor registration and management
- Unit purchases, redemptions, holdings
- Dependencies: None at domain level (uses Fund via coordinator)
Supporting Domains
-
Bank Account (
worker/services/bank-account/)- Bank account management and transactions
- Interest calculations
- Dependencies: Fund, Accounting
-
FDR (
worker/services/fdr/)- Fixed Deposit Receipt management
- Maturity calculations, interest accrual
- Dependencies: Fund
-
Portfolio (
worker/services/portfolio/)- Equity (
portfolio/equity/): Equity securities management - Bond (
portfolio/bond/): Bond securities management - Market data integration, valuations
- Dependencies: Fund
- Equity (
-
Organization (
worker/services/organization/)- Multi-tenant organization management
- Organization hierarchy
- Dependencies: None (foundational domain)
-
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:
- Shared Kernel: Common types in
shared/(Money, financial calculations) - Service Dependencies: Injected via factory
- 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?
- Prevents circular dependencies
- Makes dependencies explicit
- Enables independent testing
- Allows future extraction to microservices
- 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:
FundEntityis the aggregate root- Contains: fees, EOD state, snapshots
- Investor Domain:
Investoris the aggregate root- Contains: holdings, transactions
- Accounting Domain:
AccountandJournalEntryare 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
- Maintainability: Changes in one domain don't ripple through others
- Testability: Domains can be tested in isolation with mocked dependencies
- Scalability: Clear boundaries enable future microservices extraction
- Onboarding: New developers understand one domain at a time
- Parallel Development: Teams can work on different domains independently
See Also
- Service Factory - How domains are wired together
- Repository Pattern - Data access details
- Coordinators - Cross-domain orchestration