Skip to main content

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:

  1. Centralized Configuration: Single place to configure all services
  2. Dependency Injection: Automatically wire service dependencies
  3. Type Safety: TypeScript ensures correct dependency types
  4. Testing Support: Easy to create test instances with mocked dependencies
  5. 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 ✅

  1. Always use the factory to create services in production
  2. Pass all dependencies through constructors
  3. Use type-safe interfaces for dependencies
  4. Create test fixtures using the factory with test database
  5. Document service dependencies in JSDoc comments

DON'T ❌

  1. Don't create services manually outside the factory in production code
  2. Don't use global singletons for services
  3. Don't import services directly in other domains
  4. Don't modify the factory without updating all dependent code
  5. Don't create circular dependencies between services

Adding a New Service

When adding a new service to the system:

  1. Create the service class with constructor dependencies
  2. Add the service type to DomainServices or ApplicationServices
  3. Create the repository (if needed)
  4. Wire the service in the appropriate factory function
  5. Update dependencies if other services need it
  6. Export the service from worker/services/index.ts
  7. 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