Back to Langfuse

Services and Repositories - Business Logic Layer

.agents/skills/backend-dev-guidelines/references/services-and-repositories.md

3.172.115.5 KB
Original Source

Services and Repositories - Business Logic Layer

Complete guide to organizing business logic with services and data access with repositories.

Table of Contents


Service Layer Overview

Purpose of Services

Services contain business logic - the 'what' and 'why' of your application:

Controller asks: "Should I do this?"
Service answers: "Yes/No, here's why, and here's what happens"
Repository executes: "Here's the data you requested"

Services are responsible for:

  • ✅ Business rules enforcement
  • ✅ Orchestrating multiple repositories
  • ✅ Transaction management
  • ✅ Complex calculations
  • ✅ External service integration
  • ✅ Business validations

Services should NOT:

  • ❌ Know about HTTP (Request/Response)
  • ❌ Direct Prisma access (use repositories)
  • ❌ Handle route-specific logic
  • ❌ Format HTTP responses

Dependency Injection Pattern

Why Dependency Injection?

Benefits:

  • Easy to test (inject mocks)
  • Clear dependencies
  • Flexible configuration
  • Promotes loose coupling

Excellent Example: NotificationService

File: /blog-api/src/services/NotificationService.ts

typescript
// Define dependencies interface for clarity
export interface NotificationServiceDependencies {
  prisma: PrismaClient;
  batchingService: BatchingService;
  emailComposer: EmailComposer;
}

// Service with dependency injection
export class NotificationService {
  private prisma: PrismaClient;
  private batchingService: BatchingService;
  private emailComposer: EmailComposer;
  private preferencesCache: Map<
    string,
    { preferences: UserPreference; timestamp: number }
  > = new Map();
  private CACHE_TTL =
    (notificationConfig.preferenceCacheTTLMinutes || 5) * 60 * 1000;

  // Dependencies injected via constructor
  constructor(dependencies: NotificationServiceDependencies) {
    this.prisma = dependencies.prisma;
    this.batchingService = dependencies.batchingService;
    this.emailComposer = dependencies.emailComposer;
  }

  /**
   * Create a notification and route it appropriately
   */
  async createNotification(params: CreateNotificationParams) {
    const {
      recipientID,
      type,
      title,
      message,
      link,
      context = {},
      channel = "both",
      priority = NotificationPriority.NORMAL,
    } = params;

    try {
      // Get template and render content
      const template = getNotificationTemplate(type);
      const rendered = renderNotificationContent(template, context);

      // Create in-app notification record
      const notificationId = await createNotificationRecord({
        instanceId: parseInt(context.instanceId || "0", 10),
        template: type,
        recipientUserId: recipientID,
        channel: channel === "email" ? "email" : "inApp",
        contextData: context,
        title: finalTitle,
        message: finalMessage,
        link: finalLink,
      });

      // Route notification based on channel
      if (channel === "email" || channel === "both") {
        await this.routeNotification({
          notificationId,
          userId: recipientID,
          type,
          priority,
          title: finalTitle,
          message: finalMessage,
          link: finalLink,
          context,
        });
      }

      return notification;
    } catch (error) {
      ErrorLogger.log(error, {
        context: {
          "[NotificationService] createNotification": {
            type: params.type,
            recipientID: params.recipientID,
          },
        },
      });
      throw error;
    }
  }

  /**
   * Route notification based on user preferences
   */
  private async routeNotification(params: {
    notificationId: number;
    userId: string;
    type: string;
    priority: NotificationPriority;
    title: string;
    message: string;
    link?: string;
    context?: Record<string, any>;
  }) {
    // Get user preferences with caching
    const preferences = await this.getUserPreferences(params.userId);

    // Check if we should batch or send immediately
    if (this.shouldBatchEmail(preferences, params.type, params.priority)) {
      await this.batchingService.queueNotificationForBatch({
        notificationId: params.notificationId,
        userId: params.userId,
        userPreference: preferences,
        priority: params.priority,
      });
    } else {
      // Send immediately via EmailComposer
      await this.sendImmediateEmail({
        userId: params.userId,
        title: params.title,
        message: params.message,
        link: params.link,
        context: params.context,
        type: params.type,
      });
    }
  }

  /**
   * Determine if email should be batched
   */
  shouldBatchEmail(
    preferences: UserPreference,
    notificationType: string,
    priority: NotificationPriority,
  ): boolean {
    // HIGH priority always immediate
    if (priority === NotificationPriority.HIGH) {
      return false;
    }

    // Check batch mode
    const batchMode = preferences.emailBatchMode || BatchMode.IMMEDIATE;
    return batchMode !== BatchMode.IMMEDIATE;
  }

  /**
   * Get user preferences with caching
   */
  async getUserPreferences(userId: string): Promise<UserPreference> {
    // Check cache first
    const cached = this.preferencesCache.get(userId);
    if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
      return cached.preferences;
    }

    const preference = await this.prisma.userPreference.findUnique({
      where: { userID: userId },
    });

    const finalPreferences = preference || DEFAULT_PREFERENCES;

    // Update cache
    this.preferencesCache.set(userId, {
      preferences: finalPreferences,
      timestamp: Date.now(),
    });

    return finalPreferences;
  }
}

Usage in Controller:

typescript
// Instantiate with dependencies
const notificationService = new NotificationService({
  prisma: PrismaService.main,
  batchingService: new BatchingService(PrismaService.main),
  emailComposer: new EmailComposer(),
});

// Use in controller
const notification = await notificationService.createNotification({
  recipientID: "user-123",
  type: "AFRLWorkflowNotification",
  context: { workflowName: "AFRL Monthly Report" },
});

Key Takeaways:

  • Dependencies passed via constructor
  • Clear interface defines required dependencies
  • Easy to test (inject mocks)
  • Encapsulated caching logic
  • Business rules isolated from HTTP

Singleton Pattern

When to Use Singletons

Use for:

  • Services with expensive initialization
  • Services with shared state (caching)
  • Services accessed from many places
  • Permission services
  • Configuration services

Example: PermissionService (Singleton)

File: /blog-api/src/services/permissionService.ts

typescript
import { PrismaClient } from "@prisma/client";

class PermissionService {
  private static instance: PermissionService;
  private prisma: PrismaClient;
  private permissionCache: Map<
    string,
    { canAccess: boolean; timestamp: number }
  > = new Map();
  private CACHE_TTL = 5 * 60 * 1000; // 5 minutes

  // Private constructor prevents direct instantiation
  private constructor() {
    this.prisma = PrismaService.main;
  }

  // Get singleton instance
  public static getInstance(): PermissionService {
    if (!PermissionService.instance) {
      PermissionService.instance = new PermissionService();
    }
    return PermissionService.instance;
  }

  /**
   * Check if user can complete a workflow step
   */
  async canCompleteStep(
    userId: string,
    stepInstanceId: number,
  ): Promise<boolean> {
    const cacheKey = `${userId}:${stepInstanceId}`;

    // Check cache
    const cached = this.permissionCache.get(cacheKey);
    if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
      return cached.canAccess;
    }

    try {
      const post = await this.prisma.post.findUnique({
        where: { id: postId },
        include: {
          author: true,
          comments: {
            include: {
              user: true,
            },
          },
        },
      });

      if (!post) {
        return false;
      }

      // Check if user has permission
      const canEdit =
        post.authorId === userId || (await this.isUserAdmin(userId));

      // Cache result
      this.permissionCache.set(cacheKey, {
        canAccess: isAssigned,
        timestamp: Date.now(),
      });

      return isAssigned;
    } catch (error) {
      console.error(
        "[PermissionService] Error checking step permission:",
        error,
      );
      return false;
    }
  }

  /**
   * Clear cache for user
   */
  clearUserCache(userId: string): void {
    for (const [key] of this.permissionCache) {
      if (key.startsWith(`${userId}:`)) {
        this.permissionCache.delete(key);
      }
    }
  }

  /**
   * Clear all cache
   */
  clearCache(): void {
    this.permissionCache.clear();
  }
}

// Export singleton instance
export const permissionService = PermissionService.getInstance();

Usage:

typescript
import { permissionService } from "../services/permissionService";

// Use anywhere in the codebase
const canComplete = await permissionService.canCompleteStep(userId, stepId);

if (!canComplete) {
  throw new ForbiddenError("You do not have permission to complete this step");
}

Repository Pattern

Purpose of Repositories

Repositories abstract data access - the 'how' of data operations:

Service: "Get me all active users sorted by name"
Repository: "Here's the Prisma query that does that"

Repositories are responsible for:

  • ✅ All Prisma operations
  • ✅ Query construction
  • ✅ Query optimization (select, include)
  • ✅ Database error handling
  • ✅ Caching database results

Repositories should NOT:

  • ❌ Contain business logic
  • ❌ Know about HTTP
  • ❌ Make decisions (that's service layer)

Langfuse Repository Examples

Use these current Langfuse files as repository templates:

  • PostgreSQL repository with project-scoped filters: packages/shared/src/server/repositories/comments.ts
  • ClickHouse repository with project-scoped filters and query helpers: packages/shared/src/server/repositories/traces.ts
  • Repository tests: web/src/__tests__/server/repositories/event-repository.servertest.ts

Keep data-access concerns in repositories and business decisions in services. Project-scoped queries must include projectId or project_id filters.


Service Design Principles

1. Single Responsibility

Each service should have ONE clear purpose:

typescript
// ✅ GOOD - Single responsibility
class UserService {
  async createUser() {}
  async updateUser() {}
  async deleteUser() {}
}

class EmailService {
  async sendEmail() {}
  async sendBulkEmails() {}
}

// ❌ BAD - Too many responsibilities
class UserService {
  async createUser() {}
  async sendWelcomeEmail() {} // Should be EmailService
  async logUserActivity() {} // Should be AuditService
  async processPayment() {} // Should be PaymentService
}

2. Clear Method Names

Method names should describe WHAT they do:

typescript
// ✅ GOOD - Clear intent
async createNotification()
async getUserPreferences()
async shouldBatchEmail()
async routeNotification()

// ❌ BAD - Vague or misleading
async process()
async handle()
async doIt()
async execute()

3. Use Params Objects for Multiple Arguments

When a function receives multiple arguments, use a single params object instead of positional arguments:

typescript
// ❌ BAD - Positional arguments are unclear and can be swapped
async function createTrace(
  projectId: string,
  userId: string,
  sessionId: string,
  name: string,
) {}

// Call site - which string is which?
await createTrace(projectId, userId, sessionId, name);

// ✅ GOOD - Params object makes intent clear
async function createTrace(params: {
  projectId: string;
  userId: string;
  sessionId: string;
  name: string;
}) {}

// Call site - clear and prevents argument swapping bugs
await createTrace({ projectId, userId, sessionId, name });

Benefits:

  • More readable at call sites
  • Prevents bugs when positional arguments of the same type are accidentally swapped
  • Easier to add optional parameters later
  • Self-documenting code

4. Return Types

Always use explicit return types:

typescript
// ✅ GOOD - Explicit types
async createUser(data: CreateUserDTO): Promise<User> {}
async findUsers(): Promise<User[]> {}
async deleteUser(id: string): Promise<void> {}

// ❌ BAD - Implicit any
async createUser(data) {}  // No types!

5. Error Handling

Services should throw meaningful errors:

typescript
// ✅ GOOD - Meaningful errors
if (!user) {
  throw new NotFoundError(`User not found: ${userId}`);
}

if (emailExists) {
  throw new ConflictError("Email already exists");
}

// ❌ BAD - Generic errors
if (!user) {
  throw new Error("Error"); // What error?
}

6. Avoid God Services

Don't create services that do everything:

typescript
// ❌ BAD - God service
class WorkflowService {
  async startWorkflow() {}
  async completeStep() {}
  async assignRoles() {}
  async sendNotifications() {} // Should be NotificationService
  async validatePermissions() {} // Should be PermissionService
  async logAuditTrail() {} // Should be AuditService
  // ... 50 more methods
}

// ✅ GOOD - Focused services
class WorkflowService {
  constructor(
    private notificationService: NotificationService,
    private permissionService: PermissionService,
    private auditService: AuditService,
  ) {}

  async startWorkflow() {
    // Orchestrate other services
    await this.permissionService.checkPermission();
    await this.workflowRepository.create();
    await this.notificationService.notify();
    await this.auditService.log();
  }
}

Caching Strategies

1. In-Memory Caching

typescript
class UserService {
  private cache: Map<string, { user: User; timestamp: number }> = new Map();
  private CACHE_TTL = 5 * 60 * 1000; // 5 minutes

  async getUser(userId: string): Promise<User> {
    // Check cache
    const cached = this.cache.get(userId);
    if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
      return cached.user;
    }

    // Fetch from database
    const user = await userRepository.findById(userId);

    // Update cache
    if (user) {
      this.cache.set(userId, { user, timestamp: Date.now() });
    }

    return user;
  }

  clearUserCache(userId: string): void {
    this.cache.delete(userId);
  }
}

2. Cache Invalidation

typescript
class UserService {
  async updateUser(userId: string, data: UpdateUserDTO): Promise<User> {
    // Update in database
    const user = await userRepository.update(userId, data);

    // Invalidate cache
    this.clearUserCache(userId);

    return user;
  }
}

Testing Services

Use testing-guide.md for backend test patterns. Prefer current Langfuse tests over invented examples:

  • Repository tests: web/src/__tests__/server/repositories/event-repository.servertest.ts
  • Pure service unit tests: web/src/__tests__/server/unit/

Related Files: