If the presentation layer is the front door and the data access layer is the warehouse, then the business logic layer is the brain. It is the code that makes your application do what it does, not store data, not handle HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted., but apply the rules that define your product.
This layer is also the most commonly abused. Developers, and AI code generators, routinely scatter business rules across controllers, database queries, and frontend code. Understanding where business logic belongs is arguably the most important architectural skill you can develop.
What is business logic?
Business logic is any code that implements a rule specific to your domain. Not "how to parse JSONWhat is json?A text format for exchanging data between systems. It uses key-value pairs and arrays, and every programming language can read and write it.", that is infrastructure. Not "how to run a SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. query", that is data access. Business logic answers questions like:
- Can this user place an order? (permission check)
- What discount does this customer get? (pricing rule)
- Should we send a notification after signup? (workflow)
- Is this transactionWhat is transaction?A group of database operations that either all succeed together or all fail together, preventing partial updates. allowed under the daily limit? (compliance rule)
If you removed the business logic, you would have a generic CRUDWhat is crud?Create, Read, Update, Delete - the four basic operations almost every application performs on data. app that stores and retrieves data with no intelligence.
The service layer pattern
The most common way to organize business logic is the service layer pattern. Each service is a class (or moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions.) that groups related operations.
// services/orderService.ts
export class OrderService {
constructor(
private orderRepo: OrderRepository,
private productRepo: ProductRepository,
private paymentGateway: PaymentGateway,
private emailService: EmailService
) {}
async placeOrder(userId: string, items: OrderItem[]): Promise<Order> {
// Business rule: validate stock
for (const item of items) {
const product = await this.productRepo.findById(item.productId);
if (!product) {
throw new NotFoundError(`Product ${item.productId} not found`);
}
if (product.stock < item.quantity) {
throw new BusinessError(
`Not enough stock for ${product.name}`
);
}
}
// Business rule: calculate total with discounts
const total = this.calculateTotal(items);
// Business rule: charge payment
const payment = await this.paymentGateway.charge(userId, total);
if (!payment.success) {
throw new PaymentError('Payment failed');
}
// Persist the order
const order = await this.orderRepo.create({
userId,
items,
total,
paymentId: payment.id,
status: 'confirmed',
});
// Side effect: send confirmation email (non-blocking)
this.emailService.sendOrderConfirmation(userId, order).catch(
(err) => console.error('Email failed:', err)
);
return order;
}
private calculateTotal(items: OrderItem[]): number {
// Complex pricing logic lives here, not in a SQL query
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
const discount = subtotal > 100 ? 0.1 : 0;
return subtotal * (1 - discount);
}
}Notice what this service does NOT do:
- It does not parse
req.bodyor setres.status()(that is presentation) - It does not write SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. queries (that is data access)
- It does not know it is being called from an HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. handler (it could be called from a CLIWhat is cli?Short for Command Line Interface. A tool you use by typing commands in the terminal instead of clicking buttons. or test)
Where validation belongs
This table clarifies one of the most confusing architectural decisions:
| What you are checking | Layer | Example | Why here? |
|---|---|---|---|
| Is the JSON well-formed? | Presentation | JSON.parse() throws | HTTP concern |
Is email a string with @? | Presentation | Zod: z.string().email() | Format check, no domain knowledge needed |
Is quantity a positive number? | Presentation | Zod: z.number().positive() | Shape validation |
| Is the email already registered? | Business logic | userRepo.findByEmail() | Requires database, domain rule |
| Does the user have permission? | Business logic | Role check against user object | Authorization rule |
| Is the product in stock? | Business logic | Compare stock to requested quantity | Domain rule requiring data lookup |
| Does the foreign key exist in the DB? | Data access | Database constraint | Database integrity concern |
The dividing line: if you need to look at the database or apply a rule specific to your product, it is business logic. If you are just checking that the incoming data is shaped correctly, it is input validation.
The most common architecture mistake
Here is the pattern you will see in nearly every Express tutorial, StackOverflow answer, and AI-generated codebase:
// THE MISTAKE: Everything in the route handler
router.post('/orders', async (req, res) => {
const { items, userId } = req.body;
// Business rule mixed into presentation
const user = await db.query('SELECT * FROM users WHERE id = CODE_BLOCK', [userId]);
if (!user) return res.status(404).json({ error: 'User not found' });
// Business rule + data access in one place
let total = 0;
for (const item of items) {
const product = await db.query(
'SELECT * FROM products WHERE id = CODE_BLOCK', [item.productId]
);
if (product.stock < item.quantity) {
return res.status(400).json({ error: 'Out of stock' });
}
total += product.price * item.quantity;
}
// More mixed concerns
if (total > 100) total *= 0.9; // discount logic
await db.query(
'INSERT INTO orders (user_id, total) VALUES (CODE_BLOCK, $2)',
[userId, total]
);
// Side effect in the controller
await sendEmail(user.email, 'Order confirmed');
res.status(201).json({ message: 'Order placed' });
});This works. It will pass code review at many companies. But it has serious problems:
- Testing: To test the discount logic, you need a running database and HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. server
- Reuse: If you need to place an order from a background job, you must duplicate this code
- Readability: Business rules are buried among HTTP and SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. concerns
- Change risk: Modifying the discount rule might accidentally break the SQL query next to it
AI's most common architecture mistake
When you ask an AI to "create an Express APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. for an e-commerce app," you will get the fat-controller pattern almost every time. AI models have trained on millions of tutorials and StackOverflow answers that use this pattern. It is the statistically most likely code.
The AI will:
- Put database queries directly in route handlers
- Mix validation, business rules, and data access in one function
- Skip the service layer entirely
- Use
req.bodyandres.json()in the same function that calculates prices
This is not because the AI does not "know" about layered architecture. It is because the most common code on the internet mixes concerns, and AI generates the most common patterns.
Your job: When AI generates a route handlerWhat is route handler?A Next.js file named route.js inside the app/ directory that handles HTTP requests directly - the App Router equivalent of API routes. longer than 15 lines, stop and ask: "Which of these lines are business logic? Which are data access? Can I extract them into separate functions?"
RefactoringWhat is refactoring?Restructuring existing code to make it cleaner, more readable, or more efficient without changing what it does. mixed concerns
When you find a fat controller, the fix is mechanical:
- Extract data access into a repositoryWhat is repository?A project folder tracked by Git that stores your files along with the complete history of every change, inside a hidden .git directory.: every
db.query()becomes a repository method - Extract business logic into a service: every rule, calculation, or conditional becomes a service method
- Leave the controller thin: parse input, call service, format output
// AFTER refactoring - controller is 8 lines
router.post('/orders', async (req, res) => {
const input = CreateOrderSchema.parse(req.body);
try {
const order = await orderService.placeOrder(input.userId, input.items);
res.status(201).json({ data: order });
} catch (err) {
if (err instanceof BusinessError) {
return res.status(400).json({ error: err.message });
}
throw err;
}
});The 40-line route handlerWhat is route handler?A Next.js file named route.js inside the app/ directory that handles HTTP requests directly - the App Router equivalent of API routes. is now 8 lines. The business logic lives in orderService.placeOrder(). The SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. lives in repository methods. Each piece can be tested independently.
Multiple entry points
A well-isolated business layer pays off the moment you need a second entry point. Suppose you add a CLIWhat is cli?Short for Command Line Interface. A tool you use by typing commands in the terminal instead of clicking buttons. tool to batch-import orders:
// cli/importOrders.ts
import { OrderService } from '../services/orderService';
async function importFromCSV(filePath: string) {
const rows = await parseCSV(filePath);
for (const row of rows) {
try {
await orderService.placeOrder(row.userId, row.items);
console.log(`Order placed for user ${row.userId}`);
} catch (err) {
console.error(`Failed for user ${row.userId}: ${err.message}`);
}
}
}Same business rules. Same validation. No HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. involved. This only works because the service layer does not import Request or Response.
export async function placeOrder(userId, items) { ... } is a perfectly valid service.