Implement Ports and Adapters in a Hexagonal Architecture with NodeJS and MongoDB


Posted on Tue, Aug 1, 2023 typescript architecture node database

The hexagonal, ports and adapters architecture promotes separation of concerns and testability by dividing an application into business logic with input and output adapters.

In this guide we will demonstrate a practical implementation in Node.js using MongoDB for the database and TypeScript for static typing.

We'll define the core domain logic and ports, implement MongoDB and REST adapters, and wire them together in a decoupled architecture. By the end, you'll understand the structure and benefits of ports and adapters for building maintainable, testable Node.js applications.

Introduction to The Hexagonal Architecture

Hexagonal Architecture is a software architecture that promotes a clean and modular approach to building applications. Developed by Alistair Cockburn, this architectural style helps developers create systems that are maintainable, scalable, and easy to test.

⬇️

The central idea behind Hexagonal Architecture is to separate the core business logic from the external dependencies. By dividing the application into three main components, Hexagonal Architecture achieves this separation of concerns and testability :

Ports (Interfaces) πŸ”Œ

Ports represent the contract between the Application Core and the outside world. They define the boundaries through which the core interacts with external systems or actors. In Hexagonal Architecture, ports are expressed as interfaces or abstract classes. By using interfaces, the Application Core remains decoupled from the specific implementations of the external systems.

Adapters πŸ“³

Adapters are responsible for implementing the ports and bridging the gap between the Application Core and external resources. They convert external data into formats understandable by the application and vice versa. Adapters can be of different types, such as database adapters, web service adapters, or user interface adapters.

The separation of concerns achieved by Hexagonal Architecture allows developers to focus on different parts of the system independently, making the codebase easier to maintain and extend. In the next section we’ll look into the layers that help in the separation of concerns in the hexagonal architecture.

Hexagonal Architecture Layers

⬇️

Hexagonal Architecture adopts a layered approach, ensuring a clear separation of concerns. The layers in the architecture are as follows :

Domain Layer 🌐

This innermost layer is the heart of the application. It contains the domain model, entities, value objects, and business rules. The domain layer is oblivious to external systems and focuses solely on representing the business logic. By keeping the Domain Layer clean and independent of external dependencies, it becomes easier to refactor and modify the core business rules without affecting the infrastructure.

Application Layer πŸ“²

The Application Layer orchestrates the use cases by invoking the appropriate methods from the Domain Layer. It acts as the mediator between the user interfaces and the Domain Layer. The Application Layer is also responsible for managing transactions and security.

By separating the application-specific concerns from the core business logic, the Application Layer becomes a clear entry point for handling user interactions.

Infrastructure Layer πŸ›€οΈ

The outermost layer is the Infrastructure Layer, housing all the adapters for database sources and communication. It provides implementations for the Ports defined in the Application Core, enabling communication with external systems, such as databases, external APIs, and message queues. By keeping the Infrastructure Layer separate from the core business logic, the system gains flexibility in adopting new technologies and third-party services without affecting the essential business rules.

We have so far covered the vital parts of hexagonal architecture, in the next section we’ll look at the advantages and key benefits this architecture offers.

Key Benefits of Hexagonal Architecture

⬇️

Implementing Hexagonal Architecture in your projects can yield various advantages :

Testability πŸ†‘

Suppose you are building a product management application that allows users to manage their inventory, track sales, and analyze product performance. With Hexagonal Architecture, you can easily create unit tests for the core business logic of the product management application without relying on real databases or external APIs.

By mocking the database and other external dependencies, you can test various product management scenarios, such as adding new products, updating inventory levels, or generating sales reports, in isolation since .

This testing capability ensures that the product management application behaves correctly, regardless of the specific database or external services used. It also allows you to catch and fix bugs early in the development process, leading to a more robust and reliable application.

Modularity ⏹️

Hexagonal Architecture promotes a modular design, which enhances the maintainability of your application. Each component of the architecture has clear responsibilities and well-defined boundaries, reducing the risk of tightly coupled code.

With a clear separation between the Application Layer, Domain, Ports, and Adapters, it becomes easier to understand the codebase and make changes without inadvertently affecting other parts of the system. This modularity facilitates code reuse, makes it simpler to add new features, and reduces the impact of changes, making the codebase more robust and maintainable over time.

🧠

A MODULARITY CASE SCENARIO : Imagine you are developing a content management system (CMS) using Hexagonal Architecture. If you need to introduce a new content type, such as "events" you can create new ports and adapters for handling events without modifying the existing core logic. This modularity allows you to add new features to your CMS with minimal disruption to the existing codebase, making development more efficient and reliable.

