npm.io
0.2.2 • Published 23h ago

@groundbrick/service-base

Licence
MIT
Version
0.2.2
Deps
2
Size
62 kB
Vulns
0
Weekly
50

@groundbrick/service-base

Service layer foundation with validation, logging, error handling, and transaction support.

Service layer base classes and utilities for business logic implementation in layered architecture applications.

Features

BaseService Class

  • Integrated logging with service context
  • Database transaction management
  • Input validation framework
  • Standardized error handling
  • Repository integration patterns
Business Logic Patterns
  • Input validation before repository calls
  • Business rule enforcement
  • Cross-entity operations coordination
  • Response transformation and mapping
Error Handling
  • Business-specific error types (BusinessError)
  • Validation error aggregation
  • Repository error transformation
  • Structured error responses for APIs
Validation System
  • Simple, extensible validation framework
  • Common validation rules included
  • Custom rule registration
  • Field-level error reporting

Installation

npm install @groundbrick/service-base

Dependencies

This package requires:

  • @groundbrick/logger - Logging functionality
  • @groundbrick/db-core - Database interfaces

Quick Start

1. Basic Service Implementation
import { BaseService, BusinessError, ValidationHelper } from '@groundbrick/service-base';
import { UserRepository } from './UserRepository';

interface User {
  id: number;
  name: string;
  email: string;
  active: boolean;
}

interface CreateUserRequest {
  name: string;
  email: string;
}

export class UserService extends BaseService {
  constructor(private userRepository: UserRepository) {
    super();
  }

  async createUser(userData: CreateUserRequest): Promise<User> {
    const endTimer = this.startOperation('createUser');

    try {
      // 1. Validate input
      const validationResult = await this.validate(userData, {
        name: [
          ValidationHelper.required(),
          ValidationHelper.minLength(2),
          ValidationHelper.maxLength(100)
        ],
        email: [
          ValidationHelper.required(),
          ValidationHelper.email()
        ]
      });

      if (!validationResult.isValid) {
        throw new BusinessError(
          'Invalid user data',
          'VALIDATION_FAILED',
          undefined,
          { errors: validationResult.errors }
        );
      }

      // 2. Business logic
      const existingUser = await this.userRepository.findByEmail(userData.email);
      if (existingUser) {
        throw new BusinessError(
          'User already exists',
          'USER_EXISTS',
          undefined,
          { email: userData.email }
        );
      }

      // 3. Create user
      const user = await this.userRepository.create({
        name: userData.name,
        email: userData.email.toLowerCase(),
        active: true
      });

      this.logger.info('User created successfully', { userId: user.id });
      return user;

    } catch (error) {
      if (BusinessError.isBusinessError(error)) {
        throw error;
      }
      throw this.handleRepositoryError(error as Error);
    } finally {
      endTimer();
    }
  }
}
2. Using Transactions
export class OrderService extends BaseService {
  constructor(
    private orderRepository: OrderRepository,
    private inventoryRepository: InventoryRepository,
    options?: ServiceOptions
  ) {
    super(options);
  }

  async createOrder(orderData: CreateOrderRequest): Promise<Order> {
    // Use transaction for multi-repository operations
    return await this.withTransaction(async (tx) => {
      // Create order
      const order = await this.orderRepository.createWithTransaction(tx, {
        user_id: orderData.user_id,
        total_amount: orderData.total
      });

      // Update inventory
      for (const item of orderData.items) {
        await this.inventoryRepository.decrementStockWithTransaction(
          tx,
          item.product_id,
          item.quantity
        );
      }

      return order;
    });
  }
}
Transaction pattern:
  1. Create Parent First, Use Generated ID
// Inside withTransaction callback
const newOrder = await this.orderRepository.createWithTransaction(tx, {
  user_id: orderData.user_id,
  total_amount: totalAmount,
  status: 'pending'
});

// newOrder.id is now available (auto-generated by database)

// Use the order ID for child records
for (const item of orderData.items) {
  await this.orderItemRepository.createWithTransaction(tx, {
    order_id: newOrder.id,  // 👈 Use the generated ID
    product_id: item.product_id,
    quantity: item.quantity,
    unit_price: item.unit_price
  });
}
  1. Alternative: Bulk Creation
// Create all order items at once
const orderItemsData = orderData.items.map(item => ({
  order_id: newOrder.id,  // Same ID for all items
  product_id: item.product_id,
  quantity: item.quantity,
  unit_price: item.unit_price
}));

await this.orderItemRepository.createManyWithTransaction(tx, orderItemsData);
  1. Complete Example with Proper Flow
