Service Layer and Caching Patterns
Use this skill when structuring business logic, implementing caching strategies, or orchestrating multiple data sources.
Table of Contents
- •Service Layer Fundamentals
- •Caching Strategies
- •Error Handling Patterns
- •Integration Patterns
- •Anti-Patterns to Avoid
Service Layer Fundamentals
What is the Service Layer?
The service layer sits between your UI components and external integrations (API clients, databases). It provides:
- •Business logic orchestration - Combine multiple data sources
- •Caching - Reduce API calls and improve performance
- •Error handling - Graceful degradation when external services fail
- •Data transformation - Convert external data to application types
Architecture:
Components (app/, components/)
↓
Service Layer (services/)
↓
Integration Layer (lib/)
↓
External APIs / Databases
File Organization
services/ ├── index.ts # Re-export all services ├── user-service.ts # User-related business logic ├── search-service.ts # Search orchestration └── analytics-service.ts # Analytics aggregation lib/ ├── api.ts # Raw API client ├── database.ts # Database client └── cache.ts # Cache client (Redis, etc.)
Caching Strategies
1. Time-Based Cache (TTL)
Use when: Data changes infrequently (user profiles, static content)
Pattern:
// services/user-service.ts
import { User } from "@/types";
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const userCache = new Map<string, { data: User; timestamp: number }>();
export async function getUserProfile(userId: string): Promise<User | null> {
// Check cache
const cached = userCache.get(userId);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data; // ✅ Cache hit
}
// Fetch from API
const user = await fetchUser(userId);
if (!user) return null;
// Store in cache
userCache.set(userId, {
data: user,
timestamp: Date.now(),
});
return user;
}
// Invalidate cache when user updates
export async function updateUser(
userId: string,
updates: Partial<User>,
): Promise<User | null> {
const updated = await apiUpdateUser(userId, updates);
// Invalidate cache
userCache.delete(userId);
return updated;
}
Pros:
- •✅ Simple implementation
- •✅ Automatic expiration
- •✅ Reduces API load
Cons:
- •❌ Stale data possible (up to TTL duration)
- •❌ Memory grows with unique keys
2. Map-Based Deduplication
Use when: Fetching lists with duplicate IDs (search results, feeds)
Pattern:
// services/search-service.ts
import { SearchResult } from "@/types";
export async function searchItems(query: string): Promise<SearchResult[]> {
const results = await apiSearch(query);
// Deduplicate by ID (O(1) vs O(n²) with array)
const uniqueMap = new Map<string, SearchResult>();
for (const item of results) {
uniqueMap.set(item.id, item);
}
// Return as array
return Array.from(uniqueMap.values());
}
Why Map over Set + Array?
// ❌ Slower: O(n) for each add
const seen = new Set<string>();
const unique = results.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
// ✅ Faster: O(1) for each add
const uniqueMap = new Map<string, SearchResult>();
results.forEach((item) => uniqueMap.set(item.id, item));
const unique = Array.from(uniqueMap.values());
3. React Server Component Caching
Use when: Using Next.js App Router with Server Components
Pattern:
// services/data-service.ts
import { cache } from 'react';
import { Item } from '@/types';
// React caches this per-request automatically
export const getItems = cache(async (): Promise<Item[]> => {
const response = await fetch('https://api.example.com/items');
if (!response.ok) return [];
return response.json();
});
// app/dashboard/page.tsx
import { getItems } from '@/services/data-service';
export default async function DashboardPage() {
// Automatically cached within this request
const items = await getItems();
return <Dashboard items={items} />;
}
// app/analytics/page.tsx
import { getItems } from '@/services/data-service';
export default async function AnalyticsPage() {
// Same request = cache hit (no extra API call)
const items = await getItems();
return <Analytics items={items} />;
}
Pros:
- •✅ Automatic deduplication per request
- •✅ Zero configuration
- •✅ Works across multiple Server Components
Cons:
- •❌ Only works in Server Components
- •❌ Cache doesn't persist across requests
4. Stale-While-Revalidate
Use when: Need instant responses but want fresh data eventually
Pattern:
// services/feed-service.ts
const feedCache = new Map<string, { data: Post[]; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000;
const STALE_TTL = 60 * 60 * 1000; // 1 hour
export async function getFeed(userId: string): Promise<Post[]> {
const cached = feedCache.get(userId);
const now = Date.now();
// Fresh cache (< 5 min) - return immediately
if (cached && now - cached.timestamp < CACHE_TTL) {
return cached.data;
}
// Stale cache (5 min - 1 hour) - return stale, revalidate in background
if (cached && now - cached.timestamp < STALE_TTL) {
// Return stale data immediately
const staleData = cached.data;
// Revalidate in background (don't await)
revalidateFeed(userId);
return staleData;
}
// No cache or too stale - fetch fresh
return await fetchFreshFeed(userId);
}
async function revalidateFeed(userId: string): Promise<void> {
const fresh = await fetchFreshFeed(userId);
feedCache.set(userId, {
data: fresh,
timestamp: Date.now(),
});
}
async function fetchFreshFeed(userId: string): Promise<Post[]> {
const posts = await apiFetchFeed(userId);
feedCache.set(userId, {
data: posts,
timestamp: Date.now(),
});
return posts;
}
Pros:
- •✅ Fast response (always returns immediately)
- •✅ Eventually consistent
- •✅ Good UX (no loading spinners)
Cons:
- •❌ More complex logic
- •❌ Users may see stale data briefly
Error Handling Patterns
1. Return Null (Simple Cases)
Use when: Failure is not critical, UI can handle missing data
export async function getUser(id: string): Promise<User | null> {
try {
const response = await fetch(`/api/user/${id}`);
if (!response.ok) return null;
return response.json();
} catch (error) {
console.error('getUser failed:', error);
return null;
}
}
// Usage in component
const user = await getUser(userId);
if (!user) {
return <div>User not found</div>;
}
2. Result Type (Explicit Error Handling)
Use when: Caller needs to differentiate error types
type Result<T, E = string> =
| { status: "success"; data: T }
| { status: "error"; error: E };
export async function updateProfile(
userId: string,
updates: Partial<User>,
): Promise<Result<User>> {
try {
const response = await fetch(`/api/user/${userId}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
if (!response.ok) {
return {
status: "error",
error: `HTTP ${response.status}: ${response.statusText}`,
};
}
const user = await response.json();
return { status: "success", data: user };
} catch (error) {
return {
status: "error",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
// Usage with explicit error handling
const result = await updateProfile(userId, { name: "New Name" });
if (result.status === "error") {
showToast(result.error);
return;
}
// TypeScript knows result.data exists here
setUser(result.data);
Integration Patterns
Parallel Data Fetching
Use when: Multiple independent data sources
export async function getDashboardData(userId: string) {
// ✅ Parallel (faster)
const [user, posts, analytics] = await Promise.all([
getUserProfile(userId),
getUserPosts(userId),
getUserAnalytics(userId),
]);
// ❌ Sequential (slower)
// const user = await getUserProfile(userId);
// const posts = await getUserPosts(userId);
// const analytics = await getUserAnalytics(userId);
return { user, posts, analytics };
}
Pagination with Cursor
export async function getPostsPaginated(
cursor?: string,
limit: number = 20,
): Promise<{ posts: Post[]; nextCursor: string | null }> {
const response = await fetch(
`/api/posts?limit=${limit}${cursor ? `&cursor=${cursor}` : ""}`,
);
if (!response.ok) {
return { posts: [], nextCursor: null };
}
const data = await response.json();
return {
posts: data.posts,
nextCursor: data.nextCursor,
};
}
Anti-Patterns to Avoid
❌ Calling APIs Directly from Components
// ❌ BAD: Direct API call in component
export default async function UserProfile({ userId }) {
const response = await fetch(`https://api.example.com/user/${userId}`);
const user = await response.json();
return <div>{user.name}</div>;
}
// ✅ GOOD: Use service layer
import { getUserProfile } from '@/services/user-service';
export default async function UserProfile({ userId }) {
const user = await getUserProfile(userId);
if (!user) return <div>User not found</div>;
return <div>{user.name}</div>;
}
Why?
- •No caching (repeated API calls)
- •No error handling
- •Hard to test
- •Duplicated logic
❌ Caching Everything
// ❌ BAD: Caching real-time data
const notificationCountCache = new Map<string, number>();
export async function getNotificationCount(userId: string): Promise<number> {
const cached = notificationCountCache.get(userId);
if (cached) return cached; // ❌ Stale count!
const count = await fetchNotificationCount(userId);
notificationCountCache.set(userId, count);
return count;
}
// ✅ GOOD: Don't cache real-time data
export async function getNotificationCount(userId: string): Promise<number> {
return await fetchNotificationCount(userId); // Always fresh
}
When NOT to cache:
- •Real-time data (notifications, live scores, chat messages)
- •Frequently changing data (stock prices, inventory)
- •User-specific sensitive data (passwords, tokens)
❌ Ignoring Cache Invalidation
// ❌ BAD: Never invalidate cache
const userCache = new Map<string, User>();
export async function getUser(id: string) {
if (userCache.has(id)) return userCache.get(id);
const user = await fetchUser(id);
userCache.set(id, user);
return user;
}
export async function updateUser(id: string, updates: Partial<User>) {
// ❌ Cache becomes stale!
return await apiUpdateUser(id, updates);
}
// ✅ GOOD: Invalidate on updates
export async function updateUser(id: string, updates: Partial<User>) {
const updated = await apiUpdateUser(id, updates);
userCache.delete(id); // ✅ Invalidate
return updated;
}
Best Practices Summary
Do's ✅
- •Use service layer for business logic (not in components)
- •Cache infrequently changing data (profiles, static content)
- •Invalidate cache on updates (delete, modify operations)
- •Use Map for deduplication (O(1) vs O(n))
- •Handle errors gracefully (return null or Result types)
- •Parallelize independent requests (Promise.all)
- •Use React's cache() in Server Components (automatic deduplication)
Don'ts ❌
- •Don't call APIs directly from components
- •Don't cache real-time data
- •Don't forget cache invalidation
- •Don't use Set + Array for deduplication (use Map)
- •Don't ignore TypeScript types (use strict types)
- •Don't cache sensitive data (tokens, passwords)
References
- •Service Layer Guidance - Service file organization
- •Architecture Documentation - System design patterns
- •Error Handling Skill - Advanced error patterns
- •Performance Optimization Skill - Performance strategies
Skill Status: Enhanced Last Updated: 2026-02-13 (Phase 5)