Simple NodeJS Project with TypeScript and Kubernetes / Part 2

Sina Ahamadpour
8 min readApr 18, 2023

--

In the previous part, we did a simple setup for developing applications on NodeJS with TypeScript. You can read that here.

In this part, we will add the logic for our application.

The application we are going to build is going to be a simple app with 5 different API endpoints, 1 for a health check and another 4 for managing notes. Basically, we are going to expose HTTP Endpoints to practice CRUD actions.

We are going to use express.js to create a simple HTTP Server in NodeJS. (We will test by executing the code on the fly but we will implement the server in the next article, just to show you a proof of concept for Clean architecture)

In this article we will go to create 2 different repositories for our notes:

  • In memory — Not persistent
  • In MongoDB — persistent

In order to make our code testable and cleaner to maintain we are going to struct our application in a Clean-Architecture manner. If you want to read more practical architecture I suggest reading this amazing article by Tiago Albuquerque

Clean Architecture is a software design approach introduced by Robert C. Martin, also known as “Uncle Bob.” It is a set of principles and guidelines that help developers to create software systems that are modular, maintainable, testable, and independent of specific frameworks, libraries, or UI technologies.

Basically, we are going to separate logic and infrastructure code so we can port our application anywhere easier without changing the logic in the domain. (At least that is what I understood from it)

Directory Structure

# Structure for inner layer (Core)
mkdir -p src/core src/core/entity src/core/repository src/core/usecase

# Structure for outer layer (infra)
mkdir -p src/infra src/infra/controllers src/infra/repository src/infra/routes

Now let’s create our first entity as below

A note has four attributes:

  • id (An identifier)
  • title (Title of the note)
  • description (Description of the note)
  • archive (whether it’s archived or not)
// src/core/entity/Note.ts
export default class Note {
id: string
title: string
description: string
archive: boolean

constructor (id: string, title: string, description: string) {
this.id = id
this.title = title
this.description = description
this.archive = false
}
}

Now it’s time to add the repository. This will act like a contract between core and infra and allow us to easily add new implementation or swap implementation in the infra layer.

// src/core/repository/NoteRepository.ts

import type Note from '../entity/Note'

export default interface NoteRepository {
findAll: () => Promise<Note[]>
find: (id: string) => Promise<Note | undefined>
create: (title: string, description: string) => Promise<Note>
update: (id: string, changes: Partial<Note>) => Promise<Note | undefined>
delete: (id: string) => Promise<void>
}

Now the last step in our inner layer is to add use-cases, use-cases are the business logic within our domain and the way entities interact with each other.

I’ll create 5 different use-cases:

// src/core/usecase/CreateNoteUsecase.ts

import type Note from '../entity/Note'
import type NoteRepository from '../repository/NoteRepository'

export default class CreateNoteUsecase {
constructor (private readonly noteRepository: NoteRepository) {}

async execute (title: string, description: string): Promise<Note> {
return await this.noteRepository.create(title, description)
}
}
// src/core/usecase/DeleteNoteUsecase.ts

import type NoteRepository from '../repository/NoteRepository'

export default class DeleteNoteUsecase {
constructor (private readonly noteRepository: NoteRepository) {}

async execute (id: string): Promise<void> {
await this.noteRepository.delete(id)
}
}
// src/core/usecase/GetNoteUsecase.ts

import type Note from '../entity/Note'
import type NoteRepository from '../repository/NoteRepository'

export default class GetNoteUsecase {
constructor (private readonly noteRepository: NoteRepository) {}

async execute (id: string): Promise<Note | undefined> {
return await this.noteRepository.find(id)
}
}
// src/core/usecase/ListNotesUsecase.ts

import type Note from '../entity/Note'
import type NoteRepository from '../repository/NoteRepository'

export default class ListNotesUsecase {
constructor (private readonly noteRepository: NoteRepository) {}

async execute (): Promise<Note[]> {
return await this.noteRepository.findAll()
}
}
// src/core/usecase/UpdateNoteUsecase.ts

