Documentation Index
Fetch the complete documentation index at: https://grantmaster.dev/llms.txt
Use this file to discover all available pages before exploring further.
Service Patterns Catalog
Canonical service patterns in the GrantMaster codebase. All services extend BaseService<T> from src/core/BaseService.ts.
Core Concepts
ServiceResult<T> — standard return type: { success: boolean; error?: string; data?: T }
withErrorBoundary() — wraps operations with consistent error handling
validateInput() — validates at boundaries using optional Zod schemas
logSuccess() — audit logging for operations
invalidateCollectionCache() — cache management after mutations
Pattern 1: Basic CRUD Service
Extend BaseService<T>, set collectionName and serviceName, return ServiceResult<T> from all operations.
import { BaseService, type ServiceResult } from '@/core/BaseService';
import type { MyEntity } from '../types/myEntity.types';
export class MyService extends BaseService<MyEntity> {
protected collectionName = 'myEntities';
protected serviceName = 'MyService';
async create(entity: MyEntity, userId: string): Promise<ServiceResult<MyEntity>> {
return this.withErrorBoundary(
async () => {
this.validateInput(entity);
// Firestore write using typed refs from firestoreCollections
this.logSuccess('CREATE', entity.id, userId);
return entity;
},
{ operation: 'create', entityId: entity.id }
);
}
async getById(id: string): Promise<ServiceResult<MyEntity>> {
return this.withErrorBoundary(
async () => {
// Firestore read with typed ref
// Read validation uses graceful degradation (log errors, allow data)
return entity;
},
{ operation: 'getById', entityId: id }
);
}
async update(id: string, data: Partial<MyEntity>, userId: string): Promise<ServiceResult<void>> {
return this.withErrorBoundary(
async () => {
this.validateInput(data);
// Firestore update
invalidateCollectionCache(this.collectionName);
this.logSuccess('UPDATE', id, userId);
},
{ operation: 'update', entityId: id }
);
}
}
Zod schema integration: Services can define schema, creationSchema, and updateSchema properties:
- Read operations: graceful degradation (log errors, allow data through)
- Write operations: strict validation (throw
ValidationError)
Reference: src/features/expenses/services/ExpenseService.ts
Pattern 2: Approval Workflow Service
Extract approval logic into a separate single-responsibility service. Handle status transitions, reason tracking, and EventBus emission.
import { BaseService, type ServiceResult } from '@/core/BaseService';
import { eventBus } from '@/core/eventBus';
import { SystemEventType, EventSeverity } from '@grantmaster/shared/events';
export class MyWorkflowService extends BaseService<MyEntity> {
protected collectionName = 'myEntities';
protected serviceName = 'MyWorkflowService';
async submit(ids: string[], userId: string): Promise<ServiceResult<void>> {
return this.withErrorBoundary(
async () => {
// Update status: 'draft' → 'pending'
// Emit EventBus event
eventBus.emit(SystemEventType.MY_ENTITY_SUBMITTED, {
entityIds: ids,
submittedBy: userId,
severity: EventSeverity.INFO,
});
this.logSuccess('SUBMIT', ids.join(','), userId);
},
{ operation: 'submit' }
);
}
async approve(ids: string[], approverId: string): Promise<ServiceResult<void>> {
return this.withErrorBoundary(
async () => {
// Update status: 'pending' → 'approved'
eventBus.emit(SystemEventType.MY_ENTITY_APPROVED, {
entityIds: ids,
approvedBy: approverId,
severity: EventSeverity.INFO,
});
this.logSuccess('APPROVE', ids.join(','), approverId);
},
{ operation: 'approve' }
);
}
async reject(ids: string[], rejectedBy: string, reason: string): Promise<ServiceResult<void>> {
return this.withErrorBoundary(
async () => {
// Update status: 'pending' → 'rejected', store reason
eventBus.emit(SystemEventType.MY_ENTITY_REJECTED, {
entityIds: ids,
rejectedBy,
reason,
severity: EventSeverity.WARNING,
});
this.logSuccess('REJECT', ids.join(','), rejectedBy);
},
{ operation: 'reject' }
);
}
}
Reference: src/features/expenses/services/ExpenseWorkflowService.ts
Pattern 3: EventBus-Emitting Service
When to Emit
MUST emit events for:
- Approval/rejection workflows
- Entity lifecycle events (created, archived, deleted)
- Critical business thresholds (budget 80%/90%/100%, deadlines)
- Compliance and audit events
- Submissions requiring manager attention
- Template creation/usage
Do NOT emit for:
- Read operations
- Trivial updates (UI preferences, minor edits)
- Internal optimizations
How to Emit
import { eventBus } from '@/core/eventBus';
import { SystemEventType, EventSeverity } from '@grantmaster/shared/events';
// In a service method:
eventBus.emit(SystemEventType.EXPENSE_APPROVED, {
entityId: expense.id,
orgId: expense.orgId,
approvedBy: userId,
amount: expense.amount,
severity: EventSeverity.INFO,
});
Key rule: EventBus in services only — never in React components (ESLint-enforced).
See: Base Service and EventBus, Event Catalog
Pattern 4: Query Service
All Firestore queries must have explicit limit() (ESLint-enforced).
async getPaginated(
orgId: string,
options: { page: number; pageSize: number; status?: string }
): Promise<ServiceResult<{ items: MyEntity[]; total: number }>> {
return this.withErrorBoundary(
async () => {
let q = query(
collection(db, this.collectionName),
where('orgId', '==', orgId),
orderBy('createdAt', 'desc'),
limit(options.pageSize) // REQUIRED — ESLint enforces this
);
if (options.status) {
q = query(q, where('status', '==', options.status));
}
// Execute query, return results
},
{ operation: 'getPaginated' }
);
}
Pattern 5: Agent-Aware Service
Services can accept AgentOperationContext for operations triggered by AI agents:
import type { AgentOperationContext, ServiceOperationOptions } from '@/core/BaseService';
async create(
entity: MyEntity,
userId: string,
options?: ServiceOperationOptions
): Promise<ServiceResult<MyEntity>> {
return this.withErrorBoundary(
async () => {
// If agent context is present, attribute the operation
if (options?.agentContext) {
// Correlation: runId, stepId, agentType
}
// ...normal create logic
},
{ operation: 'create', entityId: entity.id }
);
}
Pattern 6: Server-Side tRPC Service (Cloud Functions)
For features requiring server-side validation and authorization, use a Server*Service class in Cloud Functions that is consumed by a tRPC router. This pattern was introduced for expenses, journals, and procurement (April 2026) and is the recommended pattern for new features involving approval workflows or sensitive data mutations.
Architecture
Frontend Hook (useProcurement.ts)
→ tRPC Client (React Query)
→ tRPC Router (procurement.ts) — permission checks
→ Server*Service (ServerProcurementService.ts) — business logic + Firestore
Key Files
| Layer | Location | Example |
|---|
| Domain schemas | packages/domain-schema/src/trpc/<feature>.ts | Zod input/output schemas shared by frontend and backend |
| tRPC router | functions/src/api/routers/<feature>.ts | Permission-gated procedures calling the service |
| Server service | functions/src/api/services/Server<Feature>Service.ts | Business logic, Firestore CRUD, workflow transitions |
| Frontend hooks | src/features/<feature>/hooks/use<Feature>.ts | Thin wrappers around trpc.<feature>.* |
| Domain client | packages/domain-client/src/<feature>.ts | Reusable typed hooks consumed by web and mobile |
Service Contract
Server services follow the same ServiceResult<T> return type as client-side BaseService but run in Cloud Functions:
interface ServiceResult<T> {
success: true; data: T;
} | {
success: false; error: { code: string; message: string };
}
Each service class exposes: create(), getById(), listByFilters(), update(), delete(), plus domain-specific workflow methods (submit(), approve(), reject(), markReceived()).
Router Pattern
import { router, permissionProcedure } from '../trpc';
export const featureRouter = router({
list: permissionProcedure('VIEW_FEATURE')
.input(listInputSchema)
.query(async ({ ctx, input }) => {
const result = await serverService.listByFilters(ctx.auth, filters, pagination);
if (!result.success) throw new TRPCError({ code: mapErrorCode(result.error.code), message: result.error.message });
return result.data;
}),
});
Implemented Features
| Feature | Server Service | Router | Domain Schemas |
|---|
| Expenses | ServerExpenseService | expensesRouter | expense*.ts |
| Journals | ServerJournalService | journalsRouter | journal*.ts |
| Procurement | ServerProcurementService (4 classes) | procurementRouter | procurement*.ts |
When to Use This Pattern
- Features with approval workflows (submit → approve/reject)
- Features handling financial data (expenses, procurement, billing)
- Features requiring server-side validation beyond client-side Zod
- Features consumed by multiple clients (web app + mobile via
domain-client)
Anti-Patterns
| Don’t | Do Instead |
|---|
| Import Firestore directly in components | Use typed refs from src/core/firestoreCollections.ts via services |
Call onSnapshot directly | Use service layer or React Query for data fetching |
| Emit EventBus events in React components | Emit only from services |
Skip limit() on Firestore queries | Always add explicit limit() |
| Skip validation on write operations | Use this.validateInput() or Zod schema |
| Return raw data from services | Return ServiceResult<T> |
| Put approval logic in the main CRUD service | Extract to a separate workflow service |