Best Clean Code Patterns to Use Everyday in APIs
Streamlining API Development with Consistent and Maintainable Practices
We previously looked at clean code patterns as they relate to the design and development efforts around web apps.
You’ll find in this guide that many of the patterns found in that post also repeat themselves when considering the design and development of APIs as well. This is because clean code is a generalized philosophy and methodology of how to approach software development, holistically, without the targeted consideration of a specific system. Only after we define and understand generalized principles do we apply them to certain types of systems that frequently come up, like web apps, APIs, back-end processing systems and so on. This guide presents some clean code principles that apply best in the development of APIs.
Principles
Clear and Consistent Naming
Single Responsibility Principle (SRP)
Use Middleware for Cross-Cutting Concerns
Handle Errors Gracefully
Use Status Codes Appropriately
Keep Controllers Thin
Validate Requests
Document Your API
1. Clear and Consistent Naming
Names should be self-explanatory
Always use meaningful names for your endpoints, variables, functions, and classes
You will most certaintly develop some habits and naming conventions based on your own quirks - and that’s okay. Be sure to remain consistent in your naming of these entities throughout your application so that other developers can respect and follow your codebase’s conventions.
This creates consistency and efficiency within your codebase longer term, allowing multiple collaborates to understand the system and incrementally add to it without much trouble.
Example:
Bad:
app.get('/g', (req, res) => { // code to get user details });
Good:
app.get('/users/:id', (req, res) => { // code to get user details });
2. Single Responsibility Principle (SRP)
Each function or module should have exactly one responsibility
This makes your code easier to test and maintain
This is related to the principle of atomicity, where we aspire to create systems that can be atomic, abiding by hierarchies of abstraction and modules. This can only be achieved if we successfully create single responsibility blocks at the most fundamental, atomic level.
Example:
Bad:
app.post('/users', (req, res) => {
// validate request
// create user in database
// send response
});
Good:
const validateUser = (user) => {
// validation logic
};
const createUser = (user) => {
// database logic
};
app.post('/users', (req, res) => {
const user = req.body;
if (validateUser(user)) {
createUser(user);
res.status(201).send(user);
} else {
res.status(400).send('Invalid user');
}
});
3. Use Middleware for Cross-Cutting Concerns
Middleware functions are perfect for handling concerns that cut across multiple routes, like authentication, logging, or error handling
Utilize middleware when you want to centralize some feature of the codebase, as opposed to having it repeated and residing at the endpoint level
Example:
Bad:
app.get('/users', (req, res) => {
if (!req.headers.authorization) {
return res.status(401).send('Unauthorized');
}
// fetch and send users
});
Good:
const authenticate = (req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).send('Unauthorized');
}
next();
};
app.use(authenticate);
app.get('/users', (req, res) => {
// fetch and send users
});
4. Handle Errors Gracefully
Always handle errors properly, which means ensuring that you achieve at least the following things:
Using try-catch blocks in order to identify specific error points as well as a mechanism of preventing your server from shutting down due to unprecedented bugs
Centralized error handling through the use of middleware,
Return meaningful error messages, both for the client, but also for the developer that may need to go into the logs to troubleshoot new and unexpected behavior
Example:
Bad:
app.get('/users/:id', (req, res) => {
const user = getUserById(req.params.id);
res.send(user);
});
Good:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUserById(req.params.id);
if (!user) {
return res.status(404).send('User not found');
}
res.send(user);
} catch (error) {
next(error);
}
});
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
5. Use Status Codes Appropriately
HTTP status codes are quite important for the communication between a client and a server. We primarily utilize to specify, in the simplest way possible, to a client, whether a request suceeded or failed, and, in either case, what transformation or error ocurred during the processing.
Example:
Bad:
app.post('/users', (req, res) => {
const user = req.body;
if (validateUser(user)) {
createUser(user);
res.send(user);
} else {
res.send('Invalid user');
}
});
Good:
app.post('/users', (req, res) => {
const user = req.body;
if (validateUser(user)) {
createUser(user);
res.status(201).send(user);
} else {
res.status(400).send('Invalid user');
}
});
6. Keep Controllers Thin
Controllers are a conceptual idea that refer to the use of individual files to act as endpoint route handlers
They are known as controllers because their primary objective is to control the handling of a request and to delegate certain logic to other shared modules
We can keep controllers very thin and lightweight when we properly abide by some of the principles indicated above, such as the single responsibility principle, which allows us to create abstracted and shared modules that can be utilized by controllers to handle large proportions of the underlying actions
With this philosophy, we can keep the controller clean and focused on specifically on handling HTTP requests and responses
Example:
Bad:
app.get('/users/:id', (req, res) => {
const user = db.findUserById(req.params.id);
if (user) {
res.send(user);
} else {
res.status(404).send('User not found');
}
});
Good:
const userService = {
getUserById: (id) => {
// database logic to find user by id
}
};
app.get('/users/:id', async (req, res, next) => {
try {
const user = await userService.getUserById(req.params.id);
if (!user) {
return res.status(404).send('User not found');
}
res.send(user);
} catch (error) {
next(error);
}
});
7. Validate Requests
Always validate incoming requests
Always assume the worst for incoming requests, from the point of view of authentication, authorization, and validation - assume that the payload is completely wrong and your job is to protect the underlying processing mechanism where the gatekeeper is the validation module that you develop, only allowing requests through for processing once they guarantee a consistent and predictable payload
Validation libraries ensure that the data meets your expectations before processing it
Example:
Bad:
app.post('/users', (req, res) => {
const user = req.body;
createUser(user);
res.status(201).send(user);
});
Good:
const { body, validationResult } = require('express-validator');
app.post('/users', [
body('email').isEmail(),
body('name').isLength({ min: 3 })
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const user = req.body;
createUser(user);
res.status(201).send(user);
});
8. Document Your API
Finally, documentation is something that is typically ignored or pushed off to the side, especially when teams are frantically moving in agile cycles between one sprint and the next. However, strong documentation can significantly improve the quality of your code, the availability of others to contribute to it, as well as provide opportunities for automated testing and pipelines in deployment.
The two most common tools in API documentation are Swagger or Postman.
Example:
Bad: No documentation
Good: Swagger documentation
openapi: 3.0.0
info:
title: User API
version: 1.0.0
paths:
/users:
get:
summary: Get all users
responses:
'200':
description: A list of users
/users/{id}:
get:
summary: Get a user by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: A single user
'404':
description: User not found