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
- 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
Next, install TypeScript as a development dependency:
npm install typescript --save-dev
Additionally, we'll need @types/node to provide type definitions for Node.js:
npm install @types/node --save-dev
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"]
}
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
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",
},
};
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) {}
}
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) {}
}
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>;
}
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);
}
}
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>;
}
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;
}
}
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;
}
}
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);
});
Use the following command to run the test:
npx jest
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.