Achieving separation of concerns using higher order functions — Part I
A concern is a business or technical requirement with a well defined scope. Separation of concerns is a design principle in software development that encourage to avoid mixing different concerns in a single artifact (component, module, service, function, etc.) Even the most basic software has two or more concerns: security, persisting data, logging events, displaying information, etc. Keeping the implementation of these concerns in separate places makes the software easier to extend or modify.
For example, imagine that we are implementing a SaaS product that offers cooking courses as a subscription based service. This service provides courses for different skill levels and cuisine styles: basic knife skills, crafting fresh pasta, Thai cuisine, etc. Some of these courses are only available to premium users. We also want to log every time a user attempts to access one of these courses. If we were implementing a web service that provides information about courses, we could express the aforementioned requirements like this:
import { COURSE_NOT_FOUND, PREMIUM_RESTRICTED } from 'src/common/messages';
function getCourseServiceFactory(courseService, userService, logger) {
return function getCourse(request, response) {
const course = courseService.findById(request.params('id'));
const currentUser = userService.getCurrentUser();
if (!course) {
return response.status(404).json({ message: COURSE_NOT_FOUND });
}
logger.info(`Trying to access course ${course.name}`);
if (course.premium && !currentUser.premium) {
return response.status(401).json({ message: PREMIUM_RESTRICTED });
}
return response.status(200).json(course);
}
}
This function has several responsibilities:
- Finds the course with the id passed in the request parameters.
- Logs that someone is trying to obtain information about the specified course.
- Verifies the current user is authorized to obtain information about premium courses.
- Builds the web service response depending on whether the course exists and the current user is authorized.
Writing automated tests for this service is cumbersome because it has many dependencies. We have to provide 3 external services: logger, userService, Course, and a complex response object that allows to build the HTTP response that the service will deliver.
import { COURSE_NOT_FOUND, PREMIUM_RESTRICTED } from 'src/common/messages';
import getCourseFactory from 'src/api/course/getCourse'
describe('getCourse', () => {
let userService
let courseService
let logger
const courseId = '1'
beforeEach(() => {
userService = { currentUser: jest.fn(() => ({ premium: false })) }
courseService = { findById: jest.fn(() => ({ premium: false })) }
logger = { info: jest.fn() }
})
describe('given the course does not exist', () => {
it('should return a 404 response indicating the course does not exist', () => {
const sut = getCourseFactory(courseService, userService, logger)
const response = {
status: jest.fn(() => response)
json: jest.fn()
}
const request = {
params: jest.fn(() => courseId)
}
courseService.findById.mockReturnValueOnce(null)
sut(request, response)
expect(courseService.findById).toHaveBeenCalledWith(courseId)
expect(response.status).toHaveBeenCalledWith(400)
expect(response.json).toHaveBeenCalledWith({ message: COURSE_NOT_FOUND })
});
});
describe('given the course is premium and the user is not premium', () => {
it('should return a 401 response indicating the user is not authorized to access this resource', () => {
// implement test setup and assertions
});
});
// rest of the test cases to complete the coverage of this function
});
The test spec for getCourse contains many test cases. Preparing all the service ’s dependencies also adds considerable complexity. These are characteristics of a service with too many responsibilities. In agile practices, it’s also known as a code smell. The visual representation of this function could be a Venn diagram. It displays how different concerns are intermixed in the function’s implementation.
If there are five different services restricting access to premium resources and the logic to verify if a resource is premium changes, we want to make this change in a single place. Having authorization intermixed with other concerns wouldn’t allow us to achieve that because we’ll have to implement this concern in every service that needs it. Instead of extending, we should think in terms of augmenting or composing.
Higher order functions accept one or more functions as parameters and/or return a function as result of their execution. They are an excellent tool to achieve a clear separation of concerns because we can compose several functions by passing them as parameters of the previous one. A visual representation of composition could be:
To demonstrate these concepts, let’s refactor getCourse to extract logging, authorization, and HTTP concerns to separate higher order functions. When composing two or more functions, they have to comply with a communication contract, otherwise, they won’t how how to talk with each other. All the higher order functions we’ll implement follow the following structure:
- Accept the function that we want to extend as the first parameter.
- Return a new function that will call the function passed as a parameter and the higher order function’s logic.
- The new function should always return the value returned by the original function.
Let’s start with the logging concern. logEvent augments a function fn passed as parameter by logging a message after the function is executed.
function withLogging(fn, level, messageBuilder = (params) => '') {
return (...params) => {
const result = fn(...params);
console[level](messageBuilder(...params));
return result;
}
}
const getCourseWithLogging = withLogging(
getCourse,
'info',
(request) => `Trying to access course with id ${request.params('id')}`
)
There are several ways of making this higher order function more robust. We could specify
if logging should happen before or after the wrapped function is called. fn
could be
asynchronous so we could implement our higher order functions to handle promises
correctly too.
In the next part of the series of articles, we’ll extract the authorization and building HTTP responses concerns. We’ll also update the test specs to reflect these changes.