Skip to main content

UseCases Overview

UseCases (also called Orchestrators) are services that coordinate operations across multiple domains. They implement complex business workflows that require interaction between several domain services.

Why UseCases?

Problem: Complex operations span multiple domains

  • Setting up a new fund requires: Fund creation + Bank accounts + Chart of accounts + Initial investors
  • Processing EOD requires: Portfolio valuation + Interest accrual + NAV calculation + Snapshot creation
  • Unit purchases require: Investor validation + Holdings update + Accounting entries

Solution: UseCases orchestrate these multi-domain workflows

UseCases vs Services

Domain Services

  • Single responsibility: Operate within one domain
  • No cross-domain logic: Don't coordinate other services
  • Reusable: Can be called by multiple use cases
  • Example: FundService.createFund()

UseCases

  • Cross-domain responsibility: Coordinate multiple services
  • Complex workflows: Implement multi-step business processes
  • Transactional: Ensure consistency across operations
  • Example: FundSetupUseCase.setupFund()

Our UseCases

1. Fund Setup UseCase

Purpose: Complete fund initialization process

Location: worker/use case/fund-setup.use case.ts

Dependencies:

  • FundService
  • BankAccountService
  • InvestorService
  • AccountingService

Workflow: Organization validation → Fund creation → Bank accounts → Chart of accounts → Initial investors

[Learn more →](/use cases/fund-setup)

2. Unit Purchase UseCase

Purpose: Handle investor unit purchases with accounting

Location: worker/use case/unit-purchase.use case.ts

Dependencies:

  • InvestorService
  • FundService
  • BankAccountService
  • AccountingService

Workflow: Investor validation → Fund validation → Unit purchase → Journal entry creation

[Learn more →](/use cases/unit-purchase)

3. EOD Service

Purpose: End-of-day processing for funds

Location: worker/use case/eod/service.ts

Dependencies:

  • All core services (accounting, fund, portfolio, etc.)

Workflow: Portfolio valuation → Interest accrual → Fee calculation → NAV → Snapshot

[Learn more →](/use cases/eod-service)

4. Fund Migration Coordinator

Purpose: Execute controlled schema upgrades for existing funds without interrupting operations

Location: worker/use case/fund-migration.use case.ts

Dependencies:

  • FundService
  • BankAccountService
  • InvestorService
  • AccountingService
  • OperationsService

Workflow: Acquire locks → Validate prerequisites → Execute dry-run → Apply migration → Release locks & emit audit trail

Learn more →

UseCase Architecture

Design Principles

1. Separation of Concerns

UseCases don't contain business logic - they orchestrate services that do:

// ✅ GOOD: UseCase orchestrates
class FundSetupUseCase {
async setupFund(request: FundSetupRequest) {
// Delegate to domain services
const fund = await this.fundService.createFund({...});
const accounts = await this.createBankAccounts(fund.id, request.bankAccounts);
await this.ensureFundAccounts(fund.id);
return { fund, accounts };
}
}

// ❌ BAD: UseCase contains business logic
class FundSetupUseCase {
async setupFund(request: FundSetupRequest) {
// Don't validate fund codes here
if (!request.fund.code.match(/^[A-Z0-9-]{3,20}$/)) {
throw new Error('Invalid code');
}
// This belongs in FundService!
}
}

2. Error Handling

UseCases handle cross-service errors and provide context:

class FundSetupUseCase {
async setupFund(request: FundSetupRequest) {
try {
const fund = await this.fundService.createFund({...});

try {
const accounts = await this.createBankAccounts(fund.id, request.bankAccounts);
return { fund, accounts };
} catch (error) {
// Context: which step failed
throw new Error(`Failed to create bank accounts for fund ${fund.id}: ${error.message}`);
}
} catch (error) {
throw new Error(`Fund setup failed: ${error.message}`);
}
}
}

3. Compensation Logic

Since we can't use transactions (Cloudflare D1 limitation), use cases implement compensation:

class FundSetupUseCase {
async setupFund(request: FundSetupRequest) {
let fund: FundEntity | null = null;
let accounts: BankAccountEntity[] = [];

try {
// Step 1: Create fund
fund = await this.fundService.createFund({...});

// Step 2: Create bank accounts
for (const accountData of request.bankAccounts) {
const account = await this.bankAccountService.createBankAccount({
...accountData,
fundId: fund.id,
});
accounts.push(account);
}

// Step 3: Create chart of accounts
await this.ensureFundAccounts(fund.id, request.bankAccounts);

return { fund, bankAccounts: accounts };

} catch (error) {
// Compensation: Clean up created resources
if (fund) {
try {
await this.fundService.deleteFund(fund.id);
} catch (cleanupError) {
console.error('Failed to cleanup fund:', cleanupError);
}
}

throw new Error(`Fund setup failed: ${error.message}`);
}
}
}

4. Idempotency

UseCases should be idempotent when possible:

class FundSetupUseCase {
async setupFund(request: FundSetupRequest) {
// Check if fund already exists
const existing = await this.fundService.getFundByCode(request.fund.code);
if (existing) {
return { fund: existing, bankAccounts: [] };
}

// Proceed with creation
const fund = await this.fundService.createFund({...});
// ...
}
}

Testing UseCases

Unit Testing with Mocks

import { describe, it, expect, vi } from 'vitest';
import { FundSetupUseCase } from '@worker/use case/fund-setup.use case';

describe('FundSetupUseCase', () => {
it('orchestrates fund setup', async () => {
// Mock all services
const mockFundService = {
createFund: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
};
const mockBankService = {
createBankAccount: vi.fn().mockResolvedValue({ id: '1' }),
};
const mockInvestorService = {
registerInvestor: vi.fn(),
};
const mockAccountingService = {
createAccount: vi.fn(),
};

const use case = new FundSetupUseCase({
fundService: mockFundService as any,
bankAccountService: mockBankService as any,
investorService: mockInvestorService as any,
accountingService: mockAccountingService as any,
});

// Test orchestration
await use case.setupFund({
organizationId: 'org-1',
fund: { name: 'Test', code: 'TEST' },
bankAccounts: [{ type: 'subscription', bank: 'Test', accountNumber: '123' }],
});

// Verify service calls
expect(mockFundService.createFund).toHaveBeenCalled();
expect(mockBankService.createBankAccount).toHaveBeenCalled();
});
});

Integration Testing

import { describe, it, expect, beforeAll } from 'vitest';
import { createDomainServices, createApplicationServices } from '@worker/services/factory';
import { getTestDB } from '../helpers/setup';

describe('FundSetupUseCase Integration', () => {
let services: DomainServices;
let use cases: ApplicationServices;

beforeAll(async () => {
const db = await getTestDB();
services = createDomainServices({ db, env: {} as Env });
use cases = createApplicationServices(services);
});

it('sets up complete fund with real services', async () => {
// Create organization first
const org = await services.organization.createOrganization({
name: 'Test Org',
slug: 'test-org',
});

// Use use case with real services
const result = await use cases.fundSetup.setupFund({
organizationId: org.id,
fund: {
name: 'Test Fund',
code: 'TEST01',
},
bankAccounts: [
{
type: 'subscription',
bank: 'Test Bank',
accountNumber: '123456',
},
],
});

expect(result.fund.id).toBeDefined();
expect(result.bankAccounts).toHaveLength(1);
});
});

When to Create a UseCase

Create a use case when:

  1. Multiple domains involved: Operation spans 2+ domain services
  2. Complex workflow: Multi-step process with dependencies
  3. Compensation needed: Potential rollback of partial operations
  4. Reusable pattern: Workflow used in multiple places

Don't create a use case when:

  1. Single domain: Operation fits within one service
  2. Simple delegation: Just calling another service
  3. No coordination: No orchestration logic needed

See Also

  • [Fund Setup UseCase](/use cases/fund-setup) - Complete fund initialization
  • [Unit Purchase UseCase](/use cases/unit-purchase) - Unit purchase workflow
  • [EOD Service](/use cases/eod-service) - End-of-day processing
  • Service Factory - How use cases are created