Scalability πŸͺœ

With Hexagonal Architecture, your application becomes more scalable. The clear boundaries defined by ports allow you to swap or add adapters to accommodate new requirements, such as using different databases or external APIs.

πŸ€”

A SCALABLE CASE SCENARIO : Suppose your social networking platform, built with Hexagonal Architecture, experiences rapid growth in user activity. To handle the increased database load, you can replace the existing database adapter with a more scalable and performant solution. Since the Application Core is decoupled from the database implementation, this change can be made in the Infrastructure Layer, enabling your application to scale without impacting the core functionality.

Independence of Technologies 🎑

Hexagonal Architecture allows you to choose and switch technologies for the adapters independently, without affecting the core business rules. This independence provides the flexibility to select the most suitable tools and services for each aspect of your application.

πŸ’‘

A CASE SCENARIO FOR INDEPENDENT TECHNOLOGIES : Consider an e-commerce application that needs to integrate with various payment gateways. With Hexagonal Architecture, you can create different payment gateway adapters, each implementing the same port (Interface). This enables you to switch between payment gateways without making changes to the core logic. You can easily experiment with different payment providers to find the one that best suits your application's needs.

Flexibility πŸ’’

Hexagonal Architecture empowers developers to make changes and experiment with the application's components without affecting the overall system. You can replace or modify adapters as needed, enabling rapid prototyping and iterative development. πŸš€

πŸš€

A CASE SCENARIO FLEXIBILITY : Suppose you have developed a mobile application using Hexagonal Architecture, and you want to experiment with a different user interface framework to enhance user experience. With Hexagonal Architecture, you can implement a new user interface adapter using the new framework without rewriting the entire application. You can then compare the performance, usability, and user feedback before deciding to fully adopt the new framework.

Creating a project with Ports and Adapters in NodeJS

The hexagonal architecture pattern promotes separation of concerns for maintainable apps.

This section demonstrates implementing ports and adapters in Node.js with MongoDB and TypeScript - defining the domain and ports, building MongoDB and REST adapters, and wiring them together in a decoupled architecture.

Setting up the Project

Create a new TypeScript project and install the required dependencies :

# make demo app folder
mkdir hexarch-demo-app
# cd into the app folder
cd hexarch-demo-app
# initialize npm
npm init -y
# installing dependencies
npm install express cors body-parser mongoose axios papaparse 
# installing devDependencies
npm install @types/node @types/express @types/cors @types/body-parser @types/mongoose @types/papaparse --save-dev

The Project Structure

Create the following project structure :

hexarch-demo-app
  β”œβ”€β”€ src
  β”‚   β”œβ”€β”€ adapters
  β”‚   β”‚   β”œβ”€β”€ database
  β”‚   β”‚   β”‚   └── mongo.ts
  β”‚   β”‚   └── csv
  β”‚   β”‚       └── csv-parser.ts
  β”‚   β”œβ”€β”€ domain
  β”‚   β”‚   └── product.ts
  β”‚   β”œβ”€β”€ ports
  β”‚   β”‚   β”œβ”€β”€ http
  β”‚   β”‚   β”‚   β”œβ”€β”€ controllers
  β”‚   β”‚   β”‚   β”‚   └── product-controller.ts
  β”‚   β”‚   β”‚   └── routes.ts
  β”‚   β”‚   └── repository
  β”‚   β”‚       └── product-repository.ts
  β”‚   β”œβ”€β”€ app.ts
  β”‚   └── server.ts
  β”œβ”€β”€ .env.example
  β”œβ”€β”€ products.csv
  └── index.ts

Database Setup with MongoDB Atlas

  1. Go to https://www.mongodb.com/cloud/atlas and sign up or log in.
  2. Create a new project and cluster.
  3. Get the MongoDB connection string (a URI).

Implement a CSV Parser

In src/adapters/csv/csv-parser.ts, implement a CSV parser to read the sample CSV file. For simplicity, we'll use the PapaParse library for parsing CSV data.

import Papa from 'papaparse'; 
import fs from 'fs';

// export a function to parse a CSV file 
export const parseCSVFile = (filePath: string): Promise<any[]> => {

  // return a new Promise
  return new Promise((resolve, reject) => {

    // use fs to read the file 
    fs.readFile(filePath, 'utf8', (err, data) => {

      // if there is an error, reject the Promise with the error
      if (err) {
        console.error('error reading CSV file:', err);
        reject(err);
      
      // otherwise, parse the CSV data
      } else {
        const results = Papa.parse(data, {
          header: true, 
          skipEmptyLines: true,
        });

        // resolve the Promise with the parsed data
        resolve(results.data);
      }
    });
  });
}

