Two of the most powerful technologies today for building Restful API applications are the Spring Boot and the NodeJS frameworks.
Recently, I was working on an application that was divided into two parts. A backend API server built with Spring Boot and a frontend cross-platform mobile application. The frontend was communicating through HTTP with the server, whose responsibility was to receive an HTTP request, interact with the database, perform some calculations, and return a response.
The first option for the server technology was Spring Boot, as it is a wonderful framework for building APIs. It boosts a lot the development speed, it is secure and performs great. However, for several reasons, I decided relatively early to replace Spring Boot with NodeJS. NodeJS has become a very popular choice for building API applications in the last years, as it has many strong features to offer. You can find many articles that compare those two technologies and describe their pros and cons.
In this article, we are going to focus on the backend API migration from Spring Boot to NodeJS. Moreover, we will see how both Spring Boot and NodeJS interact with MongoDB.
The full github repositories for both apps can be found here:
Technologies Used
- Spring Boot
- Java
- MongoDB
- NodeJs
Implementation
Configuration and Prerequisites
Spring Boot
The starting point for every Spring Boot application is the main method annotated with @SpringBootApplication. The main() method uses Spring Boot’s SpringApplication.run() method to launch the application.
package com.antogeo.springbootmongocrud; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
Also, we need to create a second class with the @Configuration annotation, which will contain the application configuration. Here we just set the mongo template with the database name.
package com.antogeo.springbootmongocrud; import com.mongodb.MongoClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.core.MongoTemplate; @Configuration public class Config { @Bean public MongoClient mongo() { return new MongoClient("localhost"); } @Bean public MongoTemplate mongoTemplate() throws Exception { return new MongoTemplate(mongo(), "spring_node_crud_db"); } }
NodeJS
In the app.js we set the following :
- The API’s routers, which in our case is the AccountRouter.
- The mongoose connection.
- The body-parser, to be able to parse the request body.
- The express app.
const express = require('express'); const accounts = require('./api/routes/AccountRouter'); const dbProperties = require('./config/db'); const mongoose = require('mongoose'); const bodyParser = require('body-parser'); const app = express(); app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json()); app.use('/accounts', accounts); mongoose.connect(dbProperties.url, { useNewUrlParser: true }); app.use((req, res, next) =>{ const error = new Error('Not found'); error.status = 404; next(error); }) app.use((error, req, res, next) =>{ res.status(error.status || 500); res.json({ error:{ message : error.message } }) }) module.exports = app;
In the server.js we configure and start the server.
const port = 8000; const http = require('http'); const app = require('./app'); const server = http.createServer(app); server.listen(port); module.exports = server;
Build configuration (package.json vs pom.xml)
Spring Boot
In the pom.xml file we do the following:
- Setup the artifact id and the current version of the project.
- We configure the Spring-Boot
- We add any other dependencies that we need. In this case, no extra dependencies are required, because the ones that we will use (JUnit, Jackson, etc) are included in the Spring-Boot.
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.antogeo</groupId> <artifactId>spring-boot-mongo-crud</artifactId> <version>1.0-SNAPSHOT</version> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.5.RELEASE</version> </parent> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.0</version> <configuration> <source>11</source> <target>11</target> </configuration> </plugin> </plugins> </build> </project>
NodeJS
In the package.json file, we set our dependencies, the dev dependencies which are used for testing and development purposes, and the scripts which will help us run and test the app.
{ "name": "node-mongo-crud", "version": "1.0.0", "description": "A RESTful Crud API built with NodeJS and Mongo", "main": "index.js", "scripts": { "test": "mocha \"test/**/*.js\" --timeout 20000 --exit", "dev": "nodemon server.js" }, "author": "antonogeo", "license": "ISC", "dependencies": { "body-parser": "^1.19.0", "express": "^4.17.1", "mongodb": "^3.5.5", "mongoose": "^5.9.4" }, "devDependencies": { "chai": "^4.2.0", "chai-http": "^4.3.0", "mocha": "^7.1.1", "nodemon": "^2.0.2", "sinon": "^9.0.1" } }
Model
Spring Boot
The Account entity contains the id, the email, the first name, and the age. A constructor and getters/setters are also needed. Mongo DB will auto-generate the id.
package com.antogeo.springbootmongocrud.model; public class Account { private String id; private String email; private String firstName; private int age; public Account(String id, String email, String firstName, int age) { this.id = id; this.email = email; this.firstName = firstName; this.age = age; } // Getters and Setters }
NodeJS
In the Account model, we use the mongoose to create the Account Schema. The fields with their basic constraints are configured here. The schema maps to the MongoDB collection and defines the shape of the documents within that collection. In the end, we convert the Schema into a Model.
const mongoose = require('mongoose'); const accountSchema = mongoose.Schema({ _id: mongoose.Schema.Types.ObjectId, email: {type: String, unique: true, required: [true, "can't be blank"]}, firstName: {type: String, required: [true, "can't be blank"]}, age: {type: Number} }); module.exports = mongoose.model('accounts', accountSchema);
Repository Layer
Spring Boot
The Account repository is really minimal. Spring Data’s MongoRepository provides all the basic CRUD methods, meaning that we do not need to implement any of those. We just need to extend the MongoRepository with our type of values(Account) and id(String).
import org.springframework.data.mongodb.repository.MongoRepository; public interface AccountRepository extends MongoRepository<Account, String> { }
NodeJS
In the Account repository, we need to implement the three repository methods. MongoDB provides us with some really handy methods for the basic CRUD operations (save, findById, finOneAndDelete, etc).
const Account = require('../models/Account'); const mongoose = require('mongoose'); function getAccount(id) { return Account.findById(id).select('firstName').exec(); } function createAccount(account) { const mongoAccount = new Account({ _id: new mongoose.Types.ObjectId(), email: account.email, firstName: account.firstName, age: account.age }) return mongoAccount.save(); } function deleteAccount(id) { return Account.findOneAndDelete(id).exec(); } module.exports = { getAccount, createAccount, deleteAccount }
Service Layer
Spring Boot
We separate the Account Service layer into two parts: the interface and the implementation. We need to add Spring’s @Service annotation to the implementation.
package com.antogeo.springbootmongocrud.service; import com.antogeo.springbootmongocrud.model.Account; import java.util.Optional; public interface AccountService { void saveAccount(Account account); Optional<Account> findById(String id); void deleteAccount(String id); }
package com.antogeo.springbootmongocrud.service; import com.antogeo.springbootmongocrud.model.Account; import com.antogeo.springbootmongocrud.model.AccountRepository; import org.springframework.stereotype.Service; import java.util.Optional; @Service public class AccountServiceImpl implements AccountService { private AccountRepository repository; AccountServiceImpl(AccountRepository repository) { this.repository = repository; } @Override public void saveAccount(Account account) { repository.save(account); } @Override public Optional<Account> findById(String id) { return repository.findById(id); } @Override public void deleteAccount(String id) { Optional<Account> account = findById(id); account.ifPresent(account1 -> repository.delete(account1)); } }
NodeJS
In the service layer, we just need to get the request from the controller and pass it to the repository layer. In addition, any extra logic that will be needed in the future will be added here.
const AccountRepository = require('../repositories/AccountRepository'); function getAccount(id) { return AccountRepository.getAccount(id) .then(account => { return account; }) .catch(err => { return err; }); } function createAccount(account) { return AccountRepository.createAccount(account) .then(account => { return account; }) .catch(err => { return err; }); } function deleteAccount(id) { return AccountRepository.deleteAccount(id) .then(account => { return account._id; }) .catch(err => { return err; }); } module.exports = { getAccount, createAccount, deleteAccount}
Controller Layer
Spring Boot
In the Account controller, we need to add a @RestController annotation. Depending on our REST API, we need to add the proper mapping annotation to each method. Here we use 3 types:
- PostMapping, which configures a POST request. By using the @RequestBody we can have access to the request body which should contain the Account.
- GetMapping, which configures a GET request. We expect the Account id as a path variable “/account/{id}”.
- DeleteMapping. which configures a DELETE request. We expect the Account id as a path variable “/account/{id}”.
package com.antogeo.springbootmongocrud.controller; import com.antogeo.springbootmongocrud.model.Account; import com.antogeo.springbootmongocrud.service.AccountService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.Optional; @RestController public class AccountController { @Autowired private AccountService accountService; @PostMapping("/account") public void createAccount(@RequestBody Account account) { accountService.saveAccount(account); } @GetMapping("/account/{id}") public Account getAccount(@PathVariable String id){ Optional<Account> account = accountService.findById(id); return account.get(); } @DeleteMapping("/account/{id}") public void deleteAccount(@PathVariable String id){ accountService.deleteAccount(id); } }
NodeJS
The AccountRouter.js is responsible for configuring the REST API endpoints, and forward the request to the right controller.
const Router = require('express'); const AccountController = require('../controllers/AccountController'); const router = Router(); router.get('/:id', AccountController.getAccount); router.post('/', AccountController.createAccount); router.delete('/:id', AccountController.deleteAccount); module.exports = router;
After that, the AccountController.js extracts the request params or body and call the right service. Also, some basic error handling is performed here.
const AccountService = require('../services/AccountService'); function getAccount (req, res, next) { AccountService.getAccount(req.params.id).then(account => { res.status(200).json({ account: account }) }).catch(err => { res.status(500).json({ error: err.message }) }); } function createAccount (req, res, next) { const account = { email: req.body.email, firstName: req.body.firstName, age: req.body.age, } AccountService.createAccount(account).then(account => { res.status(200).json({ account: account }) }).catch(err => { res.status(500).json({ error: err.message }) }); } function deleteAccount (req, res, next) { AccountService.deleteAccount(req.params.id).then(id => { res.status(200).json({ id: id }) }).catch(err => { res.status(500).json({ error: err.message }) }); } module.exports = { getAccount, createAccount, deleteAccount }
Testing
Here we examine the differences in the unit testing. I have created unit tests for the AccountService in both applications.
Spring Boot
The unit tests for the service methods are simple. For the create() and the delete() methods, we care if the repository method was called with specific parameters. For the find() method, we need assertions too.
package com.antogeo.springbootmongocrud.service; import com.antogeo.springbootmongocrud.model.Account; import com.antogeo.springbootmongocrud.model.AccountRepository; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import java.util.Optional; import static org.junit.Assert.*; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.*; @EnableConfigurationProperties @RunWith(SpringJUnit4ClassRunner.class) public class AccountServiceTest { private AccountRepository accountRepository; private AccountServiceImpl subject; @Before public void setup() { accountRepository = spy(AccountRepository.class); subject = new AccountServiceImpl(accountRepository); } @Test public void shouldSaveAnAccount(){ // Given Account account = new Account("test", "test@gmail.com", "Tom", 20); // When subject.saveAccount(account); // Then verify(accountRepository, times(1)).save(account); } @Test public void shouldDeleteAccount(){ // Given String id = "test2"; Account account = new Account(id, "test@gmail.com", "Tom", 20); when(accountRepository.findById(id)).thenReturn(Optional.of(account)); // When subject.deleteAccount(id); // Then verify(accountRepository, times(1)).delete(account); } @Test public void shouldFindAccountById(){ // Given String id = "test"; Account expected = new Account(id, "test@gmail.com", "Tom", 20); when(accountRepository.findById(id)).thenReturn(Optional.of(expected)); // When Optional<Account> actual = subject.findById(id); // Then assertTrue(actual.isPresent()); assertEquals(expected.getId(), actual.get().getId()); assertEquals(expected.getEmail(), actual.get().getEmail()); assertEquals(expected.getFirstName(), actual.get().getFirstName()); assertEquals(expected.getAge(), actual.get().getAge()); } }
NodeJS
Finally, For the unit tests in the NodeJS app, the following libraries were used :
- chai.js as an assertion library
- sinon.js for stubbing and mocking
- mocha.js as the test framework which will run the tests
I have created three tests here as well, for the three different service methods.
const chai = require('chai'); const sinon = require('sinon'); const AccountService = require('../../api/services/AccountService'); const AccountRepository= require('../../api/repositories/AccountRepository'); describe('Account Service Tests', () => { it('should get an acccount by id', (done) => { // Given const id = '5e75fc46cfc35c3fbceaf36b'; const expected = { _id: id, email: 'test@gmail.com', firstName: 'Tom', age: 20 } sinon.stub(AccountRepository, 'getAccount').resolves(expected); // When AccountService.getAccount(id).then(actual => { // Then chai.expect(actual.email).to.equal(expected.email); chai.expect(actual.firstName).to.equal(expected.firstName); chai.expect(actual.age).to.equal(expected.age); done(); }); }); it('should create an acccount', (done) => { // Given const account = { email: 'test22@gmail.com', firstName: 'George', age: 22 } sinon.stub(AccountRepository, 'createAccount').resolves(account); // When AccountService.createAccount(account).then(actual => { // Then chai.expect(actual.email).to.equal(account.email); chai.expect(actual.firstName).to.equal(account.firstName); chai.expect(actual.age).to.equal(account.age); done(); }); }); it('should delete an acccount', (done) => { // Given const id = '5e75fc46cfc35c3fbceaf36b'; const account = { _id: id, email: 'test22@gmail.com', firstName: 'George', age: 22 } sinon.stub(AccountRepository, 'deleteAccount').resolves(account); // When AccountService.deleteAccount(account).then(actual => { // Then chai.expect(actual).to.equal(id); done(); }); }); })