Skip to content

Instantly share code, notes, and snippets.

@StiiCeva
Created November 12, 2024 10:05
Show Gist options
  • Save StiiCeva/eff34e133092ccd47659eb4317b06d36 to your computer and use it in GitHub Desktop.
Save StiiCeva/eff34e133092ccd47659eb4317b06d36 to your computer and use it in GitHub Desktop.
Choosing the Right Data Access Pattern in TypeScript

Choosing the Right Data Access Pattern in TypeScript

Audience: Developers


Introduction

When building TypeScript applications, selecting the appropriate data access pattern is crucial for maintainability, scalability, and development speed. This guide helps you choose between the following patterns:

  • Repository Pattern
  • Service Pattern Without Repositories
  • Service Classes with Dependency Injection (DI) Without Interfaces
  • Factory Functions for Dependency Injection

This is especially relevant when data entities are not yet clearly defined and may originate from an HTTP API, Redis cache, database table, Kafka topic, etc.


Important Note

Development should not be blocked by the unavailability of data sources. If you don't have access to the actual data source (HTTP API, Redis cache, database, Kafka topic, etc.), you can mock the data using one of the patterns below. Implementing the concrete data source can be logged as technical debt to be addressed later.


Patterns Overview with UserRole Entity Examples

1. Repository Pattern

Description: Abstracts data access behind repository interfaces, allowing for easy swapping of data sources without altering business logic.

Code Example

Interfaces and Models:

// models/UserRole.ts
export interface UserRole {
  id: number;
  userId: number;
  role: string;
}
// repositories/IUserRoleRepository.ts
export interface IUserRoleRepository {
  getUserRoleById(id: number): Promise<UserRole | null>;
  getUserRolesByUserId(userId: number): Promise<UserRole[]>;
  createUserRole(userRole: UserRole): Promise<UserRole>;
  updateUserRole(userRole: UserRole): Promise<UserRole>;
  deleteUserRole(id: number): Promise<void>;
}

Concrete Implementations:

// repositories/ApiUserRoleRepository.ts
import { IUserRoleRepository } from './IUserRoleRepository';
import { UserRole } from '../models/UserRole';
import axios from 'axios';

export class ApiUserRoleRepository implements IUserRoleRepository {
  private baseUrl = 'https://api.example.com/user-roles';

  async getUserRoleById(id: number): Promise<UserRole | null> {
    try {
      const response = await axios.get<UserRole>(`${this.baseUrl}/${id}`);
      return response.data;
    } catch {
      return null;
    }
  }

  async getUserRolesByUserId(userId: number): Promise<UserRole[]> {
    const response = await axios.get<UserRole[]>(`${this.baseUrl}?userId=${userId}`);
    return response.data;
  }

  async createUserRole(userRole: UserRole): Promise<UserRole> {
    const response = await axios.post<UserRole>(this.baseUrl, userRole);
    return response.data;
  }

  async updateUserRole(userRole: UserRole): Promise<UserRole> {
    const response = await axios.put<UserRole>(`${this.baseUrl}/${userRole.id}`, userRole);
    return response.data;
  }

  async deleteUserRole(id: number): Promise<void> {
    await axios.delete(`${this.baseUrl}/${id}`);
  }
}

Mock Implementation:

// repositories/MockUserRoleRepository.ts
import { IUserRoleRepository } from './IUserRoleRepository';
import { UserRole } from '../models/UserRole';

export class MockUserRoleRepository implements IUserRoleRepository {
  private userRoles: UserRole[] = [
    { id: 1, userId: 1, role: 'Admin' },
    { id: 2, userId: 1, role: 'Editor' },
    { id: 3, userId: 2, role: 'Viewer' },
  ];

  async getUserRoleById(id: number): Promise<UserRole | null> {
    return this.userRoles.find(ur => ur.id === id) || null;
  }

  async getUserRolesByUserId(userId: number): Promise<UserRole[]> {
    return this.userRoles.filter(ur => ur.userId === userId);
  }

  async createUserRole(userRole: UserRole): Promise<UserRole> {
    const newId = this.userRoles.length + 1;
    const newUserRole = { ...userRole, id: newId };
    this.userRoles.push(newUserRole);
    return newUserRole;
  }

  async updateUserRole(userRole: UserRole): Promise<UserRole> {
    const index = this.userRoles.findIndex(ur => ur.id === userRole.id);
    if (index === -1) throw new Error('UserRole not found');
    this.userRoles[index] = userRole;
    return userRole;
  }

  async deleteUserRole(id: number): Promise<void> {
    this.userRoles = this.userRoles.filter(ur => ur.id !== id);
  }
}

Service Layer:

// services/UserRoleService.ts
import { IUserRoleRepository } from '../repositories/IUserRoleRepository';
import { UserRole } from '../models/UserRole';

export class UserRoleService {
  constructor(private userRoleRepository: IUserRoleRepository) {}

  async getUserRoleById(id: number): Promise<UserRole | null> {
    return await this.userRoleRepository.getUserRoleById(id);
  }

  async getUserRolesByUserId(userId: number): Promise<UserRole[]> {
    return await this.userRoleRepository.getUserRolesByUserId(userId);
  }

  async createUserRole(userRole: UserRole): Promise<UserRole> {
    return await this.userRoleRepository.createUserRole(userRole);
  }

  async updateUserRole(userRole: UserRole): Promise<UserRole> {
    return await this.userRoleRepository.updateUserRole(userRole);
  }

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

Usage:

// app.ts
import { UserRoleService } from './services/UserRoleService';
import { MockUserRoleRepository } from './repositories/MockUserRoleRepository';
// import { ApiUserRoleRepository } from './repositories/ApiUserRoleRepository';

const userRoleRepository = new MockUserRoleRepository();
// const userRoleRepository = new ApiUserRoleRepository();

const userRoleService = new UserRoleService(userRoleRepository);

async function main() {
  const userRoles = await userRoleService.getUserRolesByUserId(1);
  console.log(userRoles);

  const newUserRole = await userRoleService.createUserRole({
    id: 0,
    userId: 3,
    role: 'Contributor',
  });
  console.log('Created UserRole:', newUserRole);
}

main();

2. Service Pattern Without Repositories

Description: Combines business logic and data access within service classes, eliminating the repository layer.

Code Example

// services/UserRoleService.ts
import { UserRole } from '../models/UserRole';
import axios from 'axios';

export class UserRoleService {
  private baseUrl = 'https://api.example.com/user-roles';

  async getUserRoleById(id: number): Promise<UserRole | null> {
    try {
      const response = await axios.get<UserRole>(`${this.baseUrl}/${id}`);
      return response.data;
    } catch {
      return null;
    }
  }

  async getUserRolesByUserId(userId: number): Promise<UserRole[]> {
    const response = await axios.get<UserRole[]>(`${this.baseUrl}?userId=${userId}`);
    return response.data;
  }

  async createUserRole(userRole: UserRole): Promise<UserRole> {
    const response = await axios.post<UserRole>(this.baseUrl, userRole);
    return response.data;
  }

  async updateUserRole(userRole: UserRole): Promise<UserRole> {
    const response = await axios.put<UserRole>(`${this.baseUrl}/${userRole.id}`, userRole);
    return response.data;
  }

  async deleteUserRole(id: number): Promise<void> {
    await axios.delete(`${this.baseUrl}/${id}`);
  }
}

Usage:

// app.ts
import { UserRoleService } from './services/UserRoleService';

const userRoleService = new UserRoleService();

async function main() {
  const userRoles = await userRoleService.getUserRolesByUserId(1);
  console.log(userRoles);

  const newUserRole = await userRoleService.createUserRole({
    id: 0,
    userId: 3,
    role: 'Contributor',
  });
  console.log('Created UserRole:', newUserRole);
}

main();

3. Service Classes with DI Without Interfaces

Description: Injects concrete data access classes into services without using interfaces, reducing abstraction layers.

Code Example

Data Access Class:

// dataAccess/UserRoleDataAccess.ts
import { UserRole } from '../models/UserRole';
import axios from 'axios';

export class UserRoleDataAccess {
  private baseUrl = 'https://api.example.com/user-roles';

  async getUserRoleById(id: number): Promise<UserRole | null> {
    // Implementation similar to the repository methods
  }

  // Other methods...
}

Service Class:

// services/UserRoleService.ts
import { UserRole } from '../models/UserRole';
import { UserRoleDataAccess } from '../dataAccess/UserRoleDataAccess';

export class UserRoleService {
  constructor(private dataAccess: UserRoleDataAccess) {}

  async getUserRoleById(id: number): Promise<UserRole | null> {
    return await this.dataAccess.getUserRoleById(id);
  }

  // Other methods...
}

Usage:

// app.ts
import { UserRoleService } from './services/UserRoleService';
import { UserRoleDataAccess } from './dataAccess/UserRoleDataAccess';

const dataAccess = new UserRoleDataAccess();
const userRoleService = new UserRoleService(dataAccess);

async function main() {
  const userRoles = await userRoleService.getUserRolesByUserId(1);
  console.log(userRoles);

  const newUserRole = await userRoleService.createUserRole({
    id: 0,
    userId: 3,
    role: 'Contributor',
  });
  console.log('Created UserRole:', newUserRole);
}

main();

4. Factory Functions for Dependency Injection

Description: Uses factory functions to create instances with dependencies injected, avoiding DI frameworks and interfaces.

Code Example

Data Access Class:

// dataAccess/UserRoleDataAccess.ts
import { UserRole } from '../models/UserRole';
import axios from 'axios';

export class UserRoleDataAccess {
  private baseUrl = 'https://api.example.com/user-roles';

  async getUserRoleById(id: number): Promise<UserRole | null> {
    // Implementation...
  }

  // Other methods...
}

Service Class:

// services/UserRoleService.ts
import { UserRole } from '../models/UserRole';
import { UserRoleDataAccess } from '../dataAccess/UserRoleDataAccess';

export class UserRoleService {
  constructor(private dataAccess: UserRoleDataAccess) {}

  async getUserRoleById(id: number): Promise<UserRole | null> {
    return await this.dataAccess.getUserRoleById(id);
  }

  // Other methods...
}

Factory Function:

// factories/createUserRoleService.ts
import { UserRoleDataAccess } from '../dataAccess/UserRoleDataAccess';
import { UserRoleService } from '../services/UserRoleService';

export function createUserRoleService() {
  const dataAccess = new UserRoleDataAccess();
  return new UserRoleService(dataAccess);
}

Usage:

// app.ts
import { createUserRoleService } from './factories/createUserRoleService';

const userRoleService = createUserRoleService();

async function main() {
  const userRoles = await userRoleService.getUserRolesByUserId(1);
  console.log(userRoles);

  const newUserRole = await userRoleService.createUserRole({
    id: 0,
    userId: 3,
    role: 'Contributor',
  });
  console.log('Created UserRole:', newUserRole);
}

main();

Trade-off Analysis

Criteria Repository Pattern Service Pattern Without Repositories Service with DI Without Interfaces Factory Functions for DI
Complexity High Low Medium Low
Development Speed Slower initially Fast Medium Fast
Testability High Medium Medium Medium
Flexibility High Low Medium Low
Maintainability High (long-term) Low Medium Low
Scalability High Low Medium Low

Decision Strategy

  1. Evaluate Project Needs:

    • Simplicity vs. Complexity: For simple applications or prototypes, a simpler pattern may suffice.
    • Future Growth: If you anticipate scaling or changing data sources, consider patterns with more flexibility.
    • Team Expertise: Choose a pattern that aligns with your team's experience.
  2. Start with Simplicity:

    • Begin with the Service Pattern Without Repositories or Factory Functions for rapid development.
    • This approach reduces initial complexity and speeds up development.
  3. Plan for Flexibility:

    • If moderate flexibility is needed, use Service Classes with DI Without Interfaces.
    • This provides a balance between simplicity and the ability to swap implementations.
  4. Prepare for Refactoring:

    • Design your code to allow for a transition to the Repository Pattern if needed.
    • Keep business logic separate from data access to facilitate easier refactoring.
  5. Mock Data Sources:

    • Use mock implementations to simulate data sources you don't have access to.
    • This allows development to continue without being blocked by unavailable resources.
    • Log the implementation of the concrete data source as technical debt to address later.

Refactoring Path to Repository Pattern

  1. Identify Data Access Logic:

    • Locate where your services interact directly with data sources.
  2. Extract Interfaces:

    • Define repository interfaces based on your current data access methods.
    • Example:
      interface IUserRoleRepository {
        getUserRoleById(id: number): Promise<UserRole | null>;
        // Other methods...
      }
  3. Implement Repositories:

    • Create concrete classes that implement these interfaces.
    • Move data access code from services to repository classes.
  4. Inject Repositories into Services:

    • Modify services to depend on repository interfaces.
    • Use dependency injection to pass repositories into services.
  5. Update Tests:

    • Adjust tests to mock repository interfaces instead of data sources.
    • This improves test isolation and reliability.

Conclusion

Choosing the right data access pattern depends on your application's requirements, team expertise, and future plans. For simpler cases, starting with the Service Pattern Without Repositories or Factory Functions allows for quick development and less overhead. As your application grows, you can refactor to the Repository Pattern to gain flexibility and maintainability.

Remember, the goal is to balance development speed with code quality and scalability. Planning ahead and structuring your code to allow for refactoring ensures that your application can adapt to changing needs.


For Further Discussion:

  • Team Meetings: Schedule time to discuss which pattern suits your current projects.
  • Code Reviews: Incorporate pattern decisions into code review checklists.
  • Documentation: Keep documentation updated with any architectural decisions.

References:

  • TypeScript Handbook
  • Design Patterns Explained
  • Dependency Injection in TypeScript

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment