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.
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.
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.
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.
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.
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.
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.
This pattern is easier to maintain than a long endpoint body that mixes every concern.
Controller: read request -> call createProjectService -> map success to 201 or failure to 400/403
Service: validate business rules -> persist data -> return result
Adapt this focused example to a disposable local environment and inspect every result before expanding it.
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);
});
}
The controller owns HTTP translation while the service owns the operation.
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));
}
Translate known failures without leaking internal details.
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 }
});
}
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.
Explore 500+ free tutorials across 20+ languages and frameworks.