import type Note from '../entity/Note'
import type NoteRepository from '../repository/NoteRepository'

export default class UpdateNoteUsecase {
constructor (private readonly noteRepository: NoteRepository) {}

async execute (id: string, title: string, description: string, archive: boolean): Promise<Note | undefined> {
return await this.noteRepository.update(id, { title, description, archive } satisfies Partial<Note>)
}
}

So these are the acts that most of the time are needed in our applications. whether using REST API or CLI we need to do actions like creating a note or deleting a note.

Let’s create an implementation of saving notes in this app. in memory (Just for now)

// src/infra/repository/NoteRepositoryInMemory.ts

import Note from '../../core/entity/Note'
import type NoteRepository from '../../core/repository/NoteRepository'
import { v4 as uuidv4 } from 'uuid'

export default class NoteRepositoryInMemory implements NoteRepository {
private notes: Note[] = [
new Note(uuidv4(), 'Hello World!', 'This is a simple task')
]

async findAll (): Promise<Note[]> {
return await Promise.resolve(this.notes)
}

async find (id: string): Promise<Note | undefined> {
return await Promise.resolve(this.notes.find(n => n.id === id))
}

async create (title: string, description: string): Promise<Note> {
const id = uuidv4()
const note = new Note(id, title, description)

this.notes.push(note)
return await this.find(id) as Note
}

async update (id: string, changes: Partial<Note>): Promise<Note | undefined> {
const note = await this.find(id)

if (note === undefined) {
return
}

if (changes.title !== undefined) {
note.title = changes.title
}

if (changes.description !== undefined) {
note.description = changes.description
}

if (changes.archive !== undefined) {
note.archive = changes.archive
}

for (let i = 0; i < this.notes.length; i++) {
if (this.notes[i].id === id) {
this.notes[i] = note
}
}

return note
}

async delete (id: string): Promise<void> {
this.notes = this.notes.filter(n => n.id !== id)
}
}

Also, we need to install these packages:

npm install uuid
npm install --save-dev @types/uuid

Let’s run it simply by adding the code to ./src/app.ts (This is temporary, we will change that later. It is just for proof of concept)

// src/app.ts

import CreateNoteUsecase from '../src/core/usecase/CreateNoteUsecase'
import NoteRepositoryInMemory from '../src/infra/repository/NoteRepositoryInMemory'

void (async () => {
// Step 1 - Create Repository
const repository = new NoteRepositoryInMemory()

// Step 2 - Pass Repository to the Usecase
const createNoteUsecase = new CreateNoteUsecase(repository)

// Step 3 - Execute the Usecase
const note = await createNoteUsecase.execute('Sina', 'Test Description')

console.log(`Inserted ID for the new note: ${note.id}`)
})()

As you can see the inserted ID is shown above.

Nice! Now we can add another implementation. Let’s add another one but this time in MongoDB.

Create a new file and add the below content:

// src/infra/repository/NoteRepositoryMongoDB.ts

import type Note from '../../core/entity/Note'
import type NoteRepository from '../../core/repository/NoteRepository'
import { MongoClient, ObjectId } from 'mongodb'

enum collection {
notes = 'notes'
}

