diff --git a/service_layer_readme.md b/service_layer_readme.md new file mode 100644 index 00000000..2e1bef05 --- /dev/null +++ b/service_layer_readme.md @@ -0,0 +1,523 @@ +# FastAPI Service Layer Architecture + +This document describes the service layer architecture implemented in our FastAPI application, providing clear separation of concerns between HTTP handling (routers) and business logic (services). + +## Table of Contents + +- [Overview](#overview) +- [Architecture Pattern](#architecture-pattern) +- [Service Layer Components](#service-layer-components) +- [Benefits](#benefits) +- [Implementation Guide](#implementation-guide) +- [Testing Strategy](#testing-strategy) +- [Usage Examples](#usage-examples) +- [Best Practices](#best-practices) +- [Migration Guide](#migration-guide) + +## Overview + +Our FastAPI application follows a **Service Layer Architecture** pattern that separates HTTP concerns from business logic. This architecture provides better maintainability, testability, and code reusability. + +### Before vs After + +**Before (Router handling everything):** +```python +@router.post("/products") +def create_product(product: ProductCreate, db: Session = Depends(get_db)): + # Validation logic + if product.gtin: + normalized_gtin = normalize_gtin(product.gtin) + if not normalized_gtin: + raise HTTPException(status_code=400, detail="Invalid GTIN") + + # Business logic + db_product = Product(**product.dict()) + db.add(db_product) + db.commit() + + # Return response + return db_product +``` + +**After (Service layer separation):** +```python +# Router (HTTP concerns only) +@router.post("/products") +def create_product(product: ProductCreate, db: Session = Depends(get_db)): + try: + return product_service.create_product(db, product) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +# Service (Business logic) +class ProductService: + def create_product(self, db: Session, product_data: ProductCreate) -> Product: + # All validation and business logic here + # Returns domain objects, raises domain exceptions +``` + +## Architecture Pattern + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ HTTP Client │ │ FastAPI App │ │ Database │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ HTTP Request │ │ + ├──────────────────────▶ │ │ + │ │ │ + │ ┌─────────────────┐ │ + │ │ Router │ │ + │ │ (HTTP Layer) │ │ + │ └─────────────────┘ │ + │ │ │ + │ │ Delegates to │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ Service │ │ + │ │ (Business Layer)│ │ + │ └─────────────────┘ │ + │ │ │ + │ │ Database Operations │ + │ ├──────────────────────▶│ + │ │ │ + │ HTTP Response │ │ + ◀──────────────────────── │ │ +``` + +## Service Layer Components + +### 1. ProductService + +**File:** `product_service.py` + +**Responsibilities:** +- Product CRUD operations +- GTIN validation and normalization +- Price processing +- Stock information retrieval +- CSV export generation + +**Key Methods:** +```python +class ProductService: + def create_product(self, db: Session, product_data: ProductCreate) -> Product + def get_product_by_id(self, db: Session, product_id: str) -> Optional[Product] + def get_products_with_filters(self, db: Session, **filters) -> tuple[List[Product], int] + def update_product(self, db: Session, product_id: str, product_update: ProductUpdate) -> Product + def delete_product(self, db: Session, product_id: str) -> bool + def get_stock_info(self, db: Session, gtin: str) -> Optional[StockSummaryResponse] + def generate_csv_export(self, db: Session, **filters) -> Generator[str, None, None] +``` + +### 2. StockService + +**File:** `stock_service.py` + +**Responsibilities:** +- Stock quantity management +- GTIN normalization +- Stock location tracking +- Stock operations (set, add, remove) + +**Key Methods:** +```python +class StockService: + def set_stock(self, db: Session, stock_data: StockCreate) -> Stock + def add_stock(self, db: Session, stock_data: StockAdd) -> Stock + def remove_stock(self, db: Session, stock_data: StockAdd) -> Stock + def get_stock_by_gtin(self, db: Session, gtin: str) -> StockSummaryResponse + def get_total_stock(self, db: Session, gtin: str) -> dict + def get_all_stock(self, db: Session, **filters) -> List[Stock] + def normalize_gtin(self, gtin_value) -> Optional[str] +``` + +### 3. MarketplaceService + +**File:** `marketplace_service.py` + +**Responsibilities:** +- Marketplace import job management +- Shop access validation +- User permission checking +- Job lifecycle management +- Import statistics + +**Key Methods:** +```python +class MarketplaceService: + def validate_shop_access(self, db: Session, shop_code: str, user: User) -> Shop + def create_import_job(self, db: Session, request: MarketplaceImportRequest, user: User) -> MarketplaceImportJob + def get_import_job_by_id(self, db: Session, job_id: int, user: User) -> MarketplaceImportJob + def get_import_jobs(self, db: Session, user: User, **filters) -> List[MarketplaceImportJob] + def update_job_status(self, db: Session, job_id: int, status: str, **kwargs) -> MarketplaceImportJob + def cancel_import_job(self, db: Session, job_id: int, user: User) -> MarketplaceImportJob + def delete_import_job(self, db: Session, job_id: int, user: User) -> bool +``` + +## Benefits + +### 1. **Separation of Concerns** +- **Routers**: Handle HTTP requests/responses, authentication, validation of HTTP-specific concerns +- **Services**: Handle business logic, data validation, domain rules +- **Models**: Define data structures and database relationships + +### 2. **Better Testability** +- **Unit Testing**: Test business logic independently of HTTP layer +- **Integration Testing**: Test HTTP endpoints separately from business logic +- **Mocking**: Easy to mock services for router testing + +### 3. **Code Reusability** +- Services can be used from multiple routers +- Background tasks can use services directly +- CLI commands can leverage the same business logic + +### 4. **Maintainability** +- Business logic changes only need to be made in one place +- Clear boundaries between different layers +- Easier to understand and modify code + +### 5. **Error Handling** +- Domain-specific exceptions in services +- Consistent HTTP error mapping in routers +- Better error messages and logging + +## Implementation Guide + +### Step 1: Create Service Class + +```python +# product_service.py +class ProductService: + def __init__(self): + self.gtin_processor = GTINProcessor() + self.price_processor = PriceProcessor() + + def create_product(self, db: Session, product_data: ProductCreate) -> Product: + # Business logic here + # Raise ValueError for business logic errors + # Return domain objects + pass + +# Create singleton instance +product_service = ProductService() +``` + +### Step 2: Refactor Router + +```python +# products.py +from product_service import product_service + +@router.post("/products", response_model=ProductResponse) +def create_product( + product: ProductCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + try: + result = product_service.create_product(db, product) + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error creating product: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") +``` + +### Step 3: Error Handling Pattern + +**Service Layer Exceptions:** +- `ValueError`: Business logic validation errors → HTTP 400 +- `PermissionError`: Access control errors → HTTP 403 +- Custom exceptions for specific domain errors + +**Router Error Translation:** +```python +try: + result = service.method(db, data) + return result +except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) +except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) +except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") +``` + +## Testing Strategy + +### Service Layer Tests + +```python +# test_product_service.py +class TestProductService: + def setup_method(self): + self.service = ProductService() + + def test_create_product_success(self, db): + product_data = ProductCreate(product_id="TEST001", title="Test Product") + result = self.service.create_product(db, product_data) + + assert result.product_id == "TEST001" + assert result.title == "Test Product" + + def test_create_product_invalid_gtin(self, db): + product_data = ProductCreate(product_id="TEST001", gtin="invalid") + + with pytest.raises(ValueError, match="Invalid GTIN format"): + self.service.create_product(db, product_data) +``` + +### Router Tests + +```python +# test_products_api.py +def test_create_product_endpoint(self, client, auth_headers): + product_data = {"product_id": "TEST001", "title": "Test Product"} + response = client.post("/api/v1/products", headers=auth_headers, json=product_data) + + assert response.status_code == 200 + assert response.json()["product_id"] == "TEST001" + +def test_create_product_validation_error(self, client, auth_headers): + product_data = {"product_id": "TEST001", "gtin": "invalid"} + response = client.post("/api/v1/products", headers=auth_headers, json=product_data) + + assert response.status_code == 400 + assert "Invalid GTIN format" in response.json()["detail"] +``` + +### Test Organization + +``` +tests/ +├── test_services.py # Service layer unit tests +├── test_product_service.py # ProductService specific tests +├── test_stock_service.py # StockService specific tests +├── test_marketplace_service.py # MarketplaceService specific tests +├── test_products.py # Product API integration tests +├── test_stock.py # Stock API integration tests +└── test_marketplace.py # Marketplace API integration tests +``` + +## Usage Examples + +### Using Services in Background Tasks + +```python +# background_tasks.py +from product_service import product_service +from stock_service import stock_service + +async def process_marketplace_import(job_id: int, csv_data: str): + try: + for row in csv_data: + # Create product using service + product = product_service.create_product(db, product_data) + + # Update stock using service + if product.gtin: + stock_service.set_stock(db, stock_data) + + except Exception as e: + marketplace_service.update_job_status(db, job_id, "failed", error_message=str(e)) +``` + +### Using Services in CLI Commands + +```python +# cli_commands.py +from product_service import product_service + +def bulk_update_prices(csv_file: str): + """CLI command to bulk update product prices""" + for product_id, new_price in read_csv(csv_file): + try: + product_service.update_product( + db, product_id, + ProductUpdate(price=new_price) + ) + print(f"Updated {product_id}") + except ValueError as e: + print(f"Error updating {product_id}: {e}") +``` + +### Service Composition + +```python +# order_service.py (hypothetical) +from product_service import product_service +from stock_service import stock_service + +class OrderService: + def create_order(self, db: Session, order_data: OrderCreate) -> Order: + for item in order_data.items: + # Check product exists + product = product_service.get_product_by_id(db, item.product_id) + if not product: + raise ValueError(f"Product {item.product_id} not found") + + # Check stock availability + stock = stock_service.get_total_stock(db, product.gtin) + if stock["total_quantity"] < item.quantity: + raise ValueError(f"Insufficient stock for {item.product_id}") + + # Reserve stock + stock_service.remove_stock(db, StockAdd( + gtin=product.gtin, + location="RESERVED", + quantity=item.quantity + )) +``` + +## Best Practices + +### 1. Service Design Principles + +- **Single Responsibility**: Each service handles one domain +- **Dependency Injection**: Services should not create their own dependencies +- **Pure Functions**: Methods should be predictable and side-effect free when possible +- **Domain Exceptions**: Use meaningful exception types + +### 2. Error Handling + +```python +# Good: Specific domain exceptions +class ProductService: + def create_product(self, db: Session, product_data: ProductCreate) -> Product: + if self.product_exists(db, product_data.product_id): + raise ValueError(f"Product {product_data.product_id} already exists") + + if product_data.gtin and not self.is_valid_gtin(product_data.gtin): + raise ValueError("Invalid GTIN format") + + # Business logic... + +# Bad: Generic exceptions +def create_product(product_data): + if product_exists(product_data.product_id): + raise Exception("Error occurred") # Too generic +``` + +### 3. Testing Guidelines + +- **Test business logic in service tests** +- **Test HTTP behavior in router tests** +- **Use fixtures for common test data** +- **Mock external dependencies** + +### 4. Documentation + +- Document service methods with docstrings +- Include parameter types and return types +- Document exceptions that can be raised +- Provide usage examples + +```python +def create_product(self, db: Session, product_data: ProductCreate) -> Product: + """Create a new product with validation. + + Args: + db: Database session + product_data: Product creation data + + Returns: + Created Product instance + + Raises: + ValueError: If product_id already exists or GTIN is invalid + + Example: + >>> service = ProductService() + >>> product = service.create_product(db, ProductCreate( + ... product_id="ABC123", + ... title="Test Product", + ... gtin="1234567890123" + ... )) + """ +``` + +## Migration Guide + +### Migrating Existing Routers + +1. **Identify Business Logic**: Look for code that validates data, processes business rules, or manipulates domain objects + +2. **Create Service Class**: Extract business logic into service methods + +3. **Update Router**: Replace business logic with service calls and add error handling + +4. **Add Tests**: Create comprehensive tests for the service layer + +5. **Verify Compatibility**: Ensure API behavior remains the same + +### Example Migration + +**Before:** +```python +@router.post("/products") +def create_product(product: ProductCreate, db: Session = Depends(get_db)): + # Check if product exists + existing = db.query(Product).filter(Product.product_id == product.product_id).first() + if existing: + raise HTTPException(status_code=400, detail="Product already exists") + + # Validate GTIN + if product.gtin: + normalized_gtin = normalize_gtin(product.gtin) + if not normalized_gtin: + raise HTTPException(status_code=400, detail="Invalid GTIN") + product.gtin = normalized_gtin + + # Create product + db_product = Product(**product.dict()) + db.add(db_product) + db.commit() + db.refresh(db_product) + + return db_product +``` + +**After:** +```python +# Service +class ProductService: + def create_product(self, db: Session, product_data: ProductCreate) -> Product: + # Check if product exists + if self.product_exists(db, product_data.product_id): + raise ValueError("Product already exists") + + # Validate GTIN + if product_data.gtin: + normalized_gtin = self.gtin_processor.normalize(product_data.gtin) + if not normalized_gtin: + raise ValueError("Invalid GTIN") + product_data.gtin = normalized_gtin + + # Create product + db_product = Product(**product_data.dict()) + db.add(db_product) + db.commit() + db.refresh(db_product) + + return db_product + +# Router +@router.post("/products") +def create_product(product: ProductCreate, db: Session = Depends(get_db)): + try: + return product_service.create_product(db, product) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error creating product: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") +``` + +## Conclusion + +The service layer architecture provides a robust foundation for scalable FastAPI applications. By separating HTTP concerns from business logic, we achieve better maintainability, testability, and code reuse. This pattern scales well as applications grow and makes it easier to add new features while maintaining existing functionality. + +For questions or contributions to this architecture, please refer to the team documentation or create an issue in the project repository. \ No newline at end of file