This code reads a CSV file using PapaParse library, parses it, and returns the data as an array asynchronously.

Our first MongoDB Adapter

In src/adapters/database/mongo.ts, implement the MongoDB adapter to connect to the database and interact with the products collection.


import mongoose from 'mongoose';
import { Product } from '../../domain/product';

const MONGODB_URI = "[enter URI here]";

// set timeout limits 
mongoose.connect(MONGODB_URI, {
  socketTimeoutMS: 30000,
  connectTimeoutMS: 30000  
});

const db = mongoose.connection;

// handle MongoDB connection events
db.on('error', (error) => {
  console.error('mongoDB connection error:', error);
});

db.once('open', () => {
  console.log('connected to MongoDB');
});

// define the product schema
const productSchema = new mongoose.Schema({
	// name field is required
  name: { type: String, required: true }, 
	// price field is required
  price: { type: Number, required: true }, 
  description: { type: String },
});

// create the ProductModel using the schema
export const ProductModel = mongoose.model('Product', productSchema);

// create the MongoProductRepository
export class MongoProductRepository {
  async save(product: Product): Promise<Product> {
    // validate the product before saving it to the database
    if (!product.name || !product.price) throw new Error('Product name and price are required.');
    
    const productModel = new ProductModel(product);

    try {
      await productModel.save();
      return product;
    } catch (error) {
      console.error('error saving product to the database:', error);
      throw error;
    }
  }

  async getAll(): Promise<Product[]> {
    try {
      return ProductModel.find();
    } catch (error) {
      console.error('error fetching products from the database:', error);
      throw error;
    }
  }
}

This code connects to MongoDB using Mongoose, defines a product schema, and provides functions to save and fetch products from the database. It also handles connection events and errors.

The Product Domain Model

In src/domain/product.ts, define the Product domain model :

// export the product interface
export interface Product {
  name: string;
  price: number;
  description?: string;
}

Product Repository

In src/ports/repository/product-repository.ts, define the ProductRepository interface and implement the MongoDB ProductRepository.

import { Product } from '../../domain/product';
import { ProductModel } from '../../adapters/database/mongo'; 

// interface for ProductRepository
export interface ProductRepository {

  // method to save a Product
  save(product: Product): Promise<Product>;

  // method to get all Products
  getAll(): Promise<Product[]>;

}

// MongoProductRepository implements ProductRepository
export class MongoProductRepository implements ProductRepository {

  // save Product implementation
  async save(product: Product): Promise<Product> {

    // create Mongoose model from Product
    const productModel = new ProductModel(product);

    try {
      // save to MongoDB
      await productModel.save(); 

      // return saved Product
      return product;

    } catch (error) {
      // log any errors
      console.error('error saving product to the database:', error);

      // bubble up error
      throw error;
    }
  }

  // get all Products implementation
  async getAll(): Promise<Product[]> {

    try {
      // find Products using Mongoose model
      return ProductModel.find();

    } catch (error) {
      // log any errors
      console.error('error fetching products from the database:', error);
			throw error;
    }
  }

}

This code defines a ProductRepository interface with save and getAll methods, and implements it using MongoProductRepository which interacts with the MongoDB database using Mongoose.

The Product Controller

In src/ports/http/controllers/product-controller.ts, implement the ProductController responsible for handling HTTP requests related to products :


import { Request, Response } from 'express';
import { Product } from '../../../domain/product';
import { ProductRepository } from '../../repository/product-repository';

export class ProductController {

  // dependency inject ProductRepository
  private productRepository: ProductRepository;

  constructor(productRepository: ProductRepository) {
    this.productRepository = productRepository;
  }

  // post /products endpoint
  async createProduct(req: Request, res: Response): Promise<void> {

    try {
      // get product data from request body
      const { name, price, description } = req.body;

      // create Product object
      const product: Product = { name, price, description };

      // save product using repository
      const savedProduct = await this.productRepository.save(product);

      // send 201 response with saved product
      res.status(201).json(savedProduct);

    } catch (error) {
      // log any errors
      console.error('error creating product:', error);

      // send 500 error response
      res.status(500).json({ error: 'failed to save product.' });
    }
  }

  // get /products endpoint
  async getProducts(req: Request, res: Response): Promise<void> {

    try {
      // get products from repository
      const products = await this.productRepository.getAll();

      // send 200 response with products
      res.status(200).json(products);

    } catch (error) {
      // log any errors
      console.error('error fetching products:', error);

      // send 500 error response
      res.status(500).json({ error: 'failed to fetch products.' });
    }
  }

}

