.agents/skills/backend-dev-guidelines/references/services-and-repositories.md
Complete guide to organizing business logic with services and data access with repositories.
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:
Services should NOT:
Benefits:
File: /blog-api/src/services/NotificationService.ts
// 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:
// 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:
Use for:
File: /blog-api/src/services/permissionService.ts
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:
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");
}
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:
Repositories should NOT:
Use these current Langfuse files as repository templates:
packages/shared/src/server/repositories/comments.tspackages/shared/src/server/repositories/traces.tsweb/src/__tests__/server/repositories/event-repository.servertest.tsKeep data-access concerns in repositories and business decisions in services.
Project-scoped queries must include projectId or project_id filters.
Each service should have ONE clear purpose:
// ✅ 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
}
Method names should describe WHAT they do:
// ✅ GOOD - Clear intent
async createNotification()
async getUserPreferences()
async shouldBatchEmail()
async routeNotification()
// ❌ BAD - Vague or misleading
async process()
async handle()
async doIt()
async execute()
When a function receives multiple arguments, use a single params object instead of positional arguments:
// ❌ 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:
Always use explicit return types:
// ✅ 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!
Services should throw meaningful errors:
// ✅ 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?
}
Don't create services that do everything:
// ❌ 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();
}
}
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);
}
}
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;
}
}
Use testing-guide.md for backend test patterns. Prefer current Langfuse tests
over invented examples:
web/src/__tests__/server/repositories/event-repository.servertest.tsweb/src/__tests__/server/unit/Related Files: