4 Layers Architecture
At this point, your code is running as expected. But, if you take a closer look, it's not "clean" enough.
Not "clean"
Here are some of the not-clean points of the current code:
// Database connection
let db: sqlite3.Database;
- It's a bad idea to explicitly use a global variable to hold the database instance and use it all around.
function initDB() {
db = new sqlite3.Database("./test.db", (err) => {
if (err) {
console.error("Error opening database:", err.message);
} else {
console.log("Connected to the database.");
db.exec(
`CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
published_at TEXT NOT NULL,
description TEXT NOT NULL,
isbn TEXT NOT NULL,
total_pages INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
(err) => {
if (err) {
console.error("Error opening database:", err.message);
} else {
console.log("Successfully initialized tables.");
}
}
);
}
});
}
- It's not a good practice to initialize a database in the business code. Database stuff is "infrastructrue", but not "business". They're different concerns, and you should put them in different places.
// GET a single book by ID
app.get("/books/:id", (req: Request, res: Response) => {
const id = parseInt(req.params.id);
db.get("SELECT * FROM books WHERE id = ?", [id], (err, row) => {
if (err) {
console.error("Error getting book:", err.message);
res.status(500).json({ error: "Internal Server Error" });
} else if (row) {
res.json(row);
} else {
res.status(404).json({ message: "Book not found" });
}
});
});
- It's not recommended to directly rely on third party libraries in your business code.
db.get
is a method fromsqlite3
package, which is a third party library. The cost would be huge if you want to switch to a new database framework some day in the future.
Separation of Concerns
From https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
The Clean Architecture is a software architectural pattern introduced by Robert C. Martin, in his book "Clean Architecture: A Craftsman's Guide to Software Structure and Design." It emphasizes the separation of concerns within a software system, with the primary goal of making the system more maintainable, scalable, and testable over its lifecycle.
4 Layers Architecture varies somewhat in the details, but it's similar to the Clean Architecture. They all have the same objective, which is the separation of concerns. They all achieve this separation by dividing the software into layers.
-
Adapter Layer: Responsible for routing and adaptation of frameworks, protocols, and front-end displays (web, mobile, and etc).
-
Application Layer: Primarily responsible for obtaining input, assembling context, parameter validation, invoking domain layer for business processing. The layer is open-ended, it's okay for the application layer to bypass the domain layer and directly access the infrastructure layer.
-
Domain Layer: Encapsulates core business logic and provides business entities and business logic operations to the Application layer through domain services and domain entities' methods. The domain is the core of the application and does not depend on any other layers.
-
Infrastructure Layer: Primarily handles technical details such as CRUD operations on databases, search engines, file systems, RPC for distributed services, etc. External dependencies need to be translated through gateways here before they can be used by the Application and Domain layers above.
Refactoring
Let's do the code refactoring with 4 layers architecture.
The new folder structure looks like this:
projects/lr_rest_books_node
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── src
│ ├── adapter
│ │ ├── index.ts
│ │ └── router.ts
│ ├── application
│ │ ├── executor
│ │ │ ├── book_operator.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ └── wire_helper.ts
│ ├── domain
│ │ ├── gateway
│ │ │ ├── book_manager.ts
│ │ │ └── index.ts
│ │ └── model
│ │ ├── book.ts
│ │ └── index.ts
│ ├── infrastructure
│ │ ├── config
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ └── database
│ │ ├── index.ts
│ │ └── sqlite.ts
│ └── main.ts
├── test.db
└── tsconfig.json
Move model/book.ts into domain/model/book.ts:
// Book represents the structure of a book
export interface Book {
id: number;
title: string;
author: string;
published_at: string;
description: string;
isbn: string;
total_pages: number;
created_at: Date;
updated_at: Date;
}
domain
folder is where all your core business rules reside. Non-business code stay away from this folder.
Create domain/model/index.ts:
export { Book } from "./book";
This line makes your import
easier. You can do import { Book } from "@/domain/model"
instead of import { Book } from "@/domain/model/book"
.
You many add an index.ts into all your sub-folders under src/.
@/...
is path alias that saves you from relative paths like ../../
or even ../../../
.
Modify tsconfig.json to achieve this:
@@ -4,7 +4,11 @@
"module": "CommonJS",
"outDir": "./dist",
"strict": true,
- "esModuleInterop": true
+ "esModuleInterop": true,
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"]
+ }
},
"include": ["src/**/*.ts"],
To make it work with
tsc
, you need to installtsc-alias
:npm i tsc-alias
Read more here: https://stackoverflow.com/a/67227416
Update package.json scripts:
@@ -2,11 +2,11 @@
"name": "lr_rest_books_node",
"version": "1.0.0",
"description": "RESTful API implemented with Express in Node.js.",
- "main": "app.js",
+ "main": "main.js",
"scripts": {
- "dev": "ts-node src/app.ts",
- "build": "tsc",
- "serve": "node dist/app.js"
+ "dev": "npm run build && npm run serve",
+ "build": "tsc && tsc-alias",
+ "serve": "node dist/main.js"
},
Create domain/gateway/book_manager.ts:
import { Book } from "@/domain/model";
export interface BookManager {
createBook(b: Book): Promise<number>;
updateBook(id: number, b: Book): Promise<void>;
deleteBook(id: number): Promise<void>;
getBook(id: number): Promise<Book | null>;
getBooks(): Promise<Book[]>;
}
The BookManager
interface defines the business capabilities of the domain entity Book
.
This is just an interface; the implementations are done in the infrastructure layer.
Additionally, these methods allow the application layer to utilize them for business logic.
Create infrastructrue/database/sqlite.ts:
import sqlite3 from "sqlite3";
import { Book } from "@/domain/model/book";
import { BookManager } from "@/domain/gateway/book_manager";
export class SQLitePersistence implements BookManager {
private db: sqlite3.Database;
constructor(dbFilePath: string) {
this.db = new sqlite3.Database(dbFilePath, (err) => {
if (err) {
console.error("Error opening database:", err.message);
} else {
console.log("Connected to the database.");
this.db.exec(
`CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
published_at TEXT NOT NULL,
description TEXT NOT NULL,
isbn TEXT NOT NULL,
total_pages INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
(err) => {
if (err) {
console.error("Error opening database:", err.message);
} else {
console.log("Successfully initialized tables.");
}
}
);
}
});
}
async createBook(b: Book): Promise<number> {
return new Promise<number>((resolve, reject) => {
const { title, author, published_at, description, isbn, total_pages } = b;
this.db.run(
`INSERT INTO books (title, author, published_at, description, isbn, total_pages) VALUES (?, ?, ?, ?, ?, ?)`,
[title, author, published_at, description, isbn, total_pages],
function (err) {
if (err) {
console.error("Error creating book:", err.message);
reject(err);
} else {
resolve(this.lastID);
}
}
);
});
}
async updateBook(id: number, b: Book): Promise<void> {
return new Promise<void>((resolve, reject) => {
const { title, author, published_at, description, isbn, total_pages } = b;
this.db.run(
`UPDATE books SET title = ?, author = ?, published_at = ?, description = ?, isbn = ?, total_pages = ? WHERE id = ?`,
[title, author, published_at, description, isbn, total_pages, id],
(err) => {
if (err) {
console.error("Error updating book:", err.message);
reject(err);
} else {
resolve();
}
}
);
});
}
async deleteBook(id: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.db.run("DELETE FROM books WHERE id = ?", [id], (err) => {
if (err) {
console.error("Error deleting book:", err.message);
reject(err);
} else {
resolve();
}
});
});
}
async getBook(id: number): Promise<Book | null> {
return new Promise<Book | null>((resolve, reject) => {
this.db.get("SELECT * FROM books WHERE id = ?", [id], (err, row) => {
if (err) {
console.error("Error getting book:", err.message);
reject(err);
} else if (row) {
resolve(row as Book);
} else {
resolve(null);
}
});
});
}
async getBooks(): Promise<Book[]> {
return new Promise<Book[]>((resolve, reject) => {
this.db.all("SELECT * FROM books", (err, rows) => {
if (err) {
console.error("Error getting books:", err.message);
reject(err);
} else {
resolve(rows as Book[]);
}
});
});
}
close(): void {
this.db.close();
}
}
As you see here, class SQLitePersistence
implements all the methods declared in the interface BookManager
.
Create infrastructrue/config/config.ts:
interface DBConfig {
fileName: string;
}
interface ApplicationConfig {
port: number;
}
export interface Config {
app: ApplicationConfig;
db: DBConfig;
}
The Config
interface pulls out some hard-coded items in the code, which is a good practice in general.
Create application/executor/book_operator.ts:
import { BookManager } from "@/domain/gateway";
import { Book } from "@/domain/model";
export class BookOperator {
private bookManager: BookManager;
constructor(b: BookManager) {
this.bookManager = b;
}
async createBook(b: Book): Promise<Book> {
const id = await this.bookManager.createBook(b);
b.id = id;
return b;
}
async getBook(id: number): Promise<Book | null> {
return await this.bookManager.getBook(id);
}
async getBooks(): Promise<Book[]> {
return await this.bookManager.getBooks();
}
async updateBook(id: number, b: Book): Promise<Book> {
await this.bookManager.updateBook(id, b);
return b;
}
async deleteBook(id: number): Promise<void> {
await this.bookManager.deleteBook(id);
}
}
The application layer encapsulates everything and provides interfaces for the topmost adapter layer.
Create application/wire_helper.ts:
import { SQLitePersistence } from "@/infrastructure/database";
import { Config } from "@/infrastructure/config";
import { BookManager } from "@/domain/gateway";
// WireHelper is the helper for dependency injection
export class WireHelper {
private persistence: SQLitePersistence;
constructor(c: Config) {
this.persistence = new SQLitePersistence(c.db.fileName);
}
bookManager(): BookManager {
return this.persistence;
}
}
WireHelper
helps with DI (dependency injection).
If you want advanced DI support, consider InversifyJS.
With all these preparation work, finally the router can do its job "cleanly". It doesn't know which database is used under the hood. Concerns are separated now.
Create adapter/router.ts:
import express, { Request, Response } from "express";
import { Book } from "@/domain/model";
import { BookOperator } from "@/application/executor";
import { WireHelper } from "@/application";
class RestHandler {
private bookOperator: BookOperator;
constructor(bookOperator: BookOperator) {
this.bookOperator = bookOperator;
}
// Get all books
public async getBooks(req: Request, res: Response): Promise<void> {
try {
const books = await this.bookOperator.getBooks();
res.status(200).json(books);
} catch (err) {
console.error(`Failed to get books: ${err}`);
res.status(404).json({ error: "Failed to get books" });
}
}
// Get single book
public async getBook(req: Request, res: Response): Promise<void> {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(404).json({ error: "Invalid id" });
return;
}
try {
const book = await this.bookOperator.getBook(id);
res.status(200).json(book);
} catch (err) {
console.error(`Failed to get the book with ${id}: ${err}`);
res.status(404).json({ error: "Failed to get the book" });
}
}
// Create a new book
public async createBook(req: Request, res: Response): Promise<void> {
try {
const book = await this.bookOperator.createBook(req.body as Book);
res.status(201).json(book);
} catch (err) {
console.error(`Failed to create: ${err}`);
res.status(404).json({ error: "Failed to create" });
}
}
// Update an existing book
public async updateBook(req: Request, res: Response): Promise<void> {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(404).json({ error: "Invalid id" });
return;
}
try {
const book = await this.bookOperator.updateBook(id, req.body as Book);
res.status(200).json(book);
} catch (err) {
console.error(`Failed to update: ${err}`);
res.status(404).json({ error: "Failed to update" });
}
}
// Delete an existing book
public async deleteBook(req: Request, res: Response): Promise<void> {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(404).json({ error: "Invalid id" });
return;
}
try {
await this.bookOperator.deleteBook(id);
res.status(204).end();
} catch (err) {
console.error(`Failed to delete: ${err}`);
res.status(404).json({ error: "Failed to delete" });
}
}
}
// Create router
function MakeRouter(wireHelper: WireHelper): express.Router {
const restHandler = new RestHandler(
new BookOperator(wireHelper.bookManager())
);
const router = express.Router();
router.get("", restHandler.getBooks.bind(restHandler));
router.get("/:id", restHandler.getBook.bind(restHandler));
router.post("", restHandler.createBook.bind(restHandler));
router.put("/:id", restHandler.updateBook.bind(restHandler));
router.delete("/:id", restHandler.deleteBook.bind(restHandler));
return router;
}
export function InitApp(wireHelper: WireHelper): express.Express {
const app = express();
// Middleware to parse JSON bodies
app.use(express.json());
// Define a health endpoint handler
app.get("/", (req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
const r = MakeRouter(wireHelper);
app.use("/books", r);
return app;
}
Move app.ts to main.ts, and fill it with the following content to make it clean:
import { WireHelper } from "@/application";
import { InitApp } from "@/adapter/router";
const port = process.env.PORT || 3000;
const c = {
app: {
port: Number(port),
},
db: {
fileName: "test.db",
},
};
const wireHelper = new WireHelper(c);
const app = InitApp(wireHelper);
app.listen(port, () => {
console.log(`Running on port ${port}`);
});
Refactoring is done. 🎉Great!