Implementing a Clean Architecture Modular Application in Nuxt/Vue Typescript Part 2: Services
Published on Feb 24, 2021 by Diego Rodriguez Mancini.
Introduction
NOTE: This is the second part on a series of posts. The first part covers all the necessary topics so I’m not going to talk too much about that stuff again.
In the first part we implemented the Domain layer: created the Task entity and a TaskRepository interface. Now we are going to create a TaskService that implements that interface. Then we will create a HttpService to fetch tasks from an API. This API does not exist, so we will create a mock service and use dependency injection to decouple the service from a specific implementation (like the mock or real API service).
What we will see in this part:
- HttpService with Axios
- Mocking the http service with mock-axios-adapter
- Result class with neverthrow
- Dependency Injection with Inversify-Props
The full code is available in this repository.
The Service Environment
Figure 1: TaskService class diagram
The above diagram shows the main classes we will be building. The task service implements the TaskRepository interface. I also creates an instance of TaskParser in the constructor and we will inject the HttpService.
TaskService
The task service is the main class that performs the use cases. It should interact with an external service, our API, and parse the data to and from domain entities.
TaskParser
This class is in charge of converting entities to data transfer objects and vice versa. The data transfer object could be comming from the external API or from user input as a RequestObject.
HttpService
This class is in charge of requesting data from an external API. We will be using Axios to perform http requests. To make this service more generic, we will be using a factory called AxiosCreator that will provide axios instance objects that can be mocked using mock-axios-adapter.
AxiosCreator
This is the factory class that creates axios instances.
The tests first
For unit testing this, I’m going to consider the class diagram as a unit. So, this unit contains the TaskService, TaskParser and HttpService and the tests will include all these classes. The specification file is short, because we are only testing the getMany
method of the TaskService class.
// src/app/modules/task/infrastructure/__tests__/TaskService.spec.ts import { cid, container, mockTransient, resetContainer } from "inversify-props" import { containerBuilder } from "@@/src/ui/plugins/inversify" import { IAxiosCreator } from "@@/src/app/shared/http" import { ITaskRepository } from "../../domain" beforeEach(() => { resetContainer() containerBuilder() }) describe('TaskService', () => { describe('getMany', () => { it('should get tasks', async () => { const service = container.get<ITaskRepository>(cid.TaskService) const result = await service.getMany() expect(result.isOk()).toBeTruthy() }) it('should return error result', async () => { const service = container.get<ITaskRepository>(cid.TaskService) const result = await service.getMany() expect(result.isErr()).toBeTruthy() }) it('should return error result on timeout', async () => { const service = container.get<ITaskRepository>(cid.TaskService) const result = await service.getMany() expect(result.isErr()).toBeTruthy() }) }) })
As you can see, the tests initializes the service and checks if the results are ok or errored. Remember what I said about using a result class, this class contains the results or the error, in case something goes wrong.
Result Class
I suggest you read this article about the Result class. If you don’t want to read, the TLDR is that we will encapsulate the return data from every use case in a Result class. Instead of throwing errors, we will treat everything as a Result and perform error checks on this Result. We can also combine multiple results in a single Result class that returns a single error or the valid results.
Luckily there is a result class already implemented in typescript in the library neverthrow. This class has ok
and err
functions to make a Result. For example:
import { ok } from 'neverthrow' const myResult = ok({ myData: 'test' }) // instance of `Ok` myResult.isOk() // true myResult.isErr() // false
Inversify-Props
What is this container object in the spec file? The container is an object provided by the library Inversify-Props, it contains the necessary dependencies that we can inject to our classes. In this case we are using container.get
to get an instance of TaskService. This is the same as creating a new
object but it has all dependencies that the service uses (provided they are already added to the container)
The code
TaskService
import { Result, combine } from 'neverthrow' import { ITaskData, ITaskRepository } from '../domain/Task.types' import { TaskParser } from './TaskParser' import { ITaskParser, ITaskResponse } from './TaskService.types' import { inject } from 'inversify-props' import { HttpError, IHttpService } from '@@/src/app/shared/http' import { ParseError } from '@@/src/app/shared/error/ParseError' export class TaskService implements ITaskRepository { private parser: ITaskParser private httpService: IHttpService constructor(@inject() httpService: IHttpService) { this.parser = new TaskParser() this.httpService = httpService this.httpService.initService('https://my-url') } create(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error('Method not implemented.') } remove(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error('Method not implemented.') } edit(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error('Method not implemented.') } getOne(): Promise<Result<ITaskData, ParseError | HttpError>> { throw new Error('Method not implemented.') } async getMany(): Promise<Result<ITaskData[], ParseError | HttpError>> { const parseTo = (taskResponse: ITaskResponse) => { const tasks = taskResponse.items.map(this.parser.toDomain) return combine(tasks) } return await this.httpService.get<ITaskResponse, ITaskData[]>( { url: '/tasks' }, { parseTo } ) } }
Our TaskService class follows the diagram in the previous section. Only one method from the interface is implemented: getMany()
.
TaskParser
The TaskParser has a toDomain
method that receives data from the API and converts it to a TaskEntity. If the creation of the Task entity is successful it returns an Ok result. If it fails returns an Err result, but not throw an error.
export class TaskParser implements ITaskParser { toDomain(data: HttpTask): Result<ITask, ParseError> { try { const taskData: ITaskData = { title: data.title, description: data.description, state: data.state as TaskState, schedule: data.schedule ? new Date(data.schedule) : null, due: data.due ? new Date(data.due) : null, } const task = new Task(taskData) return ok(task) } catch (error) { return err(new ParseError(error.message)) } } }
HttpService
For the HttpService I’m going to cut the interface declarations and some private methods. You can go ahead and check the repository to see the full code. Also, most of the code was taken from another great nuxt + clean architecture repository.
export class HttpService implements IHttpService { private axiosService: AxiosInstance private axiosCreator: AxiosCreator constructor(axiosCreator: AxiosCreator) { this.axiosCreator = axiosCreator this.axiosService = axios.create() } initService(baseUrl: string) { this.axiosService = this.axiosCreator.createAxiosInstance(baseUrl) this._initializeRequestInterceptor() this._initializeResponseInterceptor() } public async get<T, M>( { url, config }: IHttpRequest, parser: Parser<T, M> ): Promise<Result<M, ParseError | HttpError>> { try { const response = await this.axiosService.get<T>(url, config) return this._parseFailable<T, M>(response.data, parser.parseTo) } catch (error) { return err(error) } } private _parseFailable<T, M>( data: T, parser: FailableParser<T, M> ): Result<M, ParseError> { try { return parser(data) } catch (error) { const parseError = new ParseError(error.message) return err(parseError) } } }
Ok, we have our HttpService that uses a factory AxiosCreator to make an instance of AxiosInstance. We can initialize the HttpService and pass a factory that creates a mocked AxiosInstance for unit testing or a real instance for connecting to the API. By doing this we are actually making a very basic version of dependency injection. Where do we create or instantiate all this injectable dependencies ? It will depend on the application, usually, there is a “container” that creates all the injectable dependencies and the dependent classes refer to the container. This is exactly what Inversifyjs does for dependency injection.
Dependency Injection
Inversifyjs provides a container object that we can bind to our dependencies, like the AxiosCreator or the HttpService (even the TaskService will be injectable) and then inject those dependencies using a decorator, which will in other words get the objects from the container and instantiate them accordingly.
For an even easier interface, we will use Inversify-Props which is a wrapper for Inversifyjs designed to be used with class property decorators like Nuxt-Property-Decorator.
In the HttpService we are injecting an AxiosCreator. We can add our AxiosCreator class to the container, and later specify a MockAxiosCreator that creates fake axios instances. That way, we can leave the HttpService and also our TaskService and their unit tests specification untouched, and just add a mock class to the container and everything else will work just the same.
Lets create the AxiosCreator class and inject it to the HttpService using Inversify-Props:
import { inject } from 'inversify-props' export interface IAxiosCreator { createAxiosInstance(baseUrl: string): AxiosInstance } export class AxiosCreator implements IAxiosCreator { createAxiosInstance(baseUrl: string): AxiosInstance { return axios.create({ baseURL: baseUrl, headers: { 'Content-Type': 'application/json' } }) } } export class HttpService implements IHttpService { private axiosService: AxiosInstance private axiosCreator: AxiosCreator constructor(@inject() axiosCreator: AxiosCreator) { this.axiosCreator = axiosCreator this.axiosService = axios.create() } initService(baseUrl: string) { this.axiosService = this.axiosCreator.createAxiosInstance(baseUrl) this._initializeRequestInterceptor() this._initializeResponseInterceptor() } ... }
In the same way we can now inject the HttpService to the TaskService:
import { inject } from 'inversify-props' import { HttpError, IHttpService } from '@@/src/app/shared/http' export class TaskService implements ITaskRepository { private parser: ITaskParser private httpService: IHttpService constructor(@inject() httpService: IHttpService) { this.parser = new TaskParser() this.httpService = httpService this.httpService.initService('https://my-url') } }
With the dependencies injected, the only remaining step is to create the container. There should be just one container in the application, usually in Vue, the container is created with the creation of the app. Here, with Nuxt, a good place is to create the container in a Plugin that runs before every other setup.
NOTE: With Nuxt, the store is initialized before plugins. I decided to not use the Nuxt store, and instead use a regular store initialized as a Plugin. We will cover store in the next post.
The dependency injection plugin is as follows:
// src/ui/plugins/inversify.ts import 'reflect-metadata' import { container } from 'inversify-props' import { AxiosCreator, HttpService, IAxiosCreator, IHttpService } from '@@/src/app/shared/http' import { ITaskRepository } from '@@/src/app/modules/task/domain' import { TaskService } from '@@/src/app/modules/task/infrastructure' export function containerBuilder() { container.addTransient<IAxiosCreator>(AxiosCreator) container.addTransient<IHttpService>(HttpService) container.addTransient<ITaskRepository>(TaskService) } containerBuilder()
We are adding the AxiosCreator and HttpService dependencies to the container. When we call @inject
it will create the objects from the container. The order in which we add the dependencies also matters.
Notice that we are exporting the containerBuilder
function so we can create our container inside unit tests. The reflect-metadata is a restriction of the Inversify library, it is required to be imported only once.
Continue with the tests
Our tests had a problem. They expected that the AxiosCreator created a working axios instance with a real API, but we don’t want to make http calls to the production API in our unittests, so unless we have a testing API, we need to create a mocked axios instance. And to do that, we’ll use mock-axios-adapter.
Lets start by defining some fake data with the structure that the API will have.
const nextYear = new Date().setFullYear(new Date().getFullYear() + 1) export const mockApiTask: HttpTask[] = [ { title: 'Create Nuxt App', description: 'I have to make a nuxt sample app', state: 'DOING', schedule: null, due: null, }, { title: 'Drink water', description: 'Always drink water', state: 'TODO', schedule: null, due: nextYear.toLocaleString(), }, ]
The first task in our fake data has null dates, but the second task is set to one year foward, so that it is always a valid task.
Now continuing with the mock axios instance:
import axios, { AxiosInstance } from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { inject } from 'inversify-props'; import { IAxiosCreator } from '.'; import { HttpTask, ITaskResponse } from '../../modules/task/infrastructure'; export type TestActions = 'ok' | 'error' | 'timeout' | 'parseError' | 'clientError' | 'serverError' export class MockAxiosCreator implements IAxiosCreator { mock!: MockAdapter actionType: TestActions constructor(@inject('ActionType') actionType: TestActions) { this.actionType = actionType } createAxiosInstance(_baseUrl: string): AxiosInstance { const instance = axios.create({ baseURL: _baseUrl }); this.mock = new MockAdapter(instance); if (this.actionType === 'ok') this.setMockActions() if (this.actionType === 'error') this.setErrorActions() if (this.actionType === 'timeout') this.setTimeoutActions() return instance } setMockActions() { this.mock.onGet('/tasks').reply((_config: any) => { const response: ITaskResponse = { total: 2, page: 1, hasNext: false, hasPrev: false, items: mockApiTask } return [200, JSON.stringify(response)] }) } setErrorActions() { this.mock.onGet('/tasks').networkError() } setTimeoutActions() { this.mock.onGet('/tasks').timeout() } }
We create a MockAxiosCreator class that will create an axios instance but wrapped in a MockAdapter class. We are also injecting a constant value actionType
that will set the mock actions for the instance, in this case, we want to test a successful query that returns the fake data, and error actions for a network error and timeout.
But how do we use the mock instance in our tests?
Unit testing with mocks using inversify-props
Inversify-Props has a mockTransient
method to replace a class in the container with a mock, so we are replacing the AxiosCreator class with the MockAxiosCreator class. Now, when the HttpService calls for the AxiosCreator it will actually use the mocked one.
// src/app/modules/task/infrastructure/__tests__/TaskService.spec.ts import { cid, container, mockTransient, resetContainer } from "inversify-props" import { containerBuilder } from "@@/src/ui/plugins/inversify" import { IAxiosCreator } from "@@/src/app/shared/http" import { ITaskRepository } from "../../domain" beforeEach(() => { resetContainer() containerBuilder() mockTransient<IAxiosCreator>(cid.AxiosCreator, MockAxiosCreator) }) describe('TaskService', () => { describe('getMany', () => { it('should get tasks', async () => { container.bind<TestActions>('ActionType').toConstantValue('ok') const service = container.get<ITaskRepository>(cid.TaskService) const result = await service.getMany() expect(result.isOk()).toBeTruthy() }) it('should return error result', async () => { container.bind<TestActions>('ActionType').toConstantValue('error') const service = container.get<ITaskRepository>(cid.TaskService) const result = await service.getMany() expect(result.isErr()).toBeTruthy() }) it('should return error result on timeout', async () => { container.bind<TestActions>('ActionType').toConstantValue('timeout') const service = container.get<ITaskRepository>(cid.TaskService) const result = await service.getMany() expect(result.isErr()).toBeTruthy() }) }) })
The last thing we add to the spec file is the constant value injected to the MockAxiosCreator. So in each test we bind a value to the ActionType attribute used by the MockAxiosCreator to return data from a successful query or throw an error.
If we execute the tests on this file now, we should get three of them passing.
PASS src/app/modules/task/infrastructure/__tests__/TaskService.spec.ts TaskService getMany ✓ should get tasks (8 ms) ✓ should return error result (2 ms) ✓ should return error result on timeout (2 ms) ---------------------------------|---------|----------|---------|---------|---------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ---------------------------------|---------|----------|---------|---------|---------------------- app/modules/task/infrastructure | 72.73 | 37.5 | 44.44 | 70.97 | TaskParser.ts | 61.54 | 37.5 | 50 | 61.54 | 18-32 TaskService.ts | 76.47 | 100 | 42.86 | 73.33 | 19-28 app/shared/http | 87.96 | 30 | 62.5 | 87.62 | HttpError.ts | 30.77 | 0 | 0 | 25 | 14-32,37 HttpService.ts | 86.67 | 50 | 90 | 85.71 | 37,78-79,103 HttpStatusCode.ts | 100 | 100 | 100 | 100 |
Conclusion
In this part of the series, we created a TaskService that uses an HttpService to fetch tasks from an API, parse them and return an Ok or Err result class which allows us for better error handling. We also succcessfully implemented dependency injection within our HttpService and TaskService, while also injecting a mock axios instance for unit testing.
This was a longer and more complex post. There is a lot going on with the Result class of neverthrow
, Dependency Injection with Inversify-Props
and mocking Axios, so I suggest you go and look at the full code in the repository.
The next post will cover the Interactor class, which will implement the executor pattern, and Vuex store. We will also use dependency injection again to inject the interactor to the vuex module and create a TaskService mock for unit testing the store.