Clean Architecture: TypeScript Express API
Photo by Max Vakhtbovych from Pexels

Clean Architecture: TypeScript Express API

By employing clean architecture, you can design applications with very low coupling and independent of technical implementation details. That way, the application becomes easy to maintain and flexible to change. Clean architecture allows us to create architectural boundaries between dependencies which allows components to be swapped out and intrinsically testable.

What's to follow is our attempt to build and API using clean architecture with TDD. The API will be used to manage and organize contacts. We'll use TypeScript.

Setup Project

Create a folder and npm initialize it.

1$ mkdir contacts-api 2$ cd contacts-api 3$ npm init -y

We're going to need a few packages.

1$ npm i -D typescript jest ts-node ts-jest supertest @types/node

Here we're adding the TypeScript system, the Jest test framework, along with type definitions for Jest and node. Without installing those, TypeScript doesn't know any of the type details for Node or for Jest.

Configuration of system code

Next, we have to create a tsconfig.json file. The tsconfig.json file specifies the root files and the compiler options required to compile the project. We use tsc do that for us. (tsc is the Typescript compiler executable.)

1$ npx tsc --init

For our system we're only going to adjust two options - outDir (where to put the compiled Javascript) and rootDir (where the source code lives):

1 "outDir": "./lib", 2 "rootDir": "./src",

Configuration of test environment

Let's set up our test environment. Because we're going to use TypeScript while writing tests, we will run TypeScript in a precompiled way using ts-jest.

ts-jest is a TypeScript preprocessor with source map support for Jest that lets you use Jest to test projects written in TypeScript Let's initialize jest for TypeScript testing

1$ npx ts-jest config:init

A jest.config.js file will be created and should look like this:

1module.exports = { 2 preset: "ts-jest", 3 testEnvironment: "node" 4};

Finally, let's add a script to the package.json file and to and watch all tests. Monitoring of code coverage is useful as well:

1 "scripts": { 2 "test": "jest --watchAll --collectCoverage", 3

And that's it for project setup and configuration. Let's start looking at the application now.

The Plan

At a high level we'd like our API to be structured in the following way

Folder Structure

Let's use files and folders to structure our application. Doing this allows us to communicate architecture intent:

/src
│── presentation
│   └── routers
│       └── contact-router.ts
├── domain
│   ├── interfaces
│   │   ├── repositories
│   │   │    └── contact-repository.ts
│   │   └── use-cases
│   │       └── contact
│   │           ├── get-all-contacts.ts
│   │           └── create-contact.ts
│   ├── entities
│   │   └── contact.ts
│   ├── repositories
│   │   └── contact-repository.ts
│   └── use-cases
│       └── contact
│           ├── get-all-contacts.ts
│           └── create-contact.ts
└── data
    ├── interfaces
    │   └── data-sources
    │       └── contact-data-source.ts
    └── data-sources
        └── mongodb
            ├── models
            │   └── contact-model.ts
            └── mongodb-contact-data-source.ts
    

The presentation layer would mainly be used for inputting and outputting user data (API routes).

The inner core domain layer holds all business logic (use cases).

The data layer holds all infrastructure implementations (data sources).

TDD

Let's write our first test. We'll start with the get-all-contacts use case

GetAllContacts Use Case Test
1//test/domain/use-cases/contact/get-all-contacts.test.ts 2import { Contact } from "../../../../src/domain/entities/contact"; 3import { ContactRepository } from "../../../../src/domain/interfaces/repositories/contact-repository"; 4import { GetAllContacts } from '../../../../src/domain/use-cases/contact/get-all-contacts' 5 6describe("Get All Contacts Use Case", () => { 7 8 class MockContactRepository implements ContactRepository { 9 getContacts(): Promise<Contact[]> { 10 throw new Error("Method not implemented."); 11 } 12 } 13 let mockContactRepository: ContactRepository; 14 15 beforeEach(() => { 16 jest.clearAllMocks(); 17 mockContactRepository = new MockContactRepository() 18 }) 19 20 test("should return data", async () => { 21 const ExpectedResult = [{ id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" }] 22 23 jest.spyOn(mockContactRepository, "getContacts").mockImplementation(() => Promise.resolve(ExpectedResult)) 24 const getAllContactsUse = new GetAllContacts(mockContactRepository) 25 const result = await getAllContactsUse.execute(); 26 expect(result).toStrictEqual(ExpectedResult) 27 28 }); 29 30})

Before we run the tests we have failing imports because the files don't exist

1 FAIL test/domain/use-cases/contact/get-all-contacts.test.ts 2 ● Test suite failed to run 3 4 test/domain/use-cases/contact/get-all-contacts.test.ts:2:25 - error TS2307: Cannot find module '../../../../src/domain/entities/contact' or its corresponding type declarations. 5 6 2 import { Contact } from "../../../../src/domain/entities/contact"; 7 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 8 test/domain/use-cases/contact/get-all-contacts.test.ts:3:35 - error TS2307: Cannot find module '../../../../src/domain/interfaces/repositories/contact-repository' or its corresponding type declarations. 9 10 3 import { ContactRepository } from "../../../../src/domain/interfaces/repositories/contact-repository"; 11 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 12 test/domain/use-cases/contact/get-all-contacts.test.ts:4:32 - error TS2307: Cannot find module '../../../../src/domain/use-cases/contact/get-all-contacts' or its corresponding type declarations. 13 14 4 import { GetAllContacts } from '../../../../src/domain/use-cases/contact/get-all-contacts'

Let's write code to pass the test. For that, we have to create the missing files

Contact Entity
1//domain/entities/contact.ts 2export interface Contact { 3 id?: string; 4 email: string; 5 firstName: string; 6 surname: string; 7}
Contact Repository Interface
1// domain/interfaces/repositories/contact-repository.ts 2import { Contact } from "../../entities/contact"; 3 4export interface ContactRepository { 5 createContact(contact: Contact): Promise<boolean>; 6 getContacts(): Promise<Contact[]>; 7} 8
GetAllContacts Use Case Interface
1// domain/interfaces/use-cases/get-all-contacts-use-case.ts 2import { Contact } from "../../entities/contact"; 3 4export interface GetAllContactsUseCase { 5 execute(): Promise<Contact[]>; 6}
GetAllContacts Use Case Code
1// domain/use-cases/contact/-get-all-contacts.ts 2import { Contact } from "../../entities/contact"; 3import { ContactRepository } from "../../interfaces/repositories/contact-repository"; 4import { GetAllContactsUseCase } from "../../interfaces/use-cases/get-all-contacts-use-case1"; 5 6export class GetAllContacts implements GetAllContactsUseCase { 7 contactRepository: ContactRepository 8 constructor(contactRepository: ContactRepository) { 9 this.contactRepository = contactRepository 10 } 11 12 async execute(): Promise<Contact[]> { 13 const result = await this.contactRepository.getContacts() 14 return result 15 } 16}

We run the test and it passes

1 PASS test/domain/use-cases/contact/get-all-contacts.test.ts 2 Get All Contacts Use Case 3 ✓ should return data (2 ms) 4 5---------------------|---------|----------|---------|---------|------------------- 6File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 7---------------------|---------|----------|---------|---------|------------------- 8All files | 100 | 100 | 100 | 100 | 9 get-all-contacts.ts | 100 | 100 | 100 | 100 | 10---------------------|---------|----------|---------|---------|------------------- 11Test Suites: 1 passed, 1 total 12Tests: 1 passed, 1 total
CreateContact Use Case Test
1//test/domain/use-cases/contact/create-contact.test.ts 2import { Contact } from "../../../../src/domain/entities/contact"; 3import { ContactRepository } from "../../../../src/domain/interfaces/repositories/contact-repository"; 4import { CreateContact } from '../../../../src/domain/use-cases/contact/create-contact' 5 6describe("Create Contact Use Case", () => { 7 class MockContactRepository implements ContactRepository { 8 createContact(contact: Contact): Promise<boolean> { 9 throw new Error("Method not implemented."); 10 } 11 getContacts(): Promise<Contact[]> { 12 throw new Error("Method not implemented."); 13 } 14 } 15 16 let mockContactRepository: ContactRepository; 17 18 beforeEach(() => { 19 jest.clearAllMocks(); 20 mockContactRepository = new MockContactRepository() 21 }) 22 23 test("should return data", async () => { 24 const InputData = { id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" } 25 26 jest.spyOn(mockContactRepository, "createContact").mockImplementation(() => Promise.resolve(true)) 27 const createContactUseCase = new CreateContact(mockContactRepository) 28 const result = await createContactUseCase.execute(InputData); 29 expect(result).toBe(true) 30 31 }); 32 33}) 34

When we run the test it fails

1 FAIL test/domain/use-cases/contact/create-contact.test.ts 2 ● Test suite failed to run 3 4 test/domain/use-cases/contact/create-contact.test.ts:4:31 - error TS2307: Cannot find module '../../../../src/domain/use-cases/contact/create-contact' or its corresponding type declarations. 5 6 4 import { CreateContact } from '../../../../src/domain/use-cases/contact/create-contact'

Let's fix this by creating the file

CreateContact Use Case Code
1// domain/use-cases/contact-create.ts 2import { Contact } from "../../entities/contact"; 3import { ContactRepository } from "../../interfaces/repositories/contact-repository"; 4import { CreateContactUseCase } from "../../interfaces/use-cases/create-contact-use-case"; 5 6 7export class CreateContact implements CreateContactUseCase { 8 contactRepository: ContactRepository 9 constructor(contactRepository: ContactRepository) { 10 this.contactRepository = contactRepository 11 } 12 13 async execute(contact: Contact): Promise<boolean> { 14 const result = await this.contactRepository.createContact(contact) 15 return result 16 } 17}

Our test passes

1 PASS test/domain/use-cases/contact/get-all-contacts.test.ts 2 PASS test/domain/use-cases/contact/create-contact.test.ts 3---------------------|---------|----------|---------|---------|------------------- 4File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 5---------------------|---------|----------|---------|---------|------------------- 6All files | 100 | 100 | 100 | 100 | 7 create-contact.ts | 100 | 100 | 100 | 100 | 8 get-all-contacts.ts | 100 | 100 | 100 | 100 | 9---------------------|---------|----------|---------|---------|-------------------

You get the idea. From here on out, I'll list the test and the passing code

Repository Test

1//test/domain/repositories/contact-repository.test.ts 2import { Contact } from "../../../src/domain/entities/contact"; 3import { ContactDataSource } from '../../../src/data/data-sources/contact-data-source' 4import { ContactRepository } from "../../../src/domain/interfaces/repositories/contact-repository"; 5import { ContactRepositoryImpl } from "../../../src/domain/repositories/contact-repository"; 6 7class MockContactDataSource implements ContactDataSource { 8 create(contact: Contact): Promise<boolean> { 9 throw new Error("Method not implemented."); 10 } 11 getAll(): Promise<Contact[]> { 12 throw new Error("Method not implemented."); 13 } 14} 15 16describe("Contact Repository", () => { 17 let mockContactDataSource: ContactDataSource; 18 19 beforeEach(() => { 20 jest.clearAllMocks(); 21 mockContactDataSource = new MockContactDataSource() 22 }) 23 24 describe("getAllContacts", () => { 25 26 test("should return data", async () => { 27 const ExpectedData = [{ id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" }] 28 jest.spyOn(mockContactDataSource, "getAll").mockImplementation(() => Promise.resolve(ExpectedData)) 29 const contactRepository: ContactRepository = new ContactRepositoryImpl(mockContactDataSource) 30 const result = await contactRepository.getContacts(); 31 expect(result).toBe(ExpectedData) 32 }); 33 }) 34 35 36 describe("createContact", () => { 37 38 test("should return true", async () => { 39 const InputData = { id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" } 40 jest.spyOn(mockContactDataSource, "create").mockImplementation(() => Promise.resolve(true)) 41 const contactRepository: ContactRepository = new ContactRepositoryImpl(mockContactDataSource) 42 const result = await contactRepository.createContact(InputData); 43 expect(result).toBe(true) 44 }); 45 }) 46}) 47
Repository Code
1// domain/repositories/contact-repository.ts 2import { ContactDataSource } from "../../data/data-sources/contact-data-source"; 3import { Contact } from "../entities/contact"; 4import { ContactRepository } from "../interfaces/repositories/contact-repository"; 5 6export class ContactRepositoryImpl implements ContactRepository { 7 contactDataSource: ContactDataSource 8 constructor(contactDataSource: ContactDataSource) { 9 this.contactDataSource = contactDataSource 10 } 11 12 async createContact(contact: Contact): Promise<boolean> { 13 const result = await this.contactDataSource.create(contact) 14 return result; 15 } 16 async getContacts(): Promise<Contact[]> { 17 const result = await this.contactDataSource.getAll() 18 return result; 19 } 20} 21

Now it passes

1 PASS test/domain/use-cases/contact/create-contact.test.ts 2 PASS test/domain/use-cases/contact/get-all-contacts.test.ts 3 PASS test/domain/repositories/contact-repository.test.ts 4------------------------|---------|----------|---------|---------|------------------- 5File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 6------------------------|---------|----------|---------|---------|------------------- 7All files | 100 | 100 | 100 | 100 | 8 repositories | 100 | 100 | 100 | 100 | 9 contact-repository.ts | 100 | 100 | 100 | 100 | 10 use-cases/contact | 100 | 100 | 100 | 100 | 11 create-contact.ts | 100 | 100 | 100 | 100 | 12 get-all-contacts.ts | 100 | 100 | 100 | 100 | 13------------------------|---------|----------|---------|---------|-------------------

Router

Up to this point we haven't decided on an API framework or Database. This structure allows your business logic to be independent of infrastructure.

We have however reached the point where we need to make a decision on API framework.

Let's use Express.

1$ npm i express 2$ npm i -D @types/express supertest @test/supertest

Here we're adding the Express framework and Supertest, API assertion tool, along with type definitions

Let's create the API server.

1//src/server.ts 2import express from 'express'; 3const server = express(); 4server.use(express.json()); 5export default server

Router Test

1import request from "supertest"; 2import { Contact } from "../../../src/domain/entities/contact"; 3import { CreateContactUseCase } from "../../../src/domain/interfaces/use-cases/create-contact-use-case"; 4import { GetAllContactsUseCase } from "../../../src/domain/interfaces/use-cases/get-all-contacts-use-case"; 5import ContactRouter from '../../../src/presentation/routers/contact-router' 6import server from '../../../src/server' 7 8class MockGetAllContactsUseCase implements GetAllContactsUseCase { 9 execute(): Promise<Contact[]> { 10 throw new Error("Method not implemented.") 11 } 12} 13 14class MockCreateContactUseCase implements CreateContactUseCase { 15 execute(contact: Contact): Promise<boolean> { 16 throw new Error("Method not implemented.") 17 } 18} 19 20describe("Contact Router", () => { 21 let mockCreateContactUseCase: CreateContactUseCase; 22 let mockGetAllContactsUseCase: GetAllContactsUseCase; 23 24 beforeAll(() => { 25 mockGetAllContactsUseCase = new MockGetAllContactsUseCase() 26 mockCreateContactUseCase = new MockCreateContactUseCase() 27 server.use("/contact", ContactRouter(mockGetAllContactsUseCase, mockCreateContactUseCase)) 28 }) 29 30 beforeEach(() => { 31 jest.clearAllMocks(); 32 }) 33 34 describe("GET /contact", () => { 35 36 test("should return 200 with data", async () => { 37 const ExpectedData = [{ id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" }]; 38 jest.spyOn(mockGetAllContactsUseCase, "execute").mockImplementation(() => Promise.resolve(ExpectedData)) 39 40 const response = await request(server).get("/contact") 41 42 expect(response.status).toBe(200) 43 expect(mockGetAllContactsUseCase.execute).toBeCalledTimes(1) 44 expect(response.body).toStrictEqual(ExpectedData) 45 }); 46 }) 47 48 describe("POST /contact", () => { 49 50 test("POST /contact", async () => { 51 const InputData = { id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" } 52 jest.spyOn(mockCreateContactUseCase, "execute").mockImplementation(() => Promise.resolve(true)) 53 const response = await request(server).post("/contact").send(InputData) 54 expect(response.status).toBe(201) 55 }); 56 }) 57 58})

Because we're testing the router we need to mock dependencies. Before all (beforeAll) the tests in this suite is run, we inject the mocks into the Contact Router and assign the Contact Router as the "/contact" middleware.

In the test, we mock the result of the "execute" function to return some expected data. Jest mock functions are also known as "spies", because they let you spy on the behavior of a function rather than only testing the output.

Repository Code
1import express from 'express' 2import { Request, Response } from 'express' 3import { CreateContactUseCase } from '../../domain/interfaces/use-cases/create-contact-use-case' 4import { GetAllContactsUseCase } from '../../domain/interfaces/use-cases/get-all-contacts-use-case' 5 6 7export default function ContactsRouter( 8 getAllContactsUseCase: GetAllContactsUseCase, 9 createContactUseCase: CreateContactUseCase 10) { 11 const router = express.Router() 12 13 router.get('/', async (req: Request, res: Response) => { 14 try { 15 const contacts = await getAllContactsUseCase.execute() 16 res.send(contacts) 17 } catch (err) { 18 res.status(500).send({ message: "Error fetching data" }) 19 } 20 }) 21 22 router.post('/', async (req: Request, res: Response) => { 23 try { 24 await createContactUseCase.execute(req.body) 25 res.statusCode = 201 26 res.json({ message: "Created" }) 27 } catch (err) { 28 res.status(500).send({ message: "Error saving data" }) 29 } 30 }) 31 32 return router 33}

Our test passes but we haven't covered 100% or the contact router

1 PASS test/domain/repositories/contact-repository.test.ts 2 PASS test/domain/use-cases/contact/create-contact.test.ts 3 PASS test/domain/use-cases/contact/get-all-contacts.test.ts 4 PASS test/presentation/routers/contact-router.test.ts 5------------------------------|---------|----------|---------|---------|------------------- 6File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 7------------------------------|---------|----------|---------|---------|------------------- 8All files | 94.28 | 100 | 100 | 93.93 | 9 src | 100 | 100 | 100 | 100 | 10 server.ts | 100 | 100 | 100 | 100 | 11 src/domain/repositories | 100 | 100 | 100 | 100 | 12 contact-repository.ts | 100 | 100 | 100 | 100 | 13 src/domain/use-cases/contact | 100 | 100 | 100 | 100 | 14 create-contact.ts | 100 | 100 | 100 | 100 | 15 get-all-contacts.ts | 100 | 100 | 100 | 100 | 16 src/presentation/routers | 88.23 | 100 | 100 | 86.66 | 17 contact-router.ts | 88.23 | 100 | 100 | 86.66 | 18,28 18------------------------------|---------|----------|---------|---------|-------------------

Let's add a failing test:

1import request from "supertest"; 2import { Contact } from "../../../src/domain/entities/contact"; 3import { CreateContactUseCase } from "../../../src/domain/interfaces/use-cases/create-contact-use-case"; 4import { GetAllContactsUseCase } from "../../../src/domain/interfaces/use-cases/get-all-contacts-use-case"; 5import ContactRouter from '../../../src/presentation/routers/contact-router' 6import server from '../../../src/server' 7 8class MockGetAllContactsUseCase implements GetAllContactsUseCase { 9 execute(): Promise<Contact[]> { 10 throw new Error("Method not implemented.") 11 } 12} 13 14class MockCreateContactUseCase implements CreateContactUseCase { 15 execute(contact: Contact): Promise<boolean> { 16 throw new Error("Method not implemented.") 17 } 18} 19 20describe("Contact Router", () => { 21 let mockCreateContactUseCase: CreateContactUseCase; 22 let mockGetAllContactsUseCase: GetAllContactsUseCase; 23 24 beforeAll(() => { 25 mockGetAllContactsUseCase = new MockGetAllContactsUseCase() 26 mockCreateContactUseCase = new MockCreateContactUseCase() 27 server.use("/contact", ContactRouter(mockGetAllContactsUseCase, mockCreateContactUseCase)) 28 }) 29 30 beforeEach(() => { 31 jest.clearAllMocks(); 32 }) 33 34 describe("GET /contact", () => { 35 36 test("should return 200 with data", async () => { 37 const ExpectedData = [{ id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" }]; 38 jest.spyOn(mockGetAllContactsUseCase, "execute").mockImplementation(() => Promise.resolve(ExpectedData)) 39 40 const response = await request(server).get("/contact") 41 42 expect(response.status).toBe(200) 43 expect(mockGetAllContactsUseCase.execute).toBeCalledTimes(1) 44 expect(response.body).toStrictEqual(ExpectedData) 45 46 }); 47 48 test("GET /contact returns 500 on use case error", async () => { 49 jest.spyOn(mockGetAllContactsUseCase, "execute").mockImplementation(() => Promise.reject(Error())) 50 const response = await request(server).get("/contact") 51 expect(response.status).toBe(500) 52 expect(response.body).toStrictEqual({ message: "Error fetching data" }) 53 }); 54 }) 55 56 describe("POST /contact", () => { 57 58 test("POST /contact", async () => { 59 const InputData = { id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" } 60 jest.spyOn(mockCreateContactUseCase, "execute").mockImplementation(() => Promise.resolve(true)) 61 const response = await request(server).post("/contact").send(InputData) 62 expect(response.status).toBe(201) 63 }); 64 65 test("POST /contact returns 500 on use case error", async () => { 66 const InputData = { id: "1", surname: "Smith", firstName: "John", email: "john@gmail.com" } 67 jest.spyOn(mockCreateContactUseCase, "execute").mockImplementation(() => Promise.reject(Error())) 68 const response = await request(server).post("/contact").send(InputData) 69 expect(response.status).toBe(500) 70 }); 71 }) 72 73})

we now have 100% code coverage

1 PASS test/domain/use-cases/contact/get-all-contacts.test.ts 2 PASS test/domain/use-cases/contact/create-contact.test.ts 3 PASS test/domain/repositories/contact-repository.test.ts 4 PASS test/presentation/routers/contact-router.test.ts 5------------------------------|---------|----------|---------|---------|------------------- 6File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 7------------------------------|---------|----------|---------|---------|------------------- 8All files | 100 | 100 | 100 | 100 | 9 src | 100 | 100 | 100 | 100 | 10 server.ts | 100 | 100 | 100 | 100 | 11 src/domain/repositories | 100 | 100 | 100 | 100 | 12 contact-repository.ts | 100 | 100 | 100 | 100 | 13 src/domain/use-cases/contact | 100 | 100 | 100 | 100 | 14 create-contact.ts | 100 | 100 | 100 | 100 | 15 get-all-contacts.ts | 100 | 100 | 100 | 100 | 16 src/presentation/routers | 100 | 100 | 100 | 100 | 17 contact-router.ts | 100 | 100 | 100 | 100 | 18------------------------------|---------|----------|---------|---------|-------------------

And finally let's create the contact data source. Let's use mongodb for data persistence

1$ npm i mongodb 2$ npm i -D @types/mongodb
Data source Test
1//test/data/data-source/mongodb/mongodb-contact-data-source.test.ts 2import { Database } from '../../../../src/data/interfaces/data-sources/database'; 3import { MongoDBContactDataSource } from '../../../../src/data/data-sources/mongodb/mongodb-contact-data-source' 4 5describe("MongoDB DataSource", () => { 6 7 let mockDatabase: Database 8 9 beforeAll(async () => { 10 mockDatabase = { 11 find: jest.fn(), 12 insertOne: jest.fn() 13 } 14 }) 15 16 beforeEach(() => { 17 jest.clearAllMocks(); 18 }) 19 20 test("getAll", async () => { 21 const ds = new MongoDBContactDataSource(mockDatabase); 22 jest.spyOn(mockDatabase, "find").mockImplementation(() => Promise.resolve([{ surname: "Smith", _id: "123", firstName: "John", email: "john@gmail.com" }])) 23 const result = await ds.getAll(); 24 expect(mockDatabase.find).toHaveBeenCalledWith({}) 25 expect(result).toStrictEqual([{ surname: "Smith", id: "123", firstName: "John", email: "john@gmail.com" }]) 26 }) 27 28 29 test("create", async () => { 30 const ds = new MongoDBContactDataSource(mockDatabase); 31 const inputData = { surname: "Smith", email: "john@gmail.com", firstName: "John" } 32 jest.spyOn(mockDatabase, "insertOne").mockImplementation(() => Promise.resolve({ insertedId: "123" })) 33 const result = await ds.create(inputData); 34 expect(mockDatabase.insertOne).toHaveBeenCalledWith(inputData) 35 expect(result).toStrictEqual(true) 36 }) 37 38}) 39
DataBase interface

We create an interface for a database wrapper so that we can mock it in the test

1//data/interfaces/data-source/database.ts 2export interface Database { 3 find(query: object): Promise<any[]> 4 insertOne(doc: any): Promise<any> 5}
MongoDBContactDataSource Code
1import { ContactDataSource } from "../../interfaces/data-sources/contact-data-source"; 2import { Database } from "../../interfaces/data-sources/database"; 3import { Contact } from "./models/contact"; 4 5export class MongoDBContactDataSource implements ContactDataSource { 6 7 private database: Database 8 constructor(database: Database) { 9 this.database = database 10 } 11 async create(contact: Contact): Promise<boolean> { 12 const result = await this.database.insertOne(contact) 13 return result !== null 14 } 15 16 async getAll(): Promise<Contact[]> { 17 const result = await this.database.find({}) 18 return result.map(item => ({ 19 id: item._id.toString(), 20 surname: item.surname, 21 firstName: item.firstName, 22 email: item.email 23 })); 24 } 25 26}

We now have all our tests passing and 100% code coverage

1 PASS test/domain/use-cases/contact/create-contact.test.ts 2 PASS test/domain/repositories/contact-repository.test.ts 3 PASS test/domain/use-cases/contact/get-all-contacts.test.ts 4 PASS test/presentation/routers/contact-router.test.ts 5 PASS test/data/data-sources/mongodb/mongodb-contact-data-source.test.ts 6---------------------------------|---------|----------|---------|---------|------------------- 7File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 8---------------------------------|---------|----------|---------|---------|------------------- 9All files | 100 | 100 | 100 | 100 | 10 src | 100 | 100 | 100 | 100 | 11 server.ts | 100 | 100 | 100 | 100 | 12 src/data/data-sources/mongodb | 100 | 100 | 100 | 100 | 13 mongodb-contact-data-source.ts | 100 | 100 | 100 | 100 | 14 src/domain/repositories | 100 | 100 | 100 | 100 | 15 contact-repository.ts | 100 | 100 | 100 | 100 | 16 src/domain/use-cases/contact | 100 | 100 | 100 | 100 | 17 create-contact.ts | 100 | 100 | 100 | 100 | 18 get-all-contacts.ts | 100 | 100 | 100 | 100 | 19 src/presentation/routers | 100 | 100 | 100 | 100 | 20 contact-router.ts | 100 | 100 | 100 | 100 | 21---------------------------------|---------|----------|---------|---------|-------------------

With all tests passing let's put it all together and lastly write our main server application

1//src/main.ts 2import server from './server' 3import ContactRouter from './presentation/routers/contact-router' 4import { GetAllContacts } from './domain/use-cases/contact/get-all-contacts' 5import { ContactRepositoryImpl } from './domain/repositories/contact-repository' 6import { CreateContact } from './domain/use-cases/contact/create-contact' 7import { MongoClient } from 'mongodb' 8import { Database } from './data/interfaces/data-sources/database' 9import { MongoDBContactDataSource } from './data/data-sources/mongodb/mongodb-contact-data-source' 10 11 12(async () => { 13 const client: MongoClient = new MongoClient("mongodb://localhost:27017/contacts") 14 await client.connect() 15 const db = client.db("CONTACTS_DB"); 16 17 const contactDatabase: Database = { 18 find: (query) => db.collection("contacts").find(query).toArray(), 19 insertOne: (doc) => db.collection("contacts").insertOne(doc) 20 } 21 22 const contactMiddleWare = ContactRouter( 23 new GetAllContacts(new ContactRepositoryImpl(new MongoDBContactDataSource(contactDatabase))), 24 new CreateContact(new ContactRepositoryImpl(new MongoDBContactDataSource(contactDatabase))), 25 ) 26 27 server.use("/contact", contactMiddleWare) 28 server.listen(4000, () => console.log("Running on server")) 29 30})()

Here we

  1. create a Mongodb contact database.
  2. Inject all dependencies into the contact router
  3. Bind the contact router to the “/contact” path
  4. Listen on port 4000.

To compile and run the application we need to add the following scripts to our package.json file

Complile and run

To compile the application to js we need to add the following scripts to our package.json file

1 ... 2 "scripts": { 3 "test": "jest --watchAll --collectCoverage", 4 "dev:build": "tsc", 5 "dev:serve": "nodemon -e js -w lib lib/main.js" 6 }, 7 ...

When we run "npm run dev:build" we will compile the ts to js in the lib folder and then running "npm run dev:serve" will run the js code from lib

I’ve gone further to create the PGContactDataSource and updated the main.ts to illustrate how we can interchange data sources

1import server from './server' 2import ContactRouter from './presentation/routers/contact-router' 3import { GetAllContacts } from './domain/use-cases/contact/get-all-contacts' 4import { ContactRepositoryImpl } from './domain/repositories/contact-repository' 5import { CreateContact } from './domain/use-cases/contact/create-contact' 6import { MongoClient } from 'mongodb' 7import { NoSQLDatabaseWrapper } from './data/interfaces/data-sources/nosql-database-wrapper' 8import { MongoDBContactDataSource } from './data/data-sources/mongodb/mongodb-contact-data-source' 9import { PGContactDataSource } from './data/data-sources/postgresql/pg-contact-data-source' 10import { Pool } from 'pg' 11 12async function getMongoDS() { 13 const client: MongoClient = new MongoClient("mongodb://localhost:27017/contacts") 14 await client.connect() 15 const db = client.db("CONTACTS_DB"); 16 17 const contactDatabase: NoSQLDatabaseWrapper = { 18 find: (query) => db.collection("contacts").find(query).toArray(), 19 insertOne: (doc) => db.collection("contacts").insertOne(doc), 20 deleteOne: (id: String) => db.collection("contacts").deleteOne({ _id: id }), 21 updateOne: (id: String, data: object) => db.collection("contacts").updateOne({ _id: id }, data) 22 } 23 24 return new MongoDBContactDataSource(contactDatabase); 25} 26 27async function getPGDS() { 28 29 const db = new Pool({ 30 user: 'postgres', 31 host: 'localhost', 32 database: 'CONTACTSDB', 33 password: '', 34 port: 5432, 35 }) 36 return new PGContactDataSource(db) 37} 38 39 40(async () => { 41 const dataSource = await getPGDS(); 42 43 const contactMiddleWare = ContactRouter( 44 new GetAllContacts(new ContactRepositoryImpl(dataSource)), 45 new CreateContact(new ContactRepositoryImpl(dataSource)), 46 ) 47 48 server.use("/contact", contactMiddleWare) 49 server.listen(4000, () => console.log("Running on http://localhost:4000")) 50 51})()

Conclusion

Creating an application in a cleanly structured way allows us to progressively test and develop parts of the application by creating architectural boundaries with interfaces. Not only can we create pluggable parts but those parts to be mocked during testing.

Check out repo: https://github.com/nanosoftonline/clean-architecture-express-contacts