This code defines a ProductController with createProduct and getProducts methods, interacting with the ProductRepository to handle HTTP requests for product creation and retrieval.

HTTP Routes

In src/ports/http/routes.ts, define the HTTP routes and their corresponding handlers :

import express from 'express';
import { ProductController } from './product-controller';
import { ProductRepository } from '../../repository/product-repository';

export const createHttpRouter = (productRepository: ProductRepository): express.Router => {

	// router from express
  const router = express.Router();
  const productController = new ProductController(productRepository);

  // route for creating a new product
  router.post('/products', productController.createProduct.bind(productController));
  // route for fetching all products
  router.get('/products', productController.getProducts.bind(productController));

  return router;
};

The code exports a function createHttpRouter that sets up an Express router. It takes a ProductRepository as input and creates a ProductController to handle HTTP requests for creating and fetching products. It then defines routes for creating a new product and fetching all products using the controller's methods.

Our App and its Server

In src/app.ts, create the App class to configure Express and its middlewares :

import express, { Express } from 'express'; 
import cors from 'cors';
import bodyParser from 'body-parser';

export class App {

  // express app instance
  private expressApp: Express;

  constructor() {
    // initialize express app
    this.expressApp = express();

    // call method to configure middlewares
    this.setupMiddlewares(); 
  }

  // configure express middlewares
  private setupMiddlewares(): void {

    // enable CORS for all routes
    this.expressApp.use(cors());

    // parse application/json 
    this.expressApp.use(bodyParser.json());
  }

  // get configured express app instance
  public getExpressApp(): Express {
    return this.expressApp;
  }

}

The code defines an App class that sets up an Express server with CORS and body-parser middlewares. The class provides a method to get the configured Express app instance. It simplifies the process of creating an Express server with commonly used middlewares for handling JSON data and enabling CORS.

In src/server.ts, create the Server class to start the application and attach the HTTP routes :

import 'dotenv/config';
import { parseCSVFile } from './adapters/csv/csv-parser';
import { MongoProductRepository } from './adapters/database/mongo';
import { Product } from './domain/product';
import { App } from './app';
import { createHttpRouter } from './ports/http/controllers/routes';

const startServer = async () => {

    try {
      // read products.csv and parse its data
      const products: Product[] = await parseCSVFile('products.csv');
  
      // connect to the MongoDB Atlas database
      const productRepository = new MongoProductRepository();
  
      // save the products to the database
      for (const product of products) await productRepository.save(product);
   
      // create the Express app and attach the HTTP routes
      const app = new App().getExpressApp();
      const httpRouter = createHttpRouter(productRepository);
      app.use('/api', httpRouter);
  
      // start the server
      const PORT = process.env.PORT || 5000;
      app.listen(PORT, () => {
        console.log(`server is running on port ${PORT}`);
      });

    } catch (error) {
      console.error('error loading and saving products:', error);
			// terminate the application in case of an error during setup
      process.exit(1); 
    }

  };
  
  startServer();

The code in server.ts sets up an Express server to handle HTTP requests. It reads data from a CSV file named "products.csv," parses it into Product objects, and saves them to a MongoDB Atlas database. The server listens on the specified port (default 5000) and routes HTTP requests to appropriate controllers for the products API.

Create a new products.csv file in the root directory and add some product samples. You can use the one below to get started :

name,price,description
Product 1,10,This is product 1
Product 2,15,This is product 2
Product 3,20,This is product 3

Running the Application

Before running the application, add the start script to your package.json files :

"scripts": {
    "start": "ts-node ./src/server.ts"
}

Run the application using the following command :

# start the server
npm start

⬇️

If all steps were taken in consideration, you should see get an output like the one below in your terminal :

Testing Endpoints

You can use tools like Postman or CURL to test the API endpoints.

Adding a product :

  • URL: http://localhost:5000/api/products
  • Method: POST
  • Body: { "name": "New Product", "price": 25, "description": "This is a new product." }

Fetching all products :

  • URL: http://localhost:5000/api/products
  • Method: GET

If you use get request method on Postman you should get a JSON response like the one below:

That's it 🎈 you now have a simple product management application with the bases of a Hexagonal Architecture !

The project can be downloaded below :

hexarch-demo.zip15.9KB

Conclusion πŸ”₯

In this guide, we saw how to implement Hexagonal architecture with Node.js, MongoDB, and TypeScript. We defined domain models and business logic in isolation, created ports to abstract external interactions, built adapters to implement the ports, and wired them together in the application layer.

This separation of concerns and loose coupling provides many benefits - the domain can be tested without infrastructure, adapters can be swapped out, and the app is positioned for maintainability and evolution. .