Advanced Patterns: Repository, Service, Controller Layers in TypeScript

Table of Contents

  • Introduction
  • Why Use Layered Architecture?
  • Layer 1: The Repository Layer
    • What is the Repository Pattern?
    • Repository Layer in TypeScript
    • Example of Repository Pattern
  • Layer 2: The Service Layer
    • What is the Service Layer?
    • Service Layer in TypeScript
    • Example of Service Layer
  • Layer 3: The Controller Layer
    • What is the Controller Layer?
    • Controller Layer in TypeScript
    • Example of Controller Layer
  • Benefits of Using Layered Architecture
  • Conclusion

Introduction

In modern software development, using layered architecture is a common design pattern that helps improve the organization, scalability, and maintainability of applications. A typical layered architecture consists of three main layers: the Repository, the Service, and the Controller.

  • Repository Layer: Manages the persistence logic (e.g., interaction with the database).
  • Service Layer: Contains the business logic, interacting with the Repository and performing calculations, transformations, etc.
  • Controller Layer: Handles HTTP requests and responses, mapping requests to services and formatting the output.

In this guide, we’ll dive into these patterns and show how to implement them using TypeScript in a Node.js-based application (for example, using Express).


Why Use Layered Architecture?

Layered architecture helps in organizing code into distinct layers, each with its own responsibilities. The primary benefits are:

  1. Separation of Concerns: Each layer is responsible for a distinct concern (e.g., database interaction, business logic, HTTP handling).
  2. Reusability: By encapsulating functionality in specific layers (like the Repository or Service), you can reuse them across different parts of the application.
  3. Testability: Individual layers are easier to test because they are independent and have clear responsibilities.
  4. Maintainability: Changes to one layer (e.g., switching the database or modifying business logic) are less likely to affect other layers.

Layer 1: The Repository Layer

What is the Repository Pattern?

The Repository Pattern is a way of abstracting data access logic. It acts as a bridge between the Service Layer and the underlying data source (e.g., a database). The Repository provides an interface to query and manipulate data, isolating the rest of the application from the specific details of data storage.

Repository Layer in TypeScript

In TypeScript, we define a Repository as a class that encapsulates methods for interacting with the database or data source. This layer is often generic, so it can work with multiple entities.

Example of Repository Pattern

import { User } from './models/User'; // Example User model
import { Repository } from 'typeorm'; // Assuming using TypeORM as ORM
import { getRepository } from 'typeorm';

// Repository for User entity
export class UserRepository {
private repository: Repository<User>;

constructor() {
this.repository = getRepository(User);
}

async findById(id: number): Promise<User | undefined> {
return await this.repository.findOne(id);
}

async create(user: User): Promise<User> {
return await this.repository.save(user);
}

async findAll(): Promise<User[]> {
return await this.repository.find();
}

async update(id: number, user: Partial<User>): Promise<User | undefined> {
await this.repository.update(id, user);
return this.findById(id);
}

async delete(id: number): Promise<void> {
await this.repository.delete(id);
}
}

Here, the UserRepository class handles all database-related logic for the User entity. This helps decouple the rest of the application from the database details.


Layer 2: The Service Layer

What is the Service Layer?

The Service Layer contains the business logic of your application. It communicates with the Repository layer to fetch, modify, or persist data, and it applies business rules or transformations as needed.

The Service layer is responsible for organizing and processing data before sending it to the controller or formatting it for the client.

Service Layer in TypeScript

The Service layer typically acts as an intermediary between the controller and repository, ensuring that business logic is abstracted and reusable.

Example of Service Layer

import { UserRepository } from './repositories/UserRepository';
import { User } from './models/User';

export class UserService {
private userRepository: UserRepository;

constructor() {
this.userRepository = new UserRepository();
}

async getUserById(id: number): Promise<User | undefined> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}

async createUser(userData: User): Promise<User> {
// Apply business rules here
const user = await this.userRepository.create(userData);
return user;
}

async updateUser(id: number, userData: Partial<User>): Promise<User | undefined> {
const user = await this.userRepository.update(id, userData);
return user;
}

async deleteUser(id: number): Promise<void> {
await this.userRepository.delete(id);
}
}

Here, the UserService handles the business logic, such as applying rules and transformations, and delegates data access to the UserRepository.


Layer 3: The Controller Layer

What is the Controller Layer?

The Controller Layer is responsible for handling HTTP requests and mapping them to the appropriate service methods. It formats the request and response objects, processes query parameters, handles routing, and passes data between the client and server.

The Controller layer does not contain business logic or database interaction; it acts as a middleman between the client and the business logic.

Controller Layer in TypeScript

The Controller layer in TypeScript can be implemented using frameworks like Express. The controller functions are typically asynchronous, handling requests and responses.

Example of Controller Layer

import { Request, Response } from 'express';
import { UserService } from './services/UserService';

export class UserController {
private userService: UserService;

constructor() {
this.userService = new UserService();
}

async getUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id, 10);
try {
const user = await this.userService.getUserById(userId);
res.status(200).json(user);
} catch (error) {
res.status(404).json({ error: error.message });
}
}

async createUser(req: Request, res: Response): Promise<void> {
try {
const userData = req.body;
const user = await this.userService.createUser(userData);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
}

async updateUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id, 10);
const userData = req.body;
try {
const updatedUser = await this.userService.updateUser(userId, userData);
res.status(200).json(updatedUser);
} catch (error) {
res.status(404).json({ error: error.message });
}
}

async deleteUser(req: Request, res: Response): Promise<void> {
const userId = parseInt(req.params.id, 10);
try {
await this.userService.deleteUser(userId);
res.status(204).end();
} catch (error) {
res.status(404).json({ error: error.message });
}
}
}

In the UserController class:

  • HTTP requests are mapped to service methods (e.g., getUserById, createUser).
  • HTTP status codes and responses are formatted before sending back to the client.

Benefits of Using Layered Architecture

  1. Separation of Concerns: Each layer is responsible for a specific part of the application, making the code more modular and easier to maintain.
  2. Testability: Each layer can be tested independently. For example, you can test the repository methods with mock data, the service methods with the repository mocked, and the controllers using integration tests.
  3. Scalability: As your application grows, the layered architecture allows you to scale the codebase by introducing new layers or modifying existing ones without breaking the entire system.
  4. Reusability: Code is more reusable across different parts of the application or even other projects. For instance, the service layer can be used in multiple controllers.
  5. Maintainability: Changes to one layer (e.g., switching from MongoDB to PostgreSQL in the repository layer) don’t affect the rest of the system significantly.

Conclusion

Layered architecture is a powerful design pattern that helps in organizing complex applications. By using the Repository, Service, and Controller layers in TypeScript, you can create maintainable, scalable, and easily testable code.

  • Repository: Manages data access and persistence.
  • Service: Contains business logic and interacts with the repository.
  • Controller: Handles HTTP requests and maps them to services.

This pattern can be used to build scalable applications with TypeScript, especially when combined with frameworks like Express and TypeORM for ORM-based database access.

By adhering to these design principles, you’ll be able to build applications that are easier to maintain, test, and extend.