DEV Community

Marcos Schead
Marcos Schead

Posted on

Using Clean Architecture and the Unit of Work Pattern on a Node.js Application

Introduction

In the realm of software development, the pursuit of clean architecture is akin to the quest for the Holy Grail. It's the cornerstone of building applications that are not only robust and scalable but also maintainable over time. Central to the concept of clean architecture are design patterns that help structure code in a way that promotes separation of concerns and modularization.

Two such indispensable patterns are the Repository and the Unit of Work. These patterns play a vital role in abstracting data access logic and managing transactions within an application, respectively. By employing these patterns effectively, developers can ensure their code remains organized, flexible, and easy to maintain.

In this tutorial, we'll embark on a journey through the implementation of the Repository and Unit of Work patterns using TypeScript and Node.js. However, instead of interfacing with databases, we'll opt for memory implementations to show that you can test your application layer without relying on external dependencies. Through practical examples and code snippets, we'll unravel the intricacies of these patterns and explore how they contribute to the foundation of clean architecture in modern software development.

So, fasten your seatbelts as we dive into the world of clean architecture and discover the power of the Repository and Unit of Work patterns in crafting elegant and maintainable code.

Setting Up the Project

Before we delve into the implementation of the Repository and Unit of Work patterns, let's ensure our development environment is set up and ready to go. In this section, we'll cover the basic setup of a TypeScript project in a Node.js environment, including the project structure and installation of necessary dependencies.

Project Structure

To maintain a clean and organized codebase, let's structure our project as follows:

project-root/

├── src/
   ├── Account.ts
   ├── Transaction.ts
   ├── IRepository.ts
   ├── IUnitOfWork.ts
   ├── InMemoryRepository.ts
   ├── InMemoryUnitOfWork.ts
   └── TransferMoneyUseCase.ts
   └── TransferMoneyUseCase.test.ts

├── package.json
└── tsconfig.json
└── jest.config.js
Enter fullscreen mode Exit fullscreen mode
  • src/: This directory will contain all our TypeScript source files.
    • Account.ts: Definition of the Account domain model.
    • Transaction.ts: Definition of the Transaction domain model.
    • IRepository.ts: Interface for the Repository pattern.
    • IUnitOfWork.ts: Interface for the Unit of Work pattern.
    • InMemoryRepository.ts: Implementation of the Repository pattern using memory.
    • InMemoryUnitOfWork.ts: Implementation of the Unit of Work pattern using memory.
    • TransferMoneyUseCase.ts: Use case to achieve our domain objective.
    • TransferMoneyUseCase.test.ts: An integration test to ensure the use case works from an application perspective.
  • package.json: Configuration file for npm dependencies and scripts.
  • tsconfig.json: Configuration file for TypeScript compiler options.
  • jest.config.js: Configuration file for Jest.

Installing Dependencies

To get started, ensure you have Node.js and npm (or yarn) installed on your system. Then, initialize a new Node.js project by running the following command in your project directory:

npm init -y
Enter fullscreen mode Exit fullscreen mode

Next, install TypeScript as a development dependency:

npm install typescript --save-dev
Enter fullscreen mode Exit fullscreen mode

Additionally, we'll need @types/node to provide type definitions for Node.js:

npm install @types/node --save-dev
Enter fullscreen mode Exit fullscreen mode

With TypeScript installed, we're ready to configure our project to use it. Create a tsconfig.json file in the root of your project and add the following configuration:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

This configuration tells TypeScript to compile our code to ES6, use CommonJS modules, and output the compiled JavaScript files to the dist directory. It also enables strict type-checking and ES module interop.

Finally, you will need to install jest to run our use case scenario:

 npm i @types/jest jest ts-jest
Enter fullscreen mode Exit fullscreen mode

Create a jest.config.js file in the root of your project and add the following configuration:

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  moduleNameMapper: {
    "src/(.*)": "<rootDir>/src/$1",
  },
};
Enter fullscreen mode Exit fullscreen mode