async createOrder(orderData: CreateOrderRequest): Promise<Order> {
  return await this.withTransaction(async (tx) => {
    // 1. Validate business rules first
    await this.validateOrderData(orderData);
    
    // 2. Calculate total amount
    let totalAmount = 0;
    const validatedItems = [];
    
    for (const item of orderData.items) {
      const product = await this.productRepository.findByIdWithTransaction(tx, item.product_id);
      // ... validation logic
      totalAmount += product.price * item.quantity;
      validatedItems.push({
        product_id: item.product_id,
        quantity: item.quantity,
        unit_price: product.price
      });
    }

    // 3. Create the order (gets auto-generated ID)
    const newOrder = await this.orderRepository.createWithTransaction(tx, {
      user_id: orderData.user_id,
      total_amount: totalAmount,
      status: 'pending',
      created_at: new Date()
    });

    // 4. Create order items using the order ID
    for (const item of validatedItems) {
      await this.orderItemRepository.createWithTransaction(tx, {
        order_id: newOrder.id,  // 👈 Key: Use generated order ID
        product_id: item.product_id,
        quantity: item.quantity,
        unit_price: item.unit_price
      });
    }

    // 5. Update product inventory
    for (const item of orderData.items) {
      await this.productRepository.decrementStockWithTransaction(
        tx, 
        item.product_id, 
        item.quantity
      );
    }

    // 6. Return the complete order (with ID)
    return newOrder;
  });
}
Repository Method Requirements:

Your repositories need to support transaction-aware methods:

// In your OrderRepository
class OrderRepository extends BaseRepository<Order> {
  async createWithTransaction(tx: DatabaseTransaction, data: Partial<Order>): Promise<Order> {
    // Use tx.query() instead of this.db.query()
    const result = await tx.query(
      'INSERT INTO orders (user_id, total_amount, status, created_at) VALUES (?, ?, ?, ?) RETURNING *',
      [data.user_id, data.total_amount, data.status, data.created_at]
    );
    return result.rows[0];
  }
}

// In your OrderItemRepository  
class OrderItemRepository extends BaseRepository<OrderItem> {
  async createWithTransaction(tx: DatabaseTransaction, data: Partial<OrderItem>): Promise<OrderItem> {
    const result = await tx.query(
      'INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?) RETURNING *',
      [data.order_id, data.product_id, data.quantity, data.unit_price]
    );
    return result.rows[0];
  }

  async createManyWithTransaction(tx: DatabaseTransaction, items: Partial<OrderItem>[]): Promise<OrderItem[]> {
    // Bulk insert implementation
    const values = items.map(item => [item.order_id, item.product_id, item.quantity, item.unit_price]);
    // ... bulk insert logic
  }
}
Key Points:
  • Order matters: Create parent record first to get the ID
  • Use the transaction: All operations must use the same tx parameter
  • ID is immediately available: After createWithTransaction, the returned object has the generated ID
  • All-or-nothing: If any step fails, the entire transaction rolls back
  • Performance: Consider bulk operations for multiple child records
3. Custom Validation Rules
export class ProductService extends BaseService {
  constructor(private productRepository: ProductRepository) {
    super();
    
    // Register custom validation rules
    this.validator.registerRule('sku', (value) => {
      const skuPattern = /^[A-Z]{2}-\d{4}$/;
      return skuPattern.test(value) ? null : 'SKU must follow format: XX-0000';
    });
  }

  async createProduct(productData: CreateProductRequest): Promise<Product> {
    const validationResult = await this.validate(productData, {
      name: [ValidationHelper.required(), ValidationHelper.minLength(2)],
      sku: [ValidationHelper.required(), this.validator.getRule('sku')!],
      price: [ValidationHelper.required(), ValidationHelper.numberRange(0.01, 10000)]
    });

    if (!validationResult.isValid) {
      throw new BusinessError('Invalid product data', 'VALIDATION_FAILED', undefined, {
        errors: validationResult.errors
      });
    }

    // Create product logic...
  }
}

API Reference

BaseService

The abstract base class that your services should extend.

Constructor Options
interface ServiceOptions {
  database?: DatabaseClient;     // For transaction support
  validator?: Validator;         // Custom validator instance
  config?: Record<string, any>;  // Service-specific config
}
Protected Methods
// Validation
protected async validate<T>(data: T, rules: ValidationRules): Promise<ValidationResult>
protected validateRequired(data: Record<string, any>, fields: string[]): void

// Transactions
protected async withTransaction<T>(callback: (tx: DatabaseTransaction) => Promise<T>): Promise<T>

// Error Handling
protected handleRepositoryError(error: Error): BusinessError

// Utilities
protected startOperation(operation: string, metadata?: Record<string, any>): () => void
protected safeSerialize(obj: any): any
BusinessError

Custom error class for business logic errors.

class BusinessError extends Error {
  constructor(
    message: string,
    code: string,
    originalError?: Error,
    context?: Record<string, any>
  )

  // Properties
  readonly code: string
  readonly originalError?: Error
  readonly context?: Record<string, any>
  readonly timestamp: Date

  // Methods
  toJSON(): Record<string, any>
  toUserResponse(): { error: string; code: string; timestamp: string }
  hasCode(code: string): boolean
  static isBusinessError(error: any): error is BusinessError
}
ValidationHelper

Pre-built validation rules for common scenarios.

class ValidationHelper {
  // Basic rules
  static required(): ValidationRule
  static minLength(min: number): ValidationRule
  static maxLength(max: number): ValidationRule
  static email(): ValidationRule
  static numberRange(min?: number, max?: number): ValidationRule
  static pattern(regex: RegExp, message: string): ValidationRule
  static oneOf(options: any[], message?: string): ValidationRule
  
  // Array rules
  static arrayMinLength(min: number): ValidationRule
  
  // Advanced rules
  static custom(validator: (value: any, data?: any) => string | null): ValidationRule
  static when(condition: (data: any) => boolean, rule: ValidationRule): ValidationRule
  
  // Utility methods
  static combine(...rules: ValidationRule[]): ValidationRule[]
  static entityId(): ValidationRule[]
  
  // Pre-configured rule sets
  static userCreation(): ValidationRules
  static pagination(): ValidationRules
}

Validation System

Basic Validation
const result = await this.validate(data, {
  email: [ValidationHelper.required(), ValidationHelper.email()],
  age: [ValidationHelper.numberRange(18, 120)]
});

if (!result.isValid) {
  // Handle validation errors
  console.log(result.errors); // { email: ['Must be a valid email'], age: ['Must be at least 18'] }
}
Custom Validation Rules
// Register a custom rule
this.validator.registerRule('phone', (value) => {
  const phonePattern = /^\+?[\d\s-()]{10,}$/;
  return phonePattern.test(value) ? null : 'Invalid phone number format';
});

// Use in validation
const rules = {
  phone: [ValidationHelper.required(), this.validator.getRule('phone')!]
};
Conditional Validation
const rules = {
  email: [ValidationHelper.required(), ValidationHelper.email()],
  password: ValidationHelper.when(
    (data) => data.isNewUser === true,
    ValidationHelper.combine(
      ValidationHelper.required(),
      ValidationHelper.minLength(8)
    )
  )
};

Error Handling Patterns

1. Repository Error Transformation

The handleRepositoryError method automatically transforms common database errors:

// Database constraint violation → BusinessError with DUPLICATE_RESOURCE code
// Record not found → BusinessError with RESOURCE_NOT_FOUND code  
// Foreign key violation → BusinessError with CONSTRAINT_VIOLATION code
// Other errors → BusinessError with OPERATION_FAILED code
2. Structured Error Responses
try {
  const user = await userService.createUser(userData);
  return createSuccessResult(user);
} catch (error) {
  if (BusinessError.isBusinessError(error)) {
    // Handle business logic errors
    return createErrorResult(error);
  }
  // Handle unexpected errors
  throw error;
}
3. API Integration
export class UserController {
  async createUser(req: any, res: any) {
    try {
      const user = await this.userService.createUser(req.body);
      res.status(201).json(createSuccessResult(user));
    } catch (error) {
      const result = createErrorResult(error as Error);
      
      // Map business error codes to HTTP status codes
      let status = 500;
      if (BusinessError.isBusinessError(error)) {
        switch (error.code) {
          case 'VALIDATION_FAILED': status = 400; break;
          case 'USER_EXISTS': status = 409; break;
          case 'USER_NOT_FOUND': status = 404; break;
        }
      }
      
      res.status(status).json(result);
    }
  }
}

Transaction Management

