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>
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
addressestable: 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
- GraphQL layer: Type system enforces only firstName as required
- Python layer: Conditional format validation (email/phone only when provided)
- 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
-
Client: POST to
/graphqlwith mutationmutation { createMember(input: { firstName: "Jane" lastName: "Smith" # ... rest of fields }) { id firstName email } } -
FastAPI: Routes to Strawberry schema
-
Strawberry: Validates input against CreateMemberInput type
-
Resolver:
create_member(input, db_session)- Validates email format with regex
- Validates phone format with regex
- Creates SQLAlchemy Member instance
- Adds to session
-
SQLAlchemy: Generates INSERT statement
INSERT INTO members (first_name, last_name, ...) VALUES (?, ?, ...) -
Database: Executes insert, returns generated ID
-
Resolver: Returns Member instance
-
Strawberry: Serializes Member to GraphQL response
-
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):
- Change DATABASE_URL to PostgreSQL
- Run
alembic upgrade headon PostgreSQL - Export SQLite data with
sqlite3 .dump - Import to PostgreSQL with
psql - Update deployment configuration
Open Questions
None at this stage. All design decisions are sufficient for v1 implementation.