With our project structure set up and dependencies installed, we're now ready to start implementing the Repository and Unit of Work patterns in TypeScript!

Defining Domain Models

Before we dive into the implementation of the Repository and Unit of Work patterns, let's define the domain models that will form the foundation of our application. In this section, we'll create simple yet essential models for representing accounts and transactions.

Account Model

The Account model represents a user's account in our system. It encapsulates basic information such as the account ID and the current balance.

export class Account {
  constructor(public id: string, public balance: number) {}
}
Enter fullscreen mode Exit fullscreen mode

The Account class consists of two properties:

  • id: A unique identifier for the account.
  • balance: The current balance of the account.

Transaction Model

The Transaction model represents a financial transaction between two accounts. It contains information about the transaction ID, the IDs of the sender and receiver accounts, and the transaction amount.

export class Transaction {
  constructor(public id: string, public fromAccountId: string, public toAccountId: string, public amount: number) {}
}
Enter fullscreen mode Exit fullscreen mode

The Transaction class includes the following properties:

  • id: A unique identifier for the transaction.
  • fromAccountId: The ID of the account from which the money is being sent.
  • toAccountId: The ID of the account to which the money is being sent.
  • amount: The amount of money involved in the transaction.

With our domain models defined, we have a solid foundation upon which to build the rest of our application. In the next sections, we'll explore how to implement the Repository and Unit of Work patterns to interact with these models in a clean and modular way.

Implementing the Repository Pattern

The Repository pattern is a fundamental part of clean architecture, serving as an abstraction layer between the application's business logic and the underlying data storage mechanism. In this section, we'll delve into how to implement the Repository pattern using TypeScript and Node.js, with memory implementations.

IRepository Interface

The IRepository interface defines the contract that all repository implementations must adhere to. It provides methods for basic CRUD (Create, Read, Update, Delete) operations on domain entities.

export interface IRepository<T> {
  findById(id: string): Promise<T | undefined>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The IRepository interface consists of the following methods:

  • findById(id: string): Retrieves an entity by its unique identifier.
  • findAll(): Retrieves all entities stored in the repository.
  • save(entity: T): Saves or updates an entity in the repository.
  • InMemoryRepository Implementation

The InMemoryRepository class provides an implementation of the IRepository interface using an in-memory data store. This fake implementation will be used for testing purposes on the application layer. For the sake of simplicity, we'll use a JavaScript Map to store entities.

import { IRepository } from "./IRepository";

export class InMemoryRepository<T> implements IRepository<T> {
  private data: Map<string, T> = new Map();

  async findById(id: string): Promise<T | undefined> {
    return this.data.get(id);
  }

  async findAll(): Promise<T[]> {
    return Array.from(this.data.values());
  }

  async save(entity: T): Promise<void> {
    // For simplicity, assuming the entity has an 'id' property
    this.data.set((entity as any).id, entity);
  }
}
Enter fullscreen mode Exit fullscreen mode

The InMemoryRepository class implements the methods defined in the IRepository interface by manipulating data stored in a Map. It provides basic functionality for finding, retrieving, and saving entities in memory.

By implementing the Repository pattern in this manner, we achieve a clean separation of concerns between the application's business logic and the data access layer. In the next section, we'll explore how to implement the Unit of Work pattern to manage transactions across multiple repositories effectively.

Implementing the Unit of Work Pattern

The Unit of Work pattern is a crucial aspect of clean architecture, responsible for coordinating multiple repository operations within a single transaction context. In this section, we'll delve into how to implement the Unit of Work pattern using TypeScript and Node.js, with memory implementations.

IUnitOfWork Interface

The IUnitOfWork interface defines the contract that all Unit of Work implementations must adhere to. It provides methods for accessing repository instances and managing transactional operations.

import { Account } from "./Account";
import { IRepository } from "./IRepository";
import { Transaction } from "./Transaction";

export interface IUnitOfWork {
  begin(): Promise<void>;
  commit(): Promise<void>;
  rollback(): Promise<void>;
  getAccountRepository(): IRepository<Account>;
  getTransactionRepository(): IRepository<Transaction>;
}
Enter fullscreen mode Exit fullscreen mode

The IUnitOfWork interface consists of the following methods:

  • commit(): Commits all pending changes made within the unit of work.
  • rollback(): Rolls back all pending changes made within the unit of work.
  • getAccountRepository(): Retrieves the repository instance for managing accounts.
  • getTransactionRepository(): Retrieves the repository instance for managing transactions.

The InMemoryUnitOfWork class provides an implementation of the IUnitOfWork interface using memory repositories. It serves as a central coordinator for managing transactions across multiple repositories.

import { IUnitOfWork } from "./IUnitOfWork";
import { IRepository } from "./IRepository";
import { Account } from "./Account";
import { Transaction } from "./Transaction";
import { InMemoryRepository } from "./InMemoryRepository";

export class InMemoryUnitOfWork implements IUnitOfWork {
  private accounts: IRepository<Account>;
  private transactions: IRepository<Transaction>;

  constructor() {
    this.accounts = new InMemoryRepository<Account>();
    this.transactions = new InMemoryRepository<Transaction>();
  }

  async begin(): Promise<void> {
    // In a memory implementation, begin doesn't do anything
  }

  async commit(): Promise<void> {
    // In a memory implementation, commit doesn't do anything
  }

  async rollback(): Promise<void> {
    // In a memory implementation, rollback doesn't do anything
  }

  getAccountRepository(): IRepository<Account> {
    return this.accounts;
  }

