Service Factory Pattern
The Service Factory is the central mechanism for creating and wiring all services in Asset360 v3. It's located in worker/services/factory.ts.
Why a Service Factory?
The factory pattern provides:
- Centralized Configuration: Single place to configure all services
- Dependency Injection: Automatically wire service dependencies
- Type Safety: TypeScript ensures correct dependency types
- Testing Support: Easy to create test instances with mocked dependencies
- Consistency: Ensures services are always created correctly
Factory Structure
// Domain Services - All business logic
export type DomainServices = {
auth: AuthService;
googleOAuth2: GoogleOAuth2Service;
accounting: AggregateAccountingService;
fund: FundService;
organization: OrganizationService;
bankAccount: BankAccountService;
fdr: FdrService;
equityPortfolio: EquityPortfolioService;
bondPortfolio: BondPortfolioService;
investor: InvestorService;
unrealizedGainLoss: UnrealizedGainLossServiceImpl;
};
// Application Services - Use Cases organized by subdomain
export type ApplicationServices = {
setupFund: SetupFundUseCase; // fund/ subdomain
purchaseUnits: PurchaseUnitsUseCase; // investor/ subdomain
runEndOfDay: RunEndOfDayUseCase; // operations/ subdomain
};
Two-Phase Service Creation
Phase 1: Domain Services (Business Logic)
export function createDomainServices({
db,
env,
googleOAuthOverrides,
}: CreateServicesOptions): DomainServices {
// Create repositories
const authRepository = new AuthRepository(db);
const accountingRepository = new AccountingRepository(db);
const fundRepository = new FundRepository(db);
// ... more repositories
// Create services with dependencies
const fundService = new FundService(fundRepository);
const accountingService = new AggregateAccountingService(
accountingRepository,
);
// Services with dependencies on other services
const bankAccountService = new BankAccountService(
bankAccountRepository,
fundService, // Depends on Fund
accountingService, // Depends on Accounting
);
return {
auth: authService,
accounting: accountingService,
fund: fundService,
// ... more services
};
}
Phase 2: Application Services (Orchestration)
export function createApplicationServices(
domainServices: DomainServices,
): ApplicationServices {
// Fund use cases
const setupFundUseCase = new SetupFundUseCase({
fundService: domainServices.fund,
bankAccountService: domainServices.bankAccount,
investorService: domainServices.investor,
accountingService: domainServices.accounting,
});
// Investor use cases
const purchaseUnitsUseCase = new PurchaseUnitsUseCase({
investorService: domainServices.investor,
accountingService: domainServices.accounting,
bankAccountService: domainServices.bankAccount,
fundService: domainServices.fund,
});
// Operations use cases
const runEndOfDayUseCase = new RunEndOfDayUseCase(
domainServices.accounting,
domainServices.fund,
domainServices.bankAccount,
domainServices.fdr,
domainServices.equityPortfolio,
domainServices.bondPortfolio,
domainServices.unrealizedGainLoss,
);
return {
setupFund: setupFundUseCase,
purchaseUnits: purchaseUnitsUseCase,
runEndOfDay: runEndOfDayUseCase,
};
}
Service Dependency Graph
Usage in Production
In Cloudflare Worker
// worker/app.ts or worker/index.ts
import {
createDomainServices,
createApplicationServices,
} from "./services/factory";
import { getDB } from "./db/client";
// Get database client
const db = getDB(env.DB);
// Create all services
const domainServices = createDomainServices({
db,
env,
googleOAuthOverrides: {
/* optional overrides */
},
});
const appServices = createApplicationServices(domainServices);
// Use services in TRPC context
export const createContext = () => ({
domainServices: domainServices, // or just 'domain'
applicationServices: appServices, // or just 'app'
});
In TRPC Routers
// worker/trpc/routers/fund.router.ts
import { publicProcedure } from "../index";
import { z } from "zod";
export const fundRouter = {
create: publicProcedure
.input(
z.object({
name: z.string(),
code: z.string(),
organizationId: z.number(),
}),
)
.mutation(async ({ input, ctx }) => {
// Access service from context
return await ctx.services.fund.createFund({
...input,
startDate: new Date(),
});
}),
setupFund: publicProcedure
.input(fundSetupSchema)
.mutation(async ({ input, ctx }) => {
// Access coordinator from context
return await ctx.coordinators.fundSetup.setupFund(input);
}),
};
Usage in Testing
Test with Real Services
// test/service/fund.test.ts
import { describe, it, expect, beforeAll } from "vitest";
import { createDomainServices } from "@worker/services/factory";
import { getTestDB } from "../helpers/setup";
describe("FundService", () => {
let services: DomainServices;
beforeAll(async () => {
const db = await getTestDB();
services = createDomainServices({ db, env: {} as Env });
});
it("should create a fund", async () => {
const fund = await services.fund.createFund({
name: "Test Fund",
code: "TEST01",
organizationId: "org-1",
startDate: new Date(),
});
expect(fund.id).toBeDefined();
expect(fund.name).toBe("Test Fund");
});
});
Test with Mocked Services
// test/coordinator/fund-setup.test.ts
import { describe, it, expect, vi } from 'vitest';
import { FundSetupCoordinator } from '@worker/coordinator/fund-setup.coordinator';
import type { DomainServices } from '@worker/services/factory';
describe('FundSetupCoordinator', () => {
it('should setup fund with mocked services', async () => {
// Create partial mock of domain services
const mockDomainServices: Partial<DomainServices> = {
fund: {
createFund: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
getFundById: vi.fn(),
} as any,
bankAccount: mockBankAccountService as any,
investor: mockInvestorService as any,
accounting: mockAccountingService as any,
};
const coordinator = new FundSetupCoordinator({
fundService: mockDomainServices.fund!,
bankAccountService: mockDomainServices.bankAccount!,
investorService: mockDomainServices.investor!,
accountingService: mockDomainServices.accounting!,
});
// Test coordinator
await coordinator.setupFund({...});
expect(mockDomainServices.fund!.createFund).toHaveBeenCalledOnce();
});
});
Dependency Resolution Order
The factory creates services in the correct order to satisfy dependencies:
// 1. Create repositories (no dependencies)
const fundRepository = new FundRepository(db);
const accountingRepository = new AccountingRepository(db);
const bankAccountRepository = new BankAccountRepository(db);
// 2. Create independent services (repository dependencies only)
const fundService = new FundService(fundRepository);
const accountingService = new AggregateAccountingService(accountingRepository);
// 3. Create dependent services (require other services)
const bankAccountService = new BankAccountService(
bankAccountRepository,
fundService, // Depends on step 2
accountingService, // Depends on step 2
);
// 4. Create composite services (depend on multiple services)
const unrealizedGainLossService = new UnrealizedGainLossServiceImpl(
accountingService, // From step 2
equityPortfolioService, // From step 3
bondPortfolioService, // From step 3
);
// 5. Create coordinators (depend on everything)
const eodService = new EODService(
accountingService, // From step 2
fundService, // From step 2
bankAccountService, // From step 3
fdrService, // From step 3
equityPortfolioService, // From step 3
bondPortfolioService, // From step 3
unrealizedGainLossService, // From step 4
);
Optional Dependencies
Some services have optional dependencies to avoid circular references:
class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService?: FundService,
private accountingService?: AggregateAccountingService,
) {}
async createBankAccount(data: CreateBankAccountRequest) {
// Check if fund exists (optional check)
if (this.fundService) {
const fund = await this.fundService.getFundById(data.fundId);
if (!fund) {
throw new Error(`Fund with ID ${data.fundId} not found`);
}
}
// Create bank account
const account = await this.repository.create(data);
// Create ledger account (optional operation)
if (this.accountingService) {
await this.createLedgerAccount(account);
}
return account;
}
}
Environment Configuration
The factory accepts environment configuration for external integrations:
export type BuildDomainServicesOptions = {
db: ProductionDBClient;
env: Env; // Cloudflare Worker environment
googleOAuthOverrides?: Partial<GoogleOAuthConfig>;
};
// Google OAuth configuration with fallbacks
const resolvedGoogleConfig: GoogleOAuthConfig = {
clientId:
googleOAuthOverrides?.clientId ??
env.VITE_GOOGLE_CLIENT_ID ??
"test-google-client-id",
clientSecret:
googleOAuthOverrides?.clientSecret ??
env.GOOGLE_CLIENT_SECRET ??
"test-google-secret",
redirectUri:
googleOAuthOverrides?.redirectUri ??
`${env.VITE_BASE_URL}/login/google/callback`,
};
Best Practices
DO ✅
- Always use the factory to create services in production
- Pass all dependencies through constructors
- Use type-safe interfaces for dependencies
- Create test fixtures using the factory with test database
- Document service dependencies in JSDoc comments
DON'T ❌
- Don't create services manually outside the factory in production code
- Don't use global singletons for services
- Don't import services directly in other domains
- Don't modify the factory without updating all dependent code
- Don't create circular dependencies between services
Adding a New Service
When adding a new service to the system:
- Create the service class with constructor dependencies
- Add the service type to
DomainServicesorApplicationServices - Create the repository (if needed)
- Wire the service in the appropriate factory function
- Update dependencies if other services need it
- Export the service from
worker/services/index.ts - Update tests to use the new service
Example:
// Step 1 & 2: Create new service
export class NewService {
constructor(
private repository: NewRepository,
private fundService: FundService,
) {}
}
// Step 3: Add to type
export type DomainServices = {
// ... existing services
newService: NewService;
};
// Step 4: Wire in factory
export function createDomainServices({ db, env }: BuildDomainServicesOptions) {
// ... existing code
const newRepository = new NewRepository(db);
const newService = new NewService(newRepository, fundService);
return {
// ... existing services
newService,
};
}
See Also
- Dependency Injection - How dependencies are managed
- Testing Guidelines - Testing with the factory
- Service Structure - Standard service structure