Skip to content

Understanding the Layered Architecture Pattern

This guide explains the layered architecture pattern implemented in the PRS Backend project, focusing on the roles of BaseService, BaseRepository, and BaseController.

Overview

The PRS Backend follows a layered architecture pattern with clear separation of concerns:

Text Only
┌─────────────────────────────────────────────────────────────┐
│                      Presentation Layer                      │
│                     (Controllers, Routes)                    │
└───────────────────────────────┬─────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                       Service Layer                          │
│                  (Business Logic, Validation)                │
└───────────────────────────────┬─────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                     Repository Layer                         │
│                      (Data Access)                           │
└───────────────────────────────┬─────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                     Infrastructure Layer                     │
│                  (Database, External Services)               │
└─────────────────────────────────────────────────────────────┘

Each layer has specific responsibilities and communicates only with adjacent layers.

Why Both BaseService and BaseRepository Have CRUD Functions

A common question is why both the BaseService and BaseRepository have CRUD functions. This is by design and follows the principles of layered architecture:

BaseRepository: Data Access Layer

The BaseRepository is responsible for:

  • Direct database interaction: Communicates with the database through the ORM (Sequelize)
  • Raw data operations: Performs basic CRUD operations on the database
  • Query execution: Executes SQL queries and returns raw results
  • No business logic: Doesn't contain business rules or validation
JavaScript
// Example BaseRepository CRUD method
async create(payload, options = {}) {
  const result = await this.tableName.create(payload, {
    ...options,
    userId: options.userId,
    payload,
  });

  return result.toJSON();
}

BaseService: Business Logic Layer

The BaseService is responsible for:

  • Business logic: Implements business rules and workflows
  • Validation: Validates input data before processing
  • Error handling: Translates technical errors to domain-specific errors
  • Transaction management: Manages database transactions across multiple operations
  • Orchestration: Coordinates calls to multiple repositories
JavaScript
// Example BaseService CRUD method
async create(repository, data, options = {}) {
  try {
    return await repository.create(data, options);
  } catch (error) {
    if (error.name === 'SequelizeUniqueConstraintError') {
      throw this.ErrorFactory.conflict(
        'Record with this unique identifier already exists',
        options.resource || 'Record',
        error.errors?.[0]?.path || 'field',
        error.errors?.[0]?.value || 'value',
        error
      );
    }

    throw this.ErrorTranslator.translate(error, {
      operation: 'create',
      resource: options.resource || 'record'
    });
  }
}

Key Differences

Aspect BaseRepository BaseService
Responsibility Data access Business logic
Interacts with Database Repositories
Error handling Minimal Comprehensive
Transaction scope Single operation Multiple operations
Validation None Input validation
Focus How to access data What operations to perform

Practical Example

Let's look at a practical example to understand the difference:

Repository Layer (How)

JavaScript
// UserRepository.js
class UserRepository extends BaseRepository {
  constructor({ db }) {
    super(db.userModel);
  }

  // Additional data access methods specific to users
  async findByEmail(email) {
    return await this.findOne({
      where: { email }
    });
  }
}

Service Layer (What)

JavaScript
// UserService.js
class UserService extends BaseService {
  constructor(container) {
    super(container);
    const { userRepository, userSettingsRepository } = container;
    this.userRepository = userRepository;
    this.userSettingsRepository = userSettingsRepository;
  }

  async registerUser(userData) {
    // Validate email format
    if (!this.isValidEmail(userData.email)) {
      throw this.ErrorFactory.validation('Invalid email format');
    }

    // Check if email already exists
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw this.ErrorFactory.conflict('User with this email already exists');
    }

    // Hash password
    userData.password = await this.hashPassword(userData.password);

    // Use transaction to ensure atomicity
    return this.withTransaction(async (transaction) => {
      // Create user
      const user = await this.create(this.userRepository, userData, { 
        transaction,
        resource: 'User'
      });

      // Create default settings for user
      await this.create(this.userSettingsRepository, {
        userId: user.id,
        theme: 'default',
        notifications: true
      }, { 
        transaction,
        resource: 'UserSettings'
      });

      return user;
    });
  }

  // Helper methods
  async hashPassword(password) {
    // Password hashing logic
  }

  isValidEmail(email) {
    // Email validation logic
  }
}

Controller Layer (Interface)

JavaScript
// UserController.js
class UserController extends BaseController {
  constructor(container) {
    super(container);
    const { userService, entities } = container;
    this.userService = userService;
    this.userEntity = entities.user;
  }

  async registerUser(request, reply) {
    return this.executeAction(async () => {
      const { body } = request;

      // Validate request body against schema
      const validatedData = this.validate(
        this.userEntity.createUserSchema,
        body
      );

      // Call service to register user
      const user = await this.userService.registerUser(validatedData);

      // Return success response
      return this.sendCreated(reply, user, 'User registered successfully');
    }, request, reply);
  }
}

Benefits of This Layered Approach

  1. Separation of Concerns: Each layer has a specific responsibility
  2. Testability: Easier to write unit tests for each layer
  3. Maintainability: Changes in one layer don't affect others
  4. Reusability: Repository methods can be reused across services
  5. Flexibility: Easy to change the implementation of a layer
  6. Scalability: Easier to scale specific layers independently

When to Use Each Layer's CRUD Methods

Use Repository CRUD Methods When:

  • You need direct, low-level access to the database
  • You're implementing custom data access methods in a repository
  • You're working within a transaction managed by a service

Use Service CRUD Methods When:

  • You need to apply business rules before/after data access
  • You need error handling and translation
  • You need to perform operations that span multiple repositories
  • You need transaction management

Common Pitfalls to Avoid

  1. Bypassing Layers: Don't call repositories directly from controllers
  2. Business Logic in Repositories: Keep repositories focused on data access
  3. Data Access in Services: Don't include ORM-specific code in services
  4. Circular Dependencies: Ensure dependencies flow downward only

Conclusion

The layered architecture with BaseService, BaseRepository, and BaseController provides a clean separation of concerns and clear responsibilities for each layer. While both BaseService and BaseRepository have CRUD functions, they serve different purposes:

  • BaseRepository: Focuses on HOW to access data (implementation details)
  • BaseService: Focuses on WHAT operations to perform (business rules)
  • BaseController: Focuses on the INTERFACE with the client (request/response)

This separation makes the codebase more maintainable, testable, and scalable.