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¶
- Pydantic v2 Documentation
- LangGraph State Management
- Related issue: Contact disambiguation failing with
'ProductivityContact' object has no attribute 'get'