Skip to content

ADR-002: Pydantic vs Dict Standard

Status: Accepted
Date: 2026-01-11
Author: Architecture Team
Related: Contact Entity Consolidation, Agent Architecture


Context

The codebase inconsistently uses Pydantic models and plain dicts for data structures. This causes: - Runtime errors ('ProductivityContact' object has no attribute 'get') - Confusion about what type to expect - Defensive code that checks for both types

We need a clear standard for when to use each.


Decision

The Standard: Pydantic at Boundaries, TypedDict for State

Layer Use Rationale
API Routes (request/response) Pydantic BaseModel Validation, OpenAPI docs, type safety
External Providers (Google, O365, etc.) Pydantic BaseModel Typed contracts for external data
Agent State (LangGraph) TypedDict LangGraph compatibility, lightweight
Database Models SQLModel (Pydantic-based) Already standard, ORM integration
Internal Helpers Accept both, convert early Flexibility at boundaries

Implementation Pattern

1. Providers Return Pydantic

# ✅ External provider returns typed model
class ProductivityContact(BaseModel):
    display_name: str
    email_addresses: list[str]
    phones: list[str]
    job_title: str | None = None

async def search_contacts(query: str) -> list[ProductivityContact]:
    # ... fetch from Google/O365 ...
    return [ProductivityContact(**data) for data in results]

2. Helpers Accept Both, Convert Early

from pydantic import BaseModel
from typing import Any

def to_dict(obj: BaseModel | dict[str, Any]) -> dict[str, Any]:
    """Convert Pydantic model to dict, pass through dicts unchanged."""
    if isinstance(obj, BaseModel):
        return obj.model_dump()
    return obj

def evaluate_contact_match(
    resolved_name: str,
    search_results: list[ProductivityContact | dict[str, Any]],
) -> ContactMatchEvaluation:
    # Convert at entry point
    contacts = [to_dict(c) for c in search_results]

    # Rest of logic uses dicts consistently
    for contact in contacts:
        name = contact.get("display_name", "")
        # ...

3. Agent State Uses TypedDict

from typing import TypedDict, Any

class ProductivityAgentState(TypedDict):
    """Agent state - lightweight, LangGraph-compatible."""
    messages: list[dict[str, Any]]
    resolved_entities: list[dict[str, Any]] | None  # Dicts, not Pydantic
    current_plan: str | None
    # ...

4. API Routes Use Pydantic

from pydantic import BaseModel

class ContactSearchRequest(BaseModel):
    query: str
    limit: int = 10

class ContactSearchResponse(BaseModel):
    contacts: list[ProductivityContact]
    total: int

@router.post("/contacts/search", response_model=ContactSearchResponse)
async def search_contacts(request: ContactSearchRequest):
    # ...

Utility Function

Add this helper to backend/app/core/utils.py:

from pydantic import BaseModel
from typing import Any, TypeVar

T = TypeVar("T", bound=BaseModel)

def to_dict(obj: BaseModel | dict[str, Any]) -> dict[str, Any]:
    """
    Convert Pydantic model to dict, pass through dicts unchanged.

    Use at the entry point of helpers that need to work with both types.
    """
    if isinstance(obj, BaseModel):
        return obj.model_dump()
    return obj

def to_dict_list(items: list[BaseModel | dict[str, Any]]) -> list[dict[str, Any]]:
    """Convert a list of Pydantic models or dicts to list of dicts."""
    return [to_dict(item) for item in items]

Consequences

Positive

  • Clear contracts: Providers return typed models, helpers accept both
  • Type safety: Pydantic validation at boundaries catches errors early
  • Flexibility: Helpers work regardless of caller's data format
  • LangGraph compatibility: State uses TypedDict as recommended

Negative

  • Conversion overhead: Small performance cost for model_dump() calls
  • Two patterns to know: Developers must understand when to use each

Mitigations

  • Conversion is cheap (microseconds) and only at boundaries
  • This ADR documents the pattern clearly

Examples in Codebase

✅ Good: Provider with Pydantic

  • backend/app/api/services/agents/productivity_agent/models/productivity_contact.py

✅ Good: Helper accepting both

  • backend/app/api/services/agents/productivity_agent/nodes/productivity_tool_execution_node.py
  • _handle_contacts_disambiguation() converts Pydantic to dict

✅ Good: Agent state with TypedDict

  • backend/app/api/services/agents/productivity_agent/agent_state.py

References