  getTransactionRepository(): IRepository<Transaction> {
    return this.transactions;
  }
}
Enter fullscreen mode Exit fullscreen mode

The InMemoryUnitOfWork class implements the methods defined in the IUnitOfWork interface by providing access to repository instances for accounts and transactions. It does not perform actual transaction management in memory implementations but serves as a placeholder for coordinating repository operations within a transaction context.

A real database implementation needs these operations to coordinate the atomicity between two or more repository writes. However, when testing specifically the use case, the focus is on the domain and not the technology.

By implementing the Unit of Work pattern in this manner, we enable efficient management of transactions and ensure data consistency across multiple repository operations. In the next section, we'll put the Repository and Unit of Work patterns into action by demonstrating a simple money transfer operation.

Putting It All Together

Now that we've implemented the Repository and Unit of Work patterns, it's time to demonstrate how they work together to perform a practical operation within our application. In this section, we'll illustrate a simple money transfer scenario, leveraging the capabilities provided by the Repository and Unit of Work implementations.

Money Transfer Function

We'll begin by implementing a function called TransferMoneyUseCase, which orchestrates the transfer of funds between two accounts. This function will utilize repository instances obtained from the Unit of Work to retrieve and update account balances, ensuring data consistency and integrity.

import { Transaction } from "./Transaction";
import { IUnitOfWork } from "./IUnitOfWork";

export async function TransferMoneyUseCase(
  unitOfWork: IUnitOfWork,
  fromAccountId: string,
  toAccountId: string,
  amount: number
) {
  try {
    await unitOfWork.begin();
    const accountRepository = unitOfWork.getAccountRepository();
    const fromAccount = await accountRepository.findById(fromAccountId);
    const toAccount = await accountRepository.findById(toAccountId);

    if (!fromAccount || !toAccount) {
      throw new Error("Account not found");
    }

    if (fromAccount.balance < amount) {
      throw new Error("Insufficient balance");
    }

    fromAccount.balance -= amount;
    toAccount.balance += amount;

    await accountRepository.save(fromAccount);
    await accountRepository.save(toAccount);

    const transactionRepository = unitOfWork.getTransactionRepository();
    const transactionId = Date.now().toString(); // Generate a simple unique transaction ID
    const transaction = new Transaction(
      transactionId,
      fromAccountId,
      toAccountId,
      amount
    );
    await transactionRepository.save(transaction);
    await unitOfWork.commit();
  } catch (err) {
    await unitOfWork.rollback();
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

The TransferMoneyUseCase function coordinates the transfer of funds from one account to another. It first retrieves the sender and receiver account from the account repository, checks for sufficient balance in the sender's account, update the account balances accordingly and saves the changes to the repository. Additionally, it creates a new transaction record and saves it to the transaction repository.

The Use Case Test

With the TransferMoneyUseCase function in place, we can now demonstrate the money transfer operation through a test scenario. We'll create some initial accounts, perform a money transfer, and display the updated balances of the accounts.

import { Account } from "./Account";
import { InMemoryUnitOfWork } from "./InMemoryUnitOfWork";
import { TransferMoneyUseCase } from "./TransferMoneyUseCase";

test("Make a transfer between two accounts", async () => {
  const unitOfWork = new InMemoryUnitOfWork();

  // Create some initial accounts
  await unitOfWork.getAccountRepository().save(new Account("1", 1000));
  await unitOfWork.getAccountRepository().save(new Account("2", 500));

  // Transfer money from account 1 to account 2
  await TransferMoneyUseCase(unitOfWork, "1", "2", 200);

  // Check the updated account balances
  const updatedAccounts = await unitOfWork.getAccountRepository().findAll();
  const [first, second] = updatedAccounts;
  expect(first.balance).toBe(800);
  expect(second.balance).toBe(700);
});
Enter fullscreen mode Exit fullscreen mode

Use the following command to run the test:

npx jest
Enter fullscreen mode Exit fullscreen mode

In the test case function, we initialize a new instance of InMemoryUnitOfWork to manage our repository operations within a transaction context. We then create two initial accounts with different balances, perform a money transfer operation from one account to another, and then, verify if the final balances align with the expected results.

It was pretty straightforward and fast to run this test since there was no need to run a database. And that's the goal of this layer, make sure the application rules are correct. To be more specific, check if the accounts have the correct balances after the operation.

By the way, do you see we have an issue on the domain? What if the first account doesn't have enough balance to transfer? Try to add a new test scenario and change the code to make it work.

With the Repository and Unit of Work patterns put into action, we've successfully demonstrated how to build a clean and testable application architecture in TypeScript and Node.js, enabling efficient management of domain entities and transactions.

Conclusion

In this tutorial, we've embarked on a journey through the implementation of the Repository and Unit of Work patterns in TypeScript and Node.js, using memory implementations to show that you can test your application layer without relying on external dependencies, such as an SQL database. These patterns are indispensable tools in the arsenal of clean architecture, enabling developers to build robust, maintainable, and scalable applications.

Through practical examples and code snippets, we've explored how the Repository pattern abstracts data access logic, providing a clean separation between the application's business logic and the underlying data storage mechanism. By implementing repositories for our domain entities such as accounts and transactions, we've ensured that our code remains modular and easy to maintain.

Additionally, we've delved into the Unit of Work pattern, which serves as a central coordinator for managing transactions across multiple repositories. By encapsulating repository operations within a transaction context, we've achieved data consistency and integrity, even in the face of failures or exceptions during the operation.

As you continue your journey in software development, I encourage you to embrace these patterns and incorporate them into your projects. By adhering to clean architecture principles and leveraging the power of patterns like the Repository and Unit of Work, you can elevate the quality of your code and build applications that stand the test of time.

Top comments (4)

Collapse
 
nathanreuter profile image
Nathan Godinho

Nice work! I'll look forward to implementing this concept in my next project.

Collapse
 
schead profile image
Marcos Schead

Thanks, my friend! We can discuss it in more detail if you want. It will give us a good talk about software development.

Collapse
 
michaeltharrington profile image
Michael Tharrington

Great first post, Marcos! Appreciate ya sharing this one with us. 🙌

Collapse
 
schead profile image
Marcos Schead

Thanks, Michael! I will bring more good stuff in my next posts. Stay tuned :)