Simple Transaction
async updateUserProfile(userId: number, profileData: any): Promise<User> {
  return await this.withTransaction(async (tx) => {
    // Update user
    const user = await this.userRepository.updateWithTransaction(tx, userId, {
      name: profileData.name,
      email: profileData.email
    });

    // Update user preferences
    await this.preferencesRepository.updateWithTransaction(tx, userId, {
      theme: profileData.theme,
      language: profileData.language
    });

    return user;
  });
}
Complex Business Transaction
async processOrder(orderData: CreateOrderRequest): Promise<Order> {
  return await this.withTransaction(async (tx) => {
    // 1. Validate inventory
    for (const item of orderData.items) {
      const product = await this.productRepository.findByIdWithTransaction(tx, item.productId);
      if (product.stock < item.quantity) {
        throw new BusinessError('Insufficient stock', 'INSUFFICIENT_STOCK');
      }
    }

    // 2. Create order
    const order = await this.orderRepository.createWithTransaction(tx, orderData);

    // 3. Update inventory
    for (const item of orderData.items) {
      await this.productRepository.decrementStockWithTransaction(
        tx, item.productId, item.quantity
      );
    }

    // 4. Send notification (example of non-transactional side effect)
    // Note: This should be handled outside the transaction
    // Consider using event-driven patterns for side effects

    return order;
  });
}

Best Practices

1. Keep Services Focused
// ✅ Good - Single responsibility
class UserService extends BaseService {
  async createUser(userData: CreateUserRequest): Promise<User> { }
  async updateUser(id: number, updates: UpdateUserRequest): Promise<User> { }
  async getUserById(id: number): Promise<User> { }
}

// ❌ Bad - Too many responsibilities
class UserService extends BaseService {
  async createUser(userData: CreateUserRequest): Promise<User> { }
  async sendWelcomeEmail(user: User): Promise<void> { } // Should be EmailService
  async generateReport(userId: number): Promise<Report> { } // Should be ReportService
}
2. Validate Input Early
// ✅ Good - Validate first
async createUser(userData: CreateUserRequest): Promise<User> {
  const validationResult = await this.validate(userData, this.getUserValidationRules());
  if (!validationResult.isValid) {
    throw new BusinessError('Validation failed', 'VALIDATION_FAILED', undefined, {
      errors: validationResult.errors
    });
  }
  
  // Continue with business logic...
}

// ❌ Bad - Validation mixed with business logic
async createUser(userData: CreateUserRequest): Promise<User> {
  const existingUser = await this.userRepository.findByEmail(userData.email);
  if (!userData.email) { // Too late for basic validation
    throw new Error('Email required');
  }
  // ...
}
3. Use Transactions for Multi-Repository Operations
// ✅ Good - Transaction for consistency
async transferFunds(fromAccount: number, toAccount: number, amount: number): Promise<void> {
  await this.withTransaction(async (tx) => {
    await this.accountRepository.decrementBalanceWithTransaction(tx, fromAccount, amount);
    await this.accountRepository.incrementBalanceWithTransaction(tx, toAccount, amount);
    await this.transactionRepository.createWithTransaction(tx, {
      from_account: fromAccount,
      to_account: toAccount,
      amount
    });
  });
}

// ❌ Bad - No transaction, inconsistent state possible
async transferFunds(fromAccount: number, toAccount: number, amount: number): Promise<void> {
  await this.accountRepository.decrementBalance(fromAccount, amount);
  await this.accountRepository.incrementBalance(toAccount, amount); // Could fail, leaving inconsistent state
  await this.transactionRepository.create({ from_account: fromAccount, to_account: toAccount, amount });
}
4. Handle Errors Appropriately
// ✅ Good - Structured error handling
async getUserById(id: number): Promise<User> {
  try {
    this.validateRequired({ id }, ['id']);
    
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new BusinessError('User not found', 'USER_NOT_FOUND', undefined, { userId: id });
    }
    
    return user;
  } catch (error) {
    if (BusinessError.isBusinessError(error)) {
      throw error; // Re-throw business errors
    }
    // Transform repository errors
    throw this.handleRepositoryError(error as Error);
  }
}

Integration with Other Packages

This service layer integrates seamlessly with other microframework packages:

import { createLogger } from '@groundbrick/logger';
import { DatabaseFactory } from '@groundbrick/db-postgres';
import { UserRepository } from '@groundbrick/repository-base';
import { UserService } from './UserService';

// Initialize database
const db = DatabaseFactory.getInstance({
  host: 'localhost',
  database: 'myapp',
  user: 'user',
  password: 'password'
});

// Initialize repository
const userRepository = new UserRepository(db);

// Initialize service with database for transaction support
const userService = new UserService(userRepository, { database: db });

// Use in application
const user = await userService.createUser({
  name: 'John Doe',
  email: 'john@example.com'
});

License

MIT


Keywords