Achieving separation of concerns using higher order functions — Part II
In the first part of this series, we discussed what separation of concerns is, how it helps us to keep our code maintainable, and laid out an example of a web service that mixes multiple concerns in its implementation. We started to refactor this service in order to extract individual concerns into their own functions, and now, we’ll continue this effort by focusing on extracting the HTTP specific concerns.
Disclaimer: The utilities I implement on this article are included in the most popular (and not so much) web frameworks out there. The purpose of this article is not to reinvent the wheel but to explain the design principles behind the tools we use on a daily basis.
After implementing our logging higher order function, we simplified the getCourse
service’s implementation. Let’s do the same with the test spec by removing the
logger
dependency here too.
import { COURSE_NOT_FOUND, PREMIUM_RESTRICTED } from 'src/common/messages';
import getCourseFactory from 'src/api/course/getCourse';
describe('getCourse', () => {
let userService;
let courseService;
const courseId = '1';
// let logger -- we no longer need a logger variable
beforeEach(() => {
userService = { currentUser: jest.fn(() => ({ premium: false })) };
courseService = { findById: jest.fn(() => ({ premium: false })) };
// We don't need to instantiate a fake logger object
// 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);
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 number of dependencies of a service is usually a clear indication of its complexity. As this number grows, using the service becomes complicated because its clients should deal with all its dependencies. A test spec is a clear proof of this because it has to instantiate the system under test which makes very clear how complex that operation could be.
Now let’s jump into the 2nd concern, HTTP. All our web services should describe what they want to respond based on the client’s request. Sometimes these responses will be quite simple, i.e. A status code and a JSON payload:
response.status(401).json({ message: PREMIUM_RESTRICTED });
In other occasions, they’ll need special headers or a different content type:
// A hypothetical response to the action of creating a new course
response.status(200)
.append('Cache-Control', 'max-age=3600')
.append('Content-Type', 'text/html')
.append('Content-Language', 'es')
.append('Content-Length', viewContent.length)
.send(viewContent);
As we call more methods of the response
API, our service function becomes more
coupled to our web framework of choice. If our code is tightly coupled to a
framework, it becomes difficult to replace in an egregious situation
(losing official support, encountering design flaws, etc.). Coupling is the
opposite of modularity and thus separation of concerns.
To keep these two apart, we should separate the response’s specification from the task of building such object. An approach to achieve this goal is by describing the web service’s response using a plain JavaScript object:
import { COURSE_NOT_FOUND, PREMIUM_RESTRICTED } from 'src/common/messages';
function getCourseServiceFactory(courseService, userService) {
return function getCourse(request) {
const course = courseService.findById(request.params('id'));
const currentUser = userService.getCurrentUser();
if (!course) {
return {
status: 404,
type 'json',
body: { message: COURSE_NOT_FOUND },
};
}
if (course.premium && !currentUser.premium) {
return {
status: 401,
type: 'json',
body: { message: PREMIUM_RESTRICTED }
};
}
return {
status: 401,
type: 'json',
body: { message: PREMIUM_RESTRICTED }
};
};
}
On this version of getCourse
, the service doesn’t have knowledge of the response
API
provided by the web framework. It relies on the assumption that something else will take
care of building a proper HTTP response based on the specification described by the
object returned. What is that something else then? It will be a higher order
function that knows how to interpret the specification object and act accordingly.
This is a oversimplified version of what this function could be:
// withLogging implementation
function withResponseBuilder(fn) {
return (request, response) => {
const result = fn(request, response);
response.status(result.status)
.append('Content-Type', getContentTypeFromSpec(result.type))
.send(encodeResponseBody(result.body));
return result;
}
}
const enhancedGetCourse = withResponseBuilder(
withLogging(
getCourse,
'info',
(request) => `Trying to access course with id ${request.params('id')}`,
),
);
From a large scale point of view, our response specification object is an example of a
domain-specific language
and the withResponseBuilder
function works as an interpreter of
the former. A DSL interpreter is a complex piece of software. A response specification
language can include many constructs such as special headers, custom body renderers, etc.
Our newest higher order function is just a façade of a more intricate mechanism. For more
examples of defining behavior as data, you can check the
node-machine project and more
specifically, sails'
machine-as-action.
Let’s update our test specification file to remove the response API dependency.
// getCourse.spec.js
// code hidden to save space
it('should return a 404 response indicating the course does not exist', () => {
const sut = getCourseFactory(courseService, userService);
const request = {
params: jest.fn(() => courseId),
};
courseService.findById.mockReturnValueOnce(null);
const response = sut(request);
expect(courseService.findById).toHaveBeenCalledWith(courseId);
expect(response).toEqual({
status: 400,
body: { message: COURSE_NOT_FOUND }
});
});
The work needed to test getCourse
’s response went from mocking a response object and
asserting method calls over that mock to just checking if the object returned satisfies
our expectations. As we implement more services in our platform, this simplification will
save us a lot of time.
In the 3rd and last part of this series, we’ll extract our third concern: authorization and explore how higher order function can interact with each other.