System Design/
Lesson

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.

02

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.body or set res.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)

03

Where validation belongs

This table clarifies one of the most confusing architectural decisions:

What you are checkingLayerExampleWhy here?
Is the JSON well-formed?PresentationJSON.parse() throwsHTTP concern
Is email a string with @?PresentationZod: z.string().email()Format check, no domain knowledge needed
Is quantity a positive number?PresentationZod: z.number().positive()Shape validation
Is the email already registered?Business logicuserRepo.findByEmail()Requires database, domain rule
Does the user have permission?Business logicRole check against user objectAuthorization rule
Is the product in stock?Business logicCompare stock to requested quantityDomain rule requiring data lookup
Does the foreign key exist in the DB?Data accessDatabase constraintDatabase 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.

04

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' });
});
AI pitfall
This fat-controller pattern above is exactly what AI generates when you ask it to "create an Express API." AI has seen millions of tutorials written this way. When you receive AI-generated code, count the lines: if a route handler is longer than 15 lines, it almost certainly contains misplaced business logic or data access code.

This works. It will pass code review at many companies. But it has serious problems:

  1. 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
  2. Reuse: If you need to place an order from a background job, you must duplicate this code
  3. 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
  4. Change risk: Modifying the discount rule might accidentally break the SQL query next to it
05

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.body and res.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?"

06

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:

  1. 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
  2. Extract business logic into a service: every rule, calculation, or conditional becomes a service method
  3. 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.

07

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.

Edge case
Sometimes business logic needs to call external APIs (payment gateways, email services). These calls should be wrapped in their own interface, not called directly from the service. This way you can mock them in tests and swap providers without touching your business rules.
Good to know
You do not always need a formal class-based service layer. A module with exported functions works just as well. The key is that business logic lives in its own file, separate from HTTP handling and database access. export async function placeOrder(userId, items) { ... } is a perfectly valid service.