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
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:
- Multiple domains involved: Operation spans 2+ domain services
- Complex workflow: Multi-step process with dependencies
- Compensation needed: Potential rollback of partial operations
- Reusable pattern: Workflow used in multiple places
Don't create a use case when:
- Single domain: Operation fits within one service
- Simple delegation: Just calling another service
- 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