# Services Layer Business logic layer between API endpoints and data access. ## Architecture Principles - **Stateless**: Config comes from request, not server state - **DI Pattern**: Dependencies injected via constructor - **Repository Protocol**: Data access through abstract protocols - **Offline Fork Ready**: No hard dependencies on Postgres/Redis ## Services Overview | Service | Responsibility | Dependencies | |---------|---------------|--------------| | `CardService` | Card definition lookup (singleton) | JSON files | | `CollectionService` | User card ownership CRUD | CollectionRepository, CardService | | `DeckService` | Deck CRUD + validation | DeckRepository, CollectionRepository, CardService | | `DeckValidator` | Pure deck validation functions | CardService (for lookups) | | `UserService` | User profile management | Direct DB access | | `GameStateManager` | Game state (Redis + Postgres) | Redis, AsyncSession | | `JWTService` | Token creation/verification | Settings | | `TokenStore` | Refresh token storage | Redis | ## Key Patterns ### Config from Request The backend is stateless. Rules come from the request via config objects like `DeckConfig`: ```python # Correct - config from caller (frontend provides rules) await deck_service.create_deck( user_id=user.id, cards=request.cards, deck_config=request.deck_config, # From request body max_decks=user.max_decks, ) # Wrong - baked-in config class DeckService: def __init__(self, config: DeckConfig): # Don't do this self._config = config ``` This enables different game modes (campaign, freeplay, custom) without server-side config. ### UNSET Sentinel For nullable fields that can be explicitly cleared, use `UNSET` to distinguish between "not provided" and "set to null": ```python from app.repositories.protocols import UNSET async def update_deck( self, deck_id: UUID, name: str | None = None, # None = don't change description: str | None = UNSET, # UNSET = keep, None = clear, str = set ) -> DeckEntry: ... if description is not UNSET: # User explicitly provided a value (could be None to clear) record.description = description ``` API layer usage with Pydantic: ```python # Check if field was in the request payload description = request.description if "description" in request.model_fields_set else UNSET ``` ### Repository Injection Services use constructor injection with repository protocols: ```python class DeckService: def __init__( self, deck_repository: DeckRepository, # Protocol, not implementation card_service: CardService, collection_repository: CollectionRepository | None = None, ) -> None: self._deck_repo = deck_repository self._card_service = card_service self._collection_repo = collection_repository ``` Benefits: - **Testability**: Inject mock repositories - **Offline Fork**: Swap PostgresRepository for LocalRepository - **Decoupling**: Services don't know about SQLAlchemy ### Pure Validation Functions Validation logic is extracted into pure functions for reuse: ```python # app/services/deck_validator.py def validate_deck( cards: dict[str, int], energy_cards: dict[str, int], deck_config: DeckConfig, card_lookup: Callable[[str], CardDefinition | None], owned_cards: dict[str, int] | None = None, ) -> ValidationResult: """Pure function - all inputs from caller.""" ... ``` ## Service Details ### CardService Singleton that loads card definitions from JSON files at startup. ```python from app.services.card_service import get_card_service card_service = get_card_service() card = card_service.get_card("a1-001-bulbasaur") all_cards = card_service.get_all_cards() ``` ### CollectionService Manages user card ownership with source tracking. ```python # Add cards to collection await collection_service.add_cards( user_id=user.id, card_definition_id="a1-001-bulbasaur", quantity=4, source=CardSource.BOOSTER, ) # Get owned cards for deck validation owned = await collection_service.get_owned_cards_dict(user.id) ``` ### DeckService Deck CRUD with validation and ownership checks. ```python # Create deck - validates and stores result deck = await deck_service.create_deck( user_id=user.id, name="My Deck", cards={"a1-001-bulbasaur": 4, ...}, energy_cards={"grass": 14, "colorless": 6}, deck_config=DeckConfig(), max_decks=user.max_decks, validate_ownership=True, # Campaign mode ) # Deck can be saved even if invalid (for work-in-progress) if not deck.is_valid: print(deck.validation_errors) ``` ### GameStateManager Hybrid storage: Redis for fast access, Postgres for durability. ```python manager = GameStateManager(redis, db_session) # Save to Redis (fast, every action) await manager.save_to_redis(game_id, game_state) # Persist to Postgres (at turn boundaries) await manager.persist_to_db(game_id, game_state) ``` ## Testing | Directory | Purpose | Database | |-----------|---------|----------| | `tests/unit/services/` | Pure unit tests, mocked deps | No | | `tests/services/` | Integration tests | Yes (testcontainers) | ### Unit Test Example ```python def test_validate_deck_requires_basic_pokemon(): """Test that decks must contain at least one Basic Pokemon.""" result = validate_deck( cards={"trainer-card": 40}, # No Pokemon energy_cards={"colorless": 20}, deck_config=DeckConfig(), card_lookup=mock_lookup, ) assert not result.is_valid assert "Basic Pokemon" in str(result.errors) ``` ### Integration Test Example ```python @pytest.mark.asyncio async def test_create_deck_enforces_limit(db_session, deck_service): """Test that free users are limited to 5 decks.""" user = await UserFactory.create(db_session, max_decks=5) # Create 5 decks for i in range(5): await deck_service.create_deck(user_id=user.id, ...) # 6th should fail with pytest.raises(DeckLimitExceededError): await deck_service.create_deck(user_id=user.id, ...) ``` ## See Also - `app/repositories/protocols.py` - Repository interfaces and DTOs - `app/repositories/postgres/` - PostgreSQL implementations - `app/core/config.py` - DeckConfig and RulesConfig - `CLAUDE.md` - Architecture guidelines