# Design Document: GraphQL Member API **Change ID:** `add-graphql-member-api` **Status:** Draft **Created:** 2025-11-20 ## Architecture Overview This change establishes a layered architecture following the pattern documented in project.md: ``` ┌─────────────────────────────────────────┐ │ API Layer (FastAPI) │ │ GraphQL Endpoint (/graphql) │ │ Strawberry Schema │ └─────────────────┬───────────────────────┘ │ ┌─────────────────▼───────────────────────┐ │ GraphQL Resolvers │ │ Query: getMember, listMembers │ │ Mutation: create, update, delete │ └─────────────────┬───────────────────────┘ │ ┌─────────────────▼───────────────────────┐ │ Service Layer (Optional) │ │ Business logic & validation │ └─────────────────┬───────────────────────┘ │ ┌─────────────────▼───────────────────────┐ │ Data Layer (SQLAlchemy) │ │ Member model, database sessions │ └─────────────────┬───────────────────────┘ │ ┌─────────────────▼───────────────────────┐ │ Database (SQLite) │ │ members table, alembic_version │ └─────────────────────────────────────────┘ ``` ## Key Design Decisions ### 1. Project Structure **Decision:** Use src/ layout with separate modules for each layer ``` src/ ├── __init__.py ├── main.py # FastAPI app, startup logic ├── config.py # Configuration management ├── database.py # Database connection, session factory ├── models/ │ ├── __init__.py │ └── member.py # SQLAlchemy Member model ├── schemas/ │ ├── __init__.py │ └── member.py # Strawberry GraphQL types └── resolvers/ ├── __init__.py └── member.py # GraphQL query/mutation resolvers ``` **Rationale:** - Clear separation of concerns - Easy to navigate and understand - Supports future growth (add events, payments, etc.) - Aligns with Python community best practices **Alternatives considered:** - Flat structure: Rejected, doesn't scale beyond ~5 files - Feature-based modules (members/, events/): Deferred until we have multiple features ### 2. Database Schema **Decision:** Single `members` table with embedded address fields ```sql CREATE TABLE members ( id INTEGER PRIMARY KEY AUTOINCREMENT, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100), street VARCHAR(200), apartment_number VARCHAR(20), zip VARCHAR(20), city VARCHAR(100), country VARCHAR(100), email VARCHAR(255), phone VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` **Rationale:** - Simple query performance (no joins needed) - Address is not a reusable entity in this domain - Satisfies all current requirements - Easy to migrate to separate table later if needed **Alternatives considered:** - Separate `addresses` table: Over-engineering for current needs - JSON column for address: Loses type safety and query capability - No apartment_number field: Addresses in many countries need this ### 3. GraphQL Schema Design **Decision:** Input types separate from output types ```graphql type Member { id: ID! firstName: String! lastName: String street: String apartmentNumber: String zip: String city: String country: String email: String phone: String createdAt: DateTime! updatedAt: DateTime! } input CreateMemberInput { firstName: String! lastName: String street: String apartmentNumber: String zip: String city: String country: String email: String phone: String } input UpdateMemberInput { id: ID! firstName: String lastName: String street: String apartmentNumber: String zip: String city: String country: String email: String phone: String } ``` **Rationale:** - Strawberry best practice - Input validation separate from output shape - Only firstName required in CreateMemberInput (enables minimal member creation) - Optional fields in both inputs enable partial/incremental data entry - Clear contract for API consumers **Alternatives considered:** - Single type for input/output: Doesn't work in GraphQL (different field requirements) - Nested Address input type: Adds complexity without clear benefit ### 4. Validation Strategy **Decision:** Conditional validation with minimal required fields 1. **GraphQL layer:** Type system enforces only firstName as required 2. **Python layer:** Conditional format validation (email/phone only when provided) 3. **Database layer:** SQLAlchemy constraints (only firstName NOT NULL, length limits on all) **Rationale:** - Minimal friction for initial member creation (only firstName required) - Validation still protects data quality when fields are provided - Supports incremental data entry workflows - GraphQL type system prevents invalid types - Python validation gives clear error messages for bad formats **Conditional validation logic:** ```python def validate_email(email: Optional[str]) -> None: """Only validate format if email is provided.""" if email is not None and email != "": if not EMAIL_REGEX.match(email): raise ValidationError(f"Invalid email format: {email}") def validate_phone(phone: Optional[str]) -> None: """Only validate format if phone is provided.""" if phone is not None and phone != "": if not PHONE_REGEX.match(phone): raise ValidationError(f"Invalid phone format: {phone}") ``` **Email validation pattern:** ```python EMAIL_REGEX = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' ``` **Phone validation pattern:** ```python PHONE_REGEX = r'^\+?[1-9]\d{1,14}$' # E.164 format (international standard) ``` **Alternatives considered:** - All fields required: Rejected, too restrictive for real-world data entry - No validation: Rejected, leads to bad data quality - Email/phone uniqueness: Explicitly out of scope (business decision) - Third-party validation library: Overkill for simple formats ### 5. Database Session Management **Decision:** Async SQLAlchemy sessions with dependency injection ```python async def get_db_session() -> AsyncGenerator[AsyncSession, None]: async with async_session_maker() as session: try: yield session await session.commit() except Exception: await session.rollback() raise finally: await session.close() ``` **Rationale:** - FastAPI's dependency injection handles lifecycle - Async/await throughout the stack (no blocking I/O) - Automatic commit/rollback - Easy to test (inject mock sessions) **Alternatives considered:** - Sync SQLAlchemy: Rejected, blocks event loop - Manual session management in resolvers: Error-prone, boilerplate - Context managers in each resolver: Doesn't compose well with FastAPI ### 6. Error Handling **Decision:** Raise GraphQL-friendly exceptions with clear messages ```python class MemberNotFoundError(Exception): """Raised when member ID doesn't exist""" pass # In resolver: if not member: raise MemberNotFoundError(f"Member with ID {id} not found") ``` **Rationale:** - Strawberry automatically converts to GraphQL errors - Clear error messages help API consumers - Exceptions are idiomatic Python **Alternatives considered:** - Return None for not found: Ambiguous (missing vs. error) - Custom error types in schema: Over-engineering for simple CRUD ### 7. Seed Data Strategy **Decision:** Idempotent seed script that checks before inserting ```python # scripts/seed.py async def seed_database(): async with async_session_maker() as session: # Check if sample member exists result = await session.execute( select(Member).where(Member.email == "jane.doe@example.com") ) if result.scalar_one_or_none(): print("Sample member already exists") return # Create sample member with minimal required data # Demonstrates that only firstName is required member = Member( first_name="Jane", last_name="Doe", email="jane.doe@example.com", # All other fields (street, zip, city, country, phone) are None ) session.add(member) await session.commit() ``` **Rationale:** - Safe to run multiple times (idempotent) - Separate script (not in application startup) - Easy to extend with more sample data **Alternatives considered:** - Fixture file (JSON/YAML): More complex to maintain - Migration with data: Mixing schema and data is anti-pattern - Application startup seed: Slows every startup unnecessarily ### 8. Testing Strategy **Decision:** Pytest with async support, focus on resolver tests ```python # tests/test_member_resolvers.py @pytest.mark.asyncio async def test_create_member(db_session): input_data = CreateMemberInput(...) member = await create_member(input_data, db_session) assert member.first_name == "John" assert member.email == "john@example.com" ``` **Rationale:** - Pytest is Python standard, excellent async support - Integration tests via resolver layer (end-to-end within backend) - Database fixtures for isolated tests - No need for GraphQL client in tests (test resolvers directly) **Test coverage targets:** - Resolvers: 100% (critical business logic) - Models: Covered by resolver tests - Validation: 100% (security-relevant) **Alternatives considered:** - GraphQL client tests: Slower, more brittle - Unit test every layer: Diminishing returns for CRUD - No tests: Rejected, testing is essential ## Data Flow Example: Create Member 1. **Client:** POST to `/graphql` with mutation ```graphql mutation { createMember(input: { firstName: "Jane" lastName: "Smith" # ... rest of fields }) { id firstName email } } ``` 2. **FastAPI:** Routes to Strawberry schema 3. **Strawberry:** Validates input against CreateMemberInput type 4. **Resolver:** `create_member(input, db_session)` - Validates email format with regex - Validates phone format with regex - Creates SQLAlchemy Member instance - Adds to session 5. **SQLAlchemy:** Generates INSERT statement ```sql INSERT INTO members (first_name, last_name, ...) VALUES (?, ?, ...) ``` 6. **Database:** Executes insert, returns generated ID 7. **Resolver:** Returns Member instance 8. **Strawberry:** Serializes Member to GraphQL response 9. **Client:** Receives JSON response ```json { "data": { "createMember": { "id": "1", "firstName": "Jane", "email": "jane@example.com" } } } ``` ## Configuration Management **Decision:** Environment-based configuration with sensible defaults ```python # src/config.py from pydantic_settings import BaseSettings class Settings(BaseSettings): database_url: str = "sqlite+aiosqlite:///./clubber.db" debug: bool = False class Config: env_file = ".env" settings = Settings() ``` **Rationale:** - 12-factor app principles - Easy to override for testing (TEST_DATABASE_URL) - Pydantic validation for config values - Sensible defaults for development ## Security Considerations **Current scope (v1):** - Input validation (SQL injection via ORM protection) - Email/phone format validation - No sensitive data exposure (all fields are readable) **Deferred to future changes:** - Authentication/authorization - Rate limiting - CORS configuration (localhost only) - GDPR data handling (right to erasure, data export) ## Performance Considerations **Current scope:** - Single-threaded SQLite (acceptable for <1000 members) - No pagination (listMembers returns all) - No caching layer - No query optimization **Future optimizations (when needed):** - Add pagination (offset/limit or cursor-based) - Migrate to PostgreSQL for concurrent writes - Add Redis caching for read-heavy queries - Optimize N+1 queries with DataLoader ## Migration Path (Future PostgreSQL) **Design ensures future migration:** - SQLAlchemy dialect-agnostic code - No SQLite-specific SQL - Async sessions work with asyncpg - Alembic migrations portable **Migration steps (future):** 1. Change DATABASE_URL to PostgreSQL 2. Run `alembic upgrade head` on PostgreSQL 3. Export SQLite data with `sqlite3 .dump` 4. Import to PostgreSQL with `psql` 5. Update deployment configuration ## Open Questions None at this stage. All design decisions are sufficient for v1 implementation. ## References - [FastAPI Dependency Injection](https://fastapi.tiangolo.com/tutorial/dependencies/) - [Strawberry GraphQL Best Practices](https://strawberry.rocks/docs/guides/best-practices) - [SQLAlchemy 2.0 Async](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) - [Alembic Tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html) - [E.164 Phone Number Format](https://en.wikipedia.org/wiki/E.164)