Services
Where the real work happens - business logic, data processing, and external integrations.
The Chef Analogy#
If controllers are waiters, services are chefs. The waiter takes orders and delivers food, but the chef does the actual cooking.
Services contain your business logic:
- How to create a user (hash password, validate email)
- How to process an order (check inventory, calculate total)
- How to handle payments (integrate with Stripe)
This is where the interesting code lives.
What Services Do#
Services handle:
- Business logic - Rules that define how your app works
- Data operations - Database queries, caching
- External integrations - Payment providers, email services
- Complex calculations - Pricing, recommendations
What they DON'T handle:
- HTTP request/response (that's controllers)
- Input validation (that's validators/middleware)
- URL routing (that's routes)
Basic Service Structure#
// src/services/users.js
import { User } from '../models/User.js';
import { hashPassword } from '../utils/crypto.js';
import { cache } from '../utils/cache.js';
import { ConflictError } from '../utils/errors.js';
const CACHE_TTL = 300; // 5 minutes
export async function findAll({ page = 1, limit = 20 }) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find()
.select('-password')
.skip(skip)
.limit(limit)
.lean(),
User.countDocuments(),
]);
return { users, total };
}
export async function findById(id) {
const cacheKey = `user:${id}`;
// Check cache first
const cached = await cache.get(cacheKey);
if (cached) return cached;
// Query database
const user = await User.findById(id)
.select('-password')
.lean();
// Cache the result
if (user) {
await cache.set(cacheKey, user, CACHE_TTL);
}
return user;
}
export async function create(data) {
// Check for existing user
const existing = await User.findOne({ email: data.email });
if (existing) {
throw new ConflictError('Email already registered');
}
// Hash password
const hashedPassword = await hashPassword(data.password);
// Create user
const user = await User.create({
...data,
password: hashedPassword,
});
// Return without password
const { password, ...userWithoutPassword } = user.toObject();
return userWithoutPassword;
}
export async function update(id, data) {
const user = await User.findByIdAndUpdate(id, data, {
new: true,
runValidators: true,
})
.select('-password')
.lean();
// Invalidate cache
if (user) {
await cache.del(`user:${id}`);
}
return user;
}
export async function remove(id) {
await User.findByIdAndDelete(id);
await cache.del(`user:${id}`);
}
Business Logic Examples#
User Registration#
// src/services/auth.js
import { User } from '../models/User.js';
import { hashPassword, generateToken } from '../utils/crypto.js';
import { sendWelcomeEmail } from '../utils/email.js';
import { ConflictError } from '../utils/errors.js';
export async function register(data) {
// 1. Check if email is taken
const existing = await User.findOne({ email: data.email.toLowerCase() });
if (existing) {
throw new ConflictError('Email already registered');
}
// 2. Hash password
const hashedPassword = await hashPassword(data.password);
// 3. Create user
const user = await User.create({
email: data.email.toLowerCase(),
password: hashedPassword,
name: data.name,
role: 'user',
});
// 4. Generate auth tokens
const accessToken = generateToken(user, '15m');
const refreshToken = generateToken(user, '7d');
// 5. Send welcome email (don't await - fire and forget)
sendWelcomeEmail(user.email, user.name).catch(console.error);
// 6. Return user and tokens
return {
user: { id: user._id, email: user.email, name: user.name },
tokens: { accessToken, refreshToken },
};
}
Order Processing#
// src/services/orders.js
import { Order } from '../models/Order.js';
import { Product } from '../models/Product.js';
import { processPayment } from './payments.js';
import { sendOrderConfirmation } from '../utils/email.js';
import { ValidationError } from '../utils/errors.js';
export async function createOrder(userId, items, paymentMethod) {
// 1. Validate and get products
const productIds = items.map(item => item.productId);
const products = await Product.find({ _id: { $in: productIds } });
if (products.length !== productIds.length) {
throw new ValidationError('One or more products not found');
}
// 2. Check inventory
for (const item of items) {
const product = products.find(p => p._id.equals(item.productId));
if (product.stock < item.quantity) {
throw new ValidationError(`Insufficient stock for ${product.name}`);
}
}
// 3. Calculate total
let subtotal = 0;
const orderItems = items.map(item => {
const product = products.find(p => p._id.equals(item.productId));
const itemTotal = product.price * item.quantity;
subtotal += itemTotal;
return {
productId: product._id,
name: product.name,
price: product.price,
quantity: item.quantity,
};
});
const tax = subtotal * 0.1; // 10% tax
const total = subtotal + tax;
// 4. Process payment
const paymentResult = await processPayment({
amount: total,
method: paymentMethod,
userId,
});
// 5. Create order
const order = await Order.create({
userId,
items: orderItems,
subtotal,
tax,
total,
paymentId: paymentResult.id,
status: 'confirmed',
});
// 6. Update inventory
for (const item of items) {
await Product.findByIdAndUpdate(item.productId, {
$inc: { stock: -item.quantity },
});
}
// 7. Send confirmation email
sendOrderConfirmation(order).catch(console.error);
return order;
}
Caching Patterns#
Cache-Aside (Most Common)#
export async function findById(id) {
const cacheKey = `user:${id}`;
// 1. Check cache
const cached = await cache.get(cacheKey);
if (cached) return cached;
// 2. Cache miss - query database
const user = await User.findById(id).lean();
// 3. Store in cache
if (user) {
await cache.set(cacheKey, user, 300);
}
return user;
}
Cache Invalidation#
export async function update(id, data) {
// Update database
const user = await User.findByIdAndUpdate(id, data, { new: true }).lean();
// Invalidate cache (old data is stale)
await cache.del(`user:${id}`);
return user;
}
Fail-Safe Caching#
Cache operations should never break your app:
// src/utils/cache.js
export const cache = {
async get(key) {
try {
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('Cache get error:', error);
return null; // Fail gracefully
}
},
async set(key, value, ttl = 300) {
try {
await redis.set(key, JSON.stringify(value), { EX: ttl });
} catch (error) {
console.error('Cache set error:', error);
// Don't throw - caching is optional
}
},
};
External Service Integration#
Payment Service#
// src/services/payments.js
import Stripe from 'stripe';
import { config } from '../config/index.js';
import { PaymentError } from '../utils/errors.js';
const stripe = new Stripe(config.stripe.secretKey);
export async function processPayment({ amount, method, userId }) {
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe uses cents
currency: 'usd',
payment_method: method,
confirm: true,
metadata: { userId },
});
return {
id: paymentIntent.id,
status: paymentIntent.status,
};
} catch (error) {
// Translate Stripe errors to our errors
if (error.type === 'StripeCardError') {
throw new PaymentError(error.message);
}
throw error;
}
}
export async function refund(paymentId, amount) {
const refund = await stripe.refunds.create({
payment_intent: paymentId,
amount: amount ? Math.round(amount * 100) : undefined,
});
return {
id: refund.id,
status: refund.status,
};
}
Email Service#
// src/services/email.js
import { Resend } from 'resend';
import { config } from '../config/index.js';
const resend = new Resend(config.resend.apiKey);
export async function sendEmail({ to, subject, html }) {
if (config.env === 'test') {
console.log('Email skipped in test mode:', { to, subject });
return;
}
await resend.emails.send({
from: 'noreply@yourapp.com',
to,
subject,
html,
});
}
export async function sendWelcomeEmail(email, name) {
await sendEmail({
to: email,
subject: 'Welcome to Our App!',
html: `<h1>Welcome, ${name}!</h1><p>We're excited to have you.</p>`,
});
}
export async function sendPasswordReset(email, resetToken) {
const resetUrl = `${config.frontendUrl}/reset-password?token=${resetToken}`;
await sendEmail({
to: email,
subject: 'Reset Your Password',
html: `<p>Click <a href="${resetUrl}">here</a> to reset your password.</p>`,
});
}
Service Patterns#
Transaction-Like Operations#
When multiple operations need to succeed or fail together:
export async function transferFunds(fromId, toId, amount) {
const session = await mongoose.startSession();
session.startTransaction();
try {
// Debit source account
const source = await Account.findByIdAndUpdate(
fromId,
{ $inc: { balance: -amount } },
{ session, new: true }
);
if (source.balance < 0) {
throw new ValidationError('Insufficient funds');
}
// Credit destination account
await Account.findByIdAndUpdate(
toId,
{ $inc: { balance: amount } },
{ session }
);
await session.commitTransaction();
return { success: true };
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}
Batch Operations#
export async function importUsers(users) {
const results = { success: 0, failed: 0, errors: [] };
for (const userData of users) {
try {
await create(userData);
results.success++;
} catch (error) {
results.failed++;
results.errors.push({ email: userData.email, error: error.message });
}
}
return results;
}
Aggregation Services#
For complex queries and reports:
// src/services/analytics.js
import { Order } from '../models/Order.js';
export async function getSalesReport({ startDate, endDate }) {
const report = await Order.aggregate([
{
$match: {
createdAt: { $gte: startDate, $lte: endDate },
status: 'completed',
},
},
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } },
totalSales: { $sum: '$total' },
orderCount: { $sum: 1 },
averageOrder: { $avg: '$total' },
},
},
{ $sort: { _id: 1 } },
]);
return report;
}
Testing Services#
Services are where you want the most tests:
// tests/services/users.test.js
import * as userService from '../../src/services/users.js';
import { User } from '../../src/models/User.js';
import { ConflictError } from '../../src/utils/errors.js';
describe('UserService', () => {
beforeEach(async () => {
await User.deleteMany({});
});
describe('create', () => {
it('creates a new user with hashed password', async () => {
const user = await userService.create({
email: 'test@test.com',
password: 'password123',
name: 'Test User',
});
expect(user.email).toBe('test@test.com');
expect(user.password).toBeUndefined(); // Not returned
});
it('throws ConflictError for duplicate email', async () => {
await userService.create({
email: 'test@test.com',
password: 'password123',
name: 'Test User',
});
await expect(
userService.create({
email: 'test@test.com',
password: 'different',
name: 'Another User',
})
).rejects.toThrow(ConflictError);
});
});
});
Key Takeaways#
- Services contain business logic - The rules that make your app work
- Services don't know about HTTP - No req, res, or status codes
- Cache strategically - Check cache first, invalidate on updates
- Integrate external services here - Payments, email, third-party APIs
- Handle failures gracefully - Especially for optional features like caching
- Test thoroughly - This is where bugs hide
The Principle
If you're explaining what your app does to a non-technical person, you're describing your services. Controllers are plumbing. Services are the actual product.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.