9.8 KiB
9.8 KiB
id, title, category, priority, tags, related
| id | title | category | priority | tags | related | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| core-separation-concerns | Separation of Concerns | core-principles | critical |
|
|
Separation of Concerns
Different concerns should be handled by different parts of the system. Each module, class, or function should address a single concern, making the code easier to understand, test, and modify.
Bad Example
// Anti-pattern: Multiple concerns mixed together
class OrderProcessor {
async processOrder(orderData: any, request: Request): Promise<Response> {
// Concern 1: HTTP request parsing
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
const token = authHeader.slice(7);
// Concern 2: Authentication
let userId: string;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
userId = decoded.userId;
} catch {
return new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401 });
}
// Concern 3: Validation
if (!orderData.items || orderData.items.length === 0) {
return new Response(JSON.stringify({ error: 'Order must have items' }), { status: 400 });
}
for (const item of orderData.items) {
if (!item.productId || item.quantity < 1) {
return new Response(JSON.stringify({ error: 'Invalid item' }), { status: 400 });
}
}
// Concern 4: Database access
const connection = await mysql.createConnection(process.env.DATABASE_URL!);
try {
// Concern 5: Business logic - inventory check
for (const item of orderData.items) {
const [rows] = await connection.execute(
'SELECT stock FROM products WHERE id = ?',
[item.productId]
);
if (rows[0].stock < item.quantity) {
return new Response(
JSON.stringify({ error: `Insufficient stock for ${item.productId}` }),
{ status: 400 }
);
}
}
// Concern 6: Business logic - pricing
let total = 0;
for (const item of orderData.items) {
const [rows] = await connection.execute(
'SELECT price FROM products WHERE id = ?',
[item.productId]
);
total += rows[0].price * item.quantity;
}
// Concern 7: Business logic - create order
const orderId = crypto.randomUUID();
await connection.execute(
'INSERT INTO orders (id, user_id, total, status) VALUES (?, ?, ?, ?)',
[orderId, userId, total, 'pending']
);
// Concern 8: Payment processing
const stripe = new Stripe(process.env.STRIPE_KEY!);
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(total * 100),
currency: 'usd',
customer: userId
});
// Concern 9: Inventory update
for (const item of orderData.items) {
await connection.execute(
'UPDATE products SET stock = stock - ? WHERE id = ?',
[item.quantity, item.productId]
);
}
// Concern 10: Notification
const transporter = nodemailer.createTransport({ /* SMTP config */ });
await transporter.sendMail({
to: orderData.email,
subject: 'Order Confirmation',
html: `<h1>Order ${orderId} confirmed</h1><p>Total: $${total}</p>`
});
// Concern 11: Logging
console.log(`Order ${orderId} created for user ${userId}`);
// Concern 12: HTTP response formatting
return new Response(
JSON.stringify({ orderId, total, paymentIntentId: paymentIntent.id }),
{ status: 201, headers: { 'Content-Type': 'application/json' } }
);
} finally {
await connection.end();
}
}
}
Good Example
// Correct approach: Each concern in its own module
// Concern: HTTP handling
class OrderController {
constructor(private orderService: OrderService) {}
async createOrder(req: Request, res: Response): Promise<void> {
try {
const userId = req.user.id; // Auth middleware already handled this
const orderData = req.body; // Validation middleware already validated
const order = await this.orderService.createOrder(userId, orderData);
res.status(201).json({
orderId: order.id,
total: order.total,
status: order.status
});
} catch (error) {
if (error instanceof InsufficientStockError) {
res.status(400).json({ error: error.message });
} else if (error instanceof PaymentFailedError) {
res.status(402).json({ error: 'Payment failed' });
} else {
throw error; // Let error middleware handle
}
}
}
}
// Concern: Authentication (middleware)
class AuthMiddleware {
constructor(private authService: AuthService) {}
async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = this.extractToken(req);
if (!token) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const user = await this.authService.validateToken(token);
if (!user) {
res.status(401).json({ error: 'Invalid token' });
return;
}
req.user = user;
next();
}
private extractToken(req: Request): string | null {
const header = req.headers.authorization;
return header?.startsWith('Bearer ') ? header.slice(7) : null;
}
}
// Concern: Validation (separate validator)
class OrderValidator {
validate(data: unknown): CreateOrderData {
const schema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive()
})).min(1)
});
return schema.parse(data);
}
}
// Concern: Business logic orchestration
class OrderService {
constructor(
private orderRepository: OrderRepository,
private inventoryService: InventoryService,
private pricingService: PricingService,
private paymentService: PaymentService,
private notificationService: NotificationService,
private logger: Logger
) {}
async createOrder(userId: string, data: CreateOrderData): Promise<Order> {
// Check inventory
await this.inventoryService.checkAvailability(data.items);
// Calculate pricing
const pricing = await this.pricingService.calculateTotal(data.items);
// Create order
const order = await this.orderRepository.create({
userId,
items: data.items,
total: pricing.total,
status: 'pending'
});
// Process payment
await this.paymentService.charge(userId, pricing.total, order.id);
// Reserve inventory
await this.inventoryService.reserve(data.items);
// Send confirmation
await this.notificationService.sendOrderConfirmation(order);
// Log
this.logger.info('Order created', { orderId: order.id, userId });
return order;
}
}
// Concern: Inventory management
class InventoryService {
constructor(private productRepository: ProductRepository) {}
async checkAvailability(items: OrderItem[]): Promise<void> {
for (const item of items) {
const product = await this.productRepository.findById(item.productId);
if (product.stock < item.quantity) {
throw new InsufficientStockError(item.productId, product.stock, item.quantity);
}
}
}
async reserve(items: OrderItem[]): Promise<void> {
for (const item of items) {
await this.productRepository.decrementStock(item.productId, item.quantity);
}
}
}
// Concern: Pricing calculation
class PricingService {
constructor(private productRepository: ProductRepository) {}
async calculateTotal(items: OrderItem[]): Promise<PricingResult> {
let subtotal = 0;
for (const item of items) {
const product = await this.productRepository.findById(item.productId);
subtotal += product.price * item.quantity;
}
const tax = this.calculateTax(subtotal);
const total = subtotal + tax;
return { subtotal, tax, total };
}
private calculateTax(amount: number): number {
return amount * 0.1; // 10% tax
}
}
// Concern: Payment processing
class PaymentService {
constructor(private stripeClient: Stripe) {}
async charge(userId: string, amount: number, orderId: string): Promise<Payment> {
const intent = await this.stripeClient.paymentIntents.create({
amount: Math.round(amount * 100),
currency: 'usd',
metadata: { orderId }
});
return { id: intent.id, status: 'pending' };
}
}
// Concern: Notifications
class NotificationService {
constructor(private emailService: EmailService) {}
async sendOrderConfirmation(order: Order): Promise<void> {
await this.emailService.send({
to: order.userEmail,
template: 'order-confirmation',
data: { orderId: order.id, total: order.total }
});
}
}
// Concern: Data access
class OrderRepository {
constructor(private db: Database) {}
async create(data: CreateOrderInput): Promise<Order> {
return this.db.orders.create({ data });
}
async findById(id: string): Promise<Order | null> {
return this.db.orders.findUnique({ where: { id } });
}
}
Why
-
Understandability: Each module has one job. Easy to understand what it does.
-
Testability: Test inventory logic without payment, test pricing without database.
-
Reusability:
PricingServicecan be used for quotes, carts, or invoices. -
Maintainability: Change email provider? Only touch
EmailService. -
Team Scaling: Different developers can work on different concerns without conflicts.
-
Flexibility: Swap Stripe for another payment provider by changing only
PaymentService. -
Debugging: Error in pricing? Look at
PricingService. Clear boundaries help locate issues.