Tutorials Logic, IN info@tutorialslogic.com

Express.js Controllers, Services, and Clean Architecture: Separate Request Code From Business Rules

Express.js Controllers, Services, and Clean Architecture

An Express route becomes easier to maintain when it does not try to do everything itself.

Controllers should care about request and response details. Services should care about the business operation being performed.

This separation helps beginners escape giant route files and helps professionals keep the codebase testable as it grows.

The goal is not to worship architecture diagrams. The goal is to keep responsibilities from collapsing into one place.

What Controllers Should Own

A controller sits near the edge of the system. It reads route parameters, body data, query values, authenticated user context, and then decides how to translate the service result into an HTTP response.

That means controllers should stay close to the web layer. They are not the best home for large business rules, pricing decisions, permission rules, or reusable workflows.

  • Parse request inputs.
  • Call the appropriate service.
  • Translate results into status codes and response bodies.

What Services Are Better At

Services are useful when the same business behavior might be used by more than one route, more than one transport, or more than one future feature. They make reasoning easier because the business action gets a real name instead of hiding inside an endpoint body.

A service can still be small. It does not need to be an elaborate class hierarchy. Sometimes a well-named function that handles one business operation is enough.

  • Keep business decisions out of raw request code.
  • Use services for operations that need to be reused or tested independently.
  • Name services after the action, not the technical layer.

Beginner Walkthrough: Separate HTTP Code From Business Rules

A route maps a method and path to a handler. The controller reads trusted request data, calls the application operation, and translates the result into an HTTP response. It should not contain complex calculations, database transactions, or third-party integration details. This keeps the HTTP boundary easy to understand and test.

A service or application action expresses the business operation using plain values and explicit dependencies. A repository or data-access module handles persistence queries. Validation protects the HTTP boundary, while the service still enforces business invariants that apply to jobs, commands, and other callers.

Begin with one feature such as creating an order. Define the route, validation middleware, controller, CreateOrder service, and repository methods. Unit-test the service with fake dependencies and integration-test the route with a real test database. Do not add interfaces or layers that have no alternate behavior or testing value.

  • Keep controllers focused on request and response translation.
  • Put reusable business rules in application services.
  • Keep persistence details behind focused data-access functions.
  • Enforce domain invariants below HTTP validation.
  • Add abstractions only when they remove real coupling.

The Clean Architecture Idea Without The Ceremony

People often overcomplicate clean architecture. The practical version is simple: the outer web layer should not own your core business rules, and infrastructure details should not leak everywhere.

If your route handler can be read in a few seconds and your business rule can be tested without spinning up the whole server, you are already moving in a healthy direction.

  • Separate what is web-specific from what is business-specific.
  • Keep data access behind a clear boundary when possible.
  • Avoid ceremony that adds folders but not clarity.

Keep Business Rules Outside Controllers

Model a funds-transfer operation in a service that accepts plain values and dependencies. The controller should translate HTTP input and output but know nothing about transaction mechanics.

Work through this as a controlled engineering exercise rather than a copy-and-paste demo. State the expected result before running anything, keep the input small enough to inspect, and record the important intermediate state. That makes the lesson explain not only what to type, but why the result is trustworthy.

If the service throws HTTP-specific response objects or the controller performs balance updates itself, the same rule cannot be reused safely by a job or command-line task.

Verification must use evidence that matches the concept. Call the service directly in unit tests, simulate insufficient funds and repository failure, and assert transaction rollback without constructing req or res. Repeat the check after deliberately introducing the failure, then after the fix. The contrast between those runs is the part that turns a definition into practical understanding.

  • Write the expected behavior and the failure condition before starting.
  • Run the smallest representative scenario and preserve its output.
  • Introduce the named failure deliberately instead of waiting for an accidental error.
  • Use the listed evidence to locate the first incorrect state.
  • Rerun the same verification after the fix and document the conclusion.

Experienced Practice: Transactions, Dependency Direction, And Error Mapping

Dependencies should point toward stable business policy. Services can accept repository, clock, ID generator, and gateway contracts without importing Express request or response types. Infrastructure modules implement those contracts. This makes the operation reusable from a queue worker and prevents framework details from spreading through the codebase.

