Skip to main content

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

LayerLocationExample
Domain schemaspackages/domain-schema/src/trpc/<feature>.tsZod input/output schemas shared by frontend and backend
tRPC routerfunctions/src/api/routers/<feature>.tsPermission-gated procedures calling the service
Server servicefunctions/src/api/services/Server<Feature>Service.tsBusiness logic, Firestore CRUD, workflow transitions
Frontend hookssrc/features/<feature>/hooks/use<Feature>.tsThin wrappers around trpc.<feature>.*
Domain clientpackages/domain-client/src/<feature>.tsReusable 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

FeatureServer ServiceRouterDomain Schemas
ExpensesServerExpenseServiceexpensesRouterexpense*.ts
JournalsServerJournalServicejournalsRouterjournal*.ts
ProcurementServerProcurementService (4 classes)procurementRouterprocurement*.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’tDo Instead
Import Firestore directly in componentsUse typed refs from src/core/firestoreCollections.ts via services
Call onSnapshot directlyUse service layer or React Query for data fetching
Emit EventBus events in React componentsEmit only from services
Skip limit() on Firestore queriesAlways add explicit limit()
Skip validation on write operationsUse this.validateInput() or Zod schema
Return raw data from servicesReturn ServiceResult<T>
Put approval logic in the main CRUD serviceExtract to a separate workflow service