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. DomainErrorshapes live inworker/trpc/utils/error-mapping.tsand includeValidationError,NotFoundError,AuthorizationError, andBusinessLogicErrorplus domain-specific variants.- tRPC routers unwrap Results via
executeResult(); coordinators use.match()to propagate or transform errors. - Legacy services still throw
Errorobjects—wrap these intry/catchblocks 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 generateafter 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/withorg/andorg.fund/namespaces - Schema validation in
worker/trpc/schemas/ - New v11 syntax with
queryOptionsandmutationOptions - Direct 1:1 mapping between frontend routes and tRPC procedures:
/$orgSlug/funds→trpc.org.funds.*/$orgSlug/f/$fundCode/accounting→trpc.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
- User Action: User interacts with UI
- Router Navigation: Tanstack Router handles route change
- Query Execution: Tanstack Query calls TRPC endpoint
- TRPC Handler: Validates input, calls service/coordinator
- Service Layer: Executes business logic, calls repository
- Repository Layer: Executes database operations
- Response: Data flows back through layers to UI
Example: Creating a Fund
Next Steps
- DDD Approach - Deep dive into Domain-Driven Design
- Service Factory - How services are created and wired
- Repository Pattern - Data access layer details
- Dependency Injection - Managing service dependencies
Last Updated: Based on commit
ee3a9e55(Merge pull request #101 from saadmniamatullah/dependabot/npm_and_yarn/non-major-4a69fc7260)