export default class NoteRepositoryMongoDB implements NoteRepository {
private readonly client
private readonly database

constructor (url: string, database: string) {
this.client = new MongoClient(url)
this.database = database
}

async findAll (): Promise<Note[]> {
await this.client.connect()
const db = this.client.db(this.database)
const result = await db.collection(collection.notes).find({}).toArray()

const notes: Note[] = []
for (const item of result) {
notes.push({
id: item?._id.toString(),
title: item?.title as string,
description: item?.description as string,
archive: item?.archive as boolean
})
}

return notes
}

async find (id: string): Promise<Note | undefined> {
await this.client.connect()
const db = this.client.db(this.database)

const query = { _id: new ObjectId(id) }
const result = await db.collection(collection.notes).findOne(query)

return {
id: result?._id.toString() as string,
title: result?.title as string,
description: result?.description as string,
archive: result?.archive as boolean
}
}

async create (title: string, description: string): Promise<Note> {
await this.client.connect()
const db = this.client.db(this.database)

const insertResult = await db.collection(collection.notes).insertOne({
title,
description,
archive: false
})

return await this.find(insertResult.insertedId.toString()) as Note
}

async update (id: string, changes: Partial<Note>): Promise<Note | undefined> {
const note = await this.find(id)

if (note === undefined) {
return
}

if (changes.title !== undefined) {
note.title = changes.title
}

if (changes.description !== undefined) {
note.description = changes.description
}

if (changes.archive !== undefined) {
note.archive = changes.archive
}

await this.client.connect()
const db = this.client.db(this.database)

const query = { _id: new ObjectId(id) }
await db.collection(collection.notes).updateOne(query, { $set: note })

return await this.find(id)
}

async delete (id: string): Promise<void> {
await this.client.connect()
const db = this.client.db(this.database)

const query = { _id: new ObjectId(id) }
await db.collection(collection.notes).deleteOne(query)
}
}

Install MongoDB as well by running the below command:

npm install mongodb 

Now change the ./src/app.ts to below:

import CreateNoteUsecase from '../src/core/usecase/CreateNoteUsecase'
import NoteRepositoryMongoDB from '../src/infra/repository/NoteRepositoryMongoDB'

void (async () => {
// Step 1 - Create Repository
const repository = new NoteRepositoryMongoDB('mongodb://localhost:27017/', 'noteapp')

// Step 2 - Pass Repository to the Usecase
const createNoteUsecase = new CreateNoteUsecase(repository)

// Step 3 - Execute the Usecase
const note = await createNoteUsecase.execute('Sina', 'Test Description')

console.log(`Inserted ID for the new note: ${note.id}`)
})()

As you can see the inserted ID is shown above and let’s see if the note has been added to the MongoDB or not….

This is really delightful, everything is working but let’s clean it up a little bit more.

Clean up?

There is a set of concepts named Twelve-factor apps. Please take a look at it. These are introduced by Adam Wiggins the founder of Heroku. As a software engineer, it’s really important to know about the essential features of scalable apps.

One of its concepts is Configs, which simply means for scaling your app easily you have to set the configs in the environment. Let’s do that now!

I’ll use dotenv package. Let’s install that:

npm install dotenv

Create the env file as below:

# .env

APP_STORAGE=mongodb
MONGODB_URL=mongodb://localhost:27017/
MONGODB_DBNAME=noteapp

And change the ./src/app.ts as below:

import * as dotenv from 'dotenv'

import CreateNoteUsecase from '../src/core/usecase/CreateNoteUsecase'
import NoteRepositoryInMemory from '../src/infra/repository/NoteRepositoryInMemory'
import NoteRepositoryMongoDB from '../src/infra/repository/NoteRepositoryMongoDB'
import type NoteRepository from './core/repository/NoteRepository'

dotenv.config()

void (async () => {
// Step 1 - Get the preferred driver from .env file or fallback to memory
const storage = process.env?.APP_STORAGE ?? 'memory'

// Step 2 - Create repository based on the preferred driver from step 1
const repository: NoteRepository = storage === 'memory'
? new NoteRepositoryInMemory()
: new NoteRepositoryMongoDB(process.env?.MONGODB_URL as string, process.env?.MONGODB_DBNAME as string)

// Step 3 - Pass Repository to the Usecase
const createNoteUsecase = new CreateNoteUsecase(repository)

// Step 4- Execute the Usecase
const note = await createNoteUsecase.execute('New Note', 'New Description')

console.log(`Inserted ID for the new note: ${note.id}`)
})()

Let’s check again:

Seems everything is fine.

Congratulation! You came this far and made me happy by reading this article. It motivates me so much for the next part. I’ll promise to write ASAP.

Kind regards

--

--