Files
clubber/openspec/changes/add-graphql-member-api/design.md
T
gurixandClaude f3f5e0282c feat: add OpenSpec proposal for GraphQL member API
Create comprehensive OpenSpec proposal for implementing a minimal GraphQL API
for member management with flexible validation requirements.

Key features:
- Only firstName required for member creation
- Conditional validation (email/phone format validated only when provided)
- Full CRUD operations via GraphQL
- SQLite database with Alembic migrations
- FastAPI + Strawberry GraphQL integration

Includes 4 capability specs:
- project-setup: Python project structure with uv dependency management
- database-layer: SQLAlchemy async models with nullable fields
- graphql-api: Strawberry schema with minimal required fields
- member-crud: Business logic with conditional validation

Implementation broken down into 20 discrete tasks across 5 phases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:29:06 +01:00

14 KiB

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

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

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:

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:

EMAIL_REGEX = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'

Phone validation pattern:

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

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

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

# 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

# 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

    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

    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

    {
      "data": {
        "createMember": {
          "id": "1",
          "firstName": "Jane",
          "email": "jane@example.com"
        }
      }
    }
    

Configuration Management

Decision: Environment-based configuration with sensible defaults

# 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