Skip to main content

Architecture Overview

Asset360 v3 is built using Domain-Driven Design (DDD) principles with a clean, layered architecture that promotes maintainability, testability, and scalability.

High-Level Architecture

Core Architectural Principles

1. Domain-Driven Design (DDD)

Each business domain is encapsulated in its own module within worker/services/:

  • Fund: Core fund management
  • Investor: Investor and unit holder management
  • Accounting: Double-entry accounting system
  • Bank Account: Bank account operations
  • FDR: Fixed deposit receipt management
  • Portfolio: Equity and bond portfolio management
  • Market Data: Adapters that fetch equity and bond prices from DSE
  • Auth: Authentication and authorization
  • Organization: Multi-tenant organization management

Key Rules:

  • Files in one domain must never directly import implementation files from another domain
  • Cross-domain imports MUST use barrel files (index.ts)
  • Cross-domain communication happens through service dependencies injected via the factory
  • ESLint automatically enforces barrel import usage

Result-Based Error Handling (Neverthrow)

  • Domain services return Result<Success, DomainError> where migration is complete (funds, organizations, auth, equity portfolio) and will progressively adopt the pattern elsewhere.
  • DomainError shapes live in worker/trpc/utils/error-mapping.ts and include ValidationError, NotFoundError, AuthorizationError, and BusinessLogicError plus domain-specific variants.
  • tRPC routers unwrap Results via executeResult(); coordinators use .match() to propagate or transform errors.
  • Legacy services still throw Error objects—wrap these in try/catch blocks and convert them to shared domain errors at the boundary until their migration is complete.

2. Layered Architecture

Domain Services

  • Contain business logic
  • Orchestrate repository operations
  • Enforce business rules and validations
  • Located in worker/services/{domain}/service.ts

Repositories

  • Handle data access and persistence
  • Execute SQL queries via Drizzle ORM
  • Abstract database implementation details
  • Located in worker/services/{domain}/repository.ts

Use Cases (Application Services)

  • Orchestrate workflows across multiple domains
  • Implement application-specific business processes
  • Located in worker/use-cases/ organized by subdomain:
    • fund/: Fund management use cases (e.g., SetupFund)
    • investor/: Investor transaction use cases (e.g., PurchaseUnits)
    • operations/: Cross-cutting operational use cases (e.g., RunEndOfDay)

3. Service Factory Pattern

All services are instantiated through factory functions (worker/services/factory.ts):

  • createDomainServices(): Creates all domain services (business logic layer)
  • createApplicationServices(): Creates application services (orchestration layer)

This enables:

  • Dependency injection
  • Centralized service configuration
  • Easy testing with mock dependencies

4. Two-Layer Service Architecture

Domain Layer: All business logic - calculations, validations, and domain rules. Services in this layer can depend on other domain services as needed. What matters is that they contain business logic, not whether they have dependencies.

Application Layer (Use Cases): Workflow orchestration organized by subdomain. Each use case coordinates multi-step processes across multiple domain services. Use cases contain no business logic themselves; they only delegate to domain services. Use cases follow a consistent pattern with an execute() method.

Technology Decisions

Cloudflare D1 (SQLite)

We use Cloudflare D1, a serverless SQLite database with specific limitations:

  • No transactions: We use multiple sequential async operations
  • Statement limits: Long SQL statements are broken into smaller ones
  • Edge deployment: Global distribution with low latency

Drizzle ORM

Type-safe ORM that generates migrations from TypeScript schema definitions:

  • Schema defined in worker/db/schema/
  • Run pnpm drizzle-kit generate after schema changes
  • Migrations stored in migrations/

TRPC v11

Type-safe API layer between frontend and backend that mirrors frontend route structure:

  • Route definitions in worker/trpc/routers/ with org/ and org.fund/ namespaces
  • Schema validation in worker/trpc/schemas/
  • New v11 syntax with queryOptions and mutationOptions
  • Direct 1:1 mapping between frontend routes and tRPC procedures:
    • /$orgSlug/fundstrpc.org.funds.*
    • /$orgSlug/f/$fundCode/accountingtrpc.org.fund.accounting.*

Domain Isolation

Why Domain Isolation?

Domain isolation ensures:

  • Maintainability: Changes in one domain don't affect others
  • Testability: Each domain can be tested independently
  • Scalability: Domains can be extracted into microservices later
  • Clear boundaries: Explicit service dependencies

How It Works

// ❌ WRONG: Direct import from internal files
import { FundService } from "../fund/service";

// ✅ CORRECT: Import from barrel + dependency injection
import type { FundService } from "../fund"; // Barrel import

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

The service factory handles all dependency injection:

const fundService = new FundService(fundRepository);
const bankAccountService = new BankAccountService(
bankAccountRepository,
fundService, // Dependency injected
);

Data Flow

Typical Request Flow

  1. User Action: User interacts with UI
  2. Router Navigation: Tanstack Router handles route change
  3. Query Execution: Tanstack Query calls TRPC endpoint
  4. TRPC Handler: Validates input, calls service/coordinator
  5. Service Layer: Executes business logic, calls repository
  6. Repository Layer: Executes database operations
  7. Response: Data flows back through layers to UI

Example: Creating a Fund

Next Steps

Last Updated: Based on commit ee3a9e55 (Merge pull request #101 from saadmniamatullah/dependabot/npm_and_yarn/non-major-4a69fc7260)