Define transaction boundaries around the complete invariant. A service may ask a transaction manager to execute repository operations together. Avoid opening a transaction in a controller and then making slow network calls while locks are held. Use an outbox when committed database state must reliably produce an asynchronous event.

Map domain errors at the HTTP edge. Insufficient inventory may become 409, invalid state may become 422, and an absent record may become 404. Unexpected errors go to centralized middleware with a request ID and safe response. Log enough context for diagnosis without exposing tokens, passwords, or private payloads.

  • Keep framework types outside core operations.
  • Place transactions around business invariants.
  • Avoid network calls while database locks are held.
  • Map known domain errors consistently at the edge.
  • Use an outbox for reliable post-commit messaging.

Thin controller, named service

This pattern is easier to maintain than a long endpoint body that mixes every concern.

Thin controller, named service
Controller: read request -> call createProjectService -> map success to 201 or failure to 400/403
Service: validate business rules -> persist data -> return result
  • The controller speaks HTTP.
  • The service speaks business behavior.
  • The split becomes especially valuable once more endpoints appear.

Keep Business Rules Outside Controllers example

Adapt this focused example to a disposable local environment and inspect every result before expanding it.

Keep Business Rules Outside Controllers example
export async function transferFunds(input, accounts, tx) {
  return tx.run(async () => {
    const from = await accounts.lock(input.fromId);
    if (from.balance < input.amount) throw new Error('INSUFFICIENT_FUNDS');
    return accounts.transfer(input);
  });
}
  • Do not run production-changing commands until their scope and rollback are understood.
  • Capture the successful output and one intentionally failing output for comparison.
  • Replace example identifiers and credentials with safe local values.
  • Convert the final verification into a repeatable test, runbook, or review checklist.

Thin order controller and service

The controller owns HTTP translation while the service owns the operation.

Thin order controller and service
export async function createOrderController(req, res, next) {
  try {
    const order = await createOrder(req.validatedBody, req.user);
    res.status(201).location(`/orders/${order.id}`).json(order);
  } catch (error) {
    next(error);
  }
}

export async function createOrder(input, user) {
  await permissions.assertCanOrder(user, input.customerId);
  return transactions.run(() => orders.createWithLines(input));
}
  • The service does not know about req or res.
  • Authorization is part of the operation.
  • Central middleware maps errors.

Domain error mapping middleware

Translate known failures without leaking internal details.

Domain error mapping middleware
export function errorHandler(error, req, res, next) {
  if (error instanceof OutOfStockError) {
    return res.status(409).json({
      error: { code: 'OUT_OF_STOCK', requestId: req.id }
    });
  }

  req.log.error({ error, requestId: req.id });
  res.status(500).json({
    error: { code: 'INTERNAL_ERROR', requestId: req.id }
  });
}
  • Express error middleware needs four parameters.
  • Do not return stack traces to clients.
  • Keep error codes stable for callers.
Key Takeaways
  • I can describe the difference between a controller and a service.
  • I know why business logic inside route files becomes painful over time.
  • I understand that clean architecture should improve clarity, not only add ceremony.
  • I can describe a thin-controller pattern in plain language.
Common Mistakes to Avoid
Putting every business decision directly inside controllers.
Creating many layers with impressive names but unclear responsibility.
Separating files without actually separating concerns.

Practice Tasks

  • Take one create-order endpoint and split its concerns between controller and service.
  • Write a short naming rule for services in your project.
  • Identify one place where infrastructure knowledge is leaking too far into business code.
  • Recreate the Keep Business Rules Outside Controllers exercise and explain why each observed signal proves or disproves the expected behavior.
  • Change one assumption in the example, predict the effect, run the verification again, and document the difference.

Frequently Asked Questions

Not necessarily. Use the simplest form that keeps business behavior reusable and understandable.

When it mixes validation, business decisions, data access, and response mapping in a way that is hard to read or test independently.

Ready to Level Up Your Skills?

Explore 500+ free tutorials across 20+ languages and frameworks.