Architecture & Design Decisions¶
Current State¶
What's Good ✅¶
- Dependency Inversion for Storage
TokenStoreis an abstract interfaceGoogleAuthdepends on abstraction, not concrete SQLite-
Easy to add new storage backends (Secret Manager, Redis, etc.)
-
Clean Entity Separation
- Domain entities:
Message,Event,File,Worksheet -
Each has its own module with clear responsibilities
-
Hierarchical Exceptions
GSuiteError→AuthenticationError→TokenRefreshError-
Each exception carries relevant context (service, status_code, etc.)
-
Lazy Service Loading
- Services are created on first use, not at init
- Reduces startup time and unnecessary API calls
Areas for Improvement 🔧¶
1. Hard Coupling to Google API Client¶
Problem: Clients directly call build() from googleapiclient:
class Gmail:
@property
def service(self):
if self._service is None:
self._service = build("gmail", "v1", credentials=self.auth.credentials)
return self._service
Impact: - Can't test without mocking Google's library - Can't swap implementations (e.g., for offline mode) - Violates Dependency Inversion Principle
Solution: Inject a service factory:
from typing import Protocol, Callable
class GmailService(Protocol):
"""Protocol for Gmail API service."""
def users(self): ...
ServiceFactory = Callable[[], GmailService]
class Gmail:
def __init__(
self,
auth: GoogleAuth,
service_factory: ServiceFactory | None = None
):
self.auth = auth
self._service_factory = service_factory or self._default_factory
self._service = None
def _default_factory(self):
from googleapiclient.discovery import build
return build("gmail", "v1", credentials=self.auth.credentials)
@property
def service(self):
if self._service is None:
self._service = self._service_factory()
return self._service
2. Entities Coupled to Clients (Active Record Anti-pattern)¶
Problem: Entities hold references to their parent client:
@dataclass
class Message:
_gmail: Optional["Gmail"] = field(default=None, repr=False)
def mark_as_read(self) -> "Message":
if self._gmail:
self._gmail._modify_labels(self.id, remove=["UNREAD"])
return self
Impact: - Entities aren't pure data objects - Can't serialize/deserialize easily - Testing requires full client setup - Circular dependencies
Solution: Keep entities pure, use service methods:
# Pure entity - just data
@dataclass
class Message:
id: str
subject: str
# ... no _gmail reference
# Client handles actions
class Gmail:
def mark_as_read(self, message_id: str) -> None:
self._modify_labels(message_id, remove=["UNREAD"])
# Usage changes from:
msg.mark_as_read()
# To:
gmail.mark_as_read(msg.id)
Trade-off: The current fluent API (msg.mark_as_read().star()) is very ergonomic. A middle ground could be a separate MessageActions helper.
3. Parsing Logic Inside Clients¶
Problem: Each client has inline _parse_* methods:
class Gmail:
def _parse_message(self, data: dict) -> Message:
# 50+ lines of parsing logic
Impact: - Clients have multiple responsibilities (SRP violation) - Parsing logic isn't reusable - Hard to unit test parsing separately
Solution: Extract to dedicated parsers:
# gsuite_gmail/parser.py
class GmailParser:
@staticmethod
def parse_message(data: dict, include_body: bool = True) -> Message:
"""Parse Gmail API response to Message entity."""
...
@staticmethod
def parse_label(data: dict) -> Label:
...
# In client:
from gsuite_gmail.parser import GmailParser
class Gmail:
def _get_message_by_id(self, message_id: str) -> Message:
msg_data = self.service.users().messages().get(...).execute()
return GmailParser.parse_message(msg_data)
4. Inconsistent Error Handling¶
Problem: Each method handles HttpError differently:
# Calendar - returns None
def get_event(self, event_id: str) -> Event | None:
try:
...
except HttpError as e:
if e.resp.status == 404:
return None
raise
# Drive - returns bool
def trash(self, file_id: str) -> bool:
try:
...
except HttpError as e:
return False
Solution: Centralized error mapping:
# gsuite_core/errors.py
def map_http_error(error: HttpError, service: str) -> GSuiteError:
"""Map Google API HttpError to domain exception."""
status = error.resp.status
if status == 404:
return NotFoundError(service, "resource", str(error))
elif status == 403:
return PermissionDeniedError(service, "operation")
elif status == 429:
return RateLimitError(service)
else:
return APIError(str(error), service, status)
# Decorator for consistent handling
def api_call(service: str):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except HttpError as e:
raise map_http_error(e, service)
return wrapper
return decorator
# Usage:
class Gmail:
@api_call("gmail")
def get_message(self, message_id: str) -> Message:
...
5. Missing Configuration Options¶
Problem: Hardcoded values: - Timezone always "UTC" for new events - No request timeout configuration - No retry configuration
Solution: Extend Settings:
class Settings(BaseSettings):
# Existing
credentials_file: str = "credentials.json"
# Add
default_timezone: str = "UTC"
request_timeout: int = 30
max_retries: int = 3
retry_delay: float = 1.0
Recommended Refactoring Priority¶
Phase 1: Quick Wins (Low Risk) ✅ DONE¶
- ✅ Extract parsers to separate modules (Gmail, Calendar, Drive, Sheets)
- ✅ Add
@api_calldecorator for error handling with retry logic - ✅ Add configurable timeouts and retry settings
Phase 2: Structural (Medium Risk)¶
- ⬜ Add service factory injection (for easier testing)
- ⬜ Add optional pure-entity mode (no client references)
Phase 3: Advanced (Higher Risk)¶
- ⬜ Add repository pattern for data access
- ⬜ Add caching layer
- ⬜ Add async support
Testing Strategy¶
Current tests are mostly unit tests with mocked API responses. Improvements:
- Parser tests - Test parsing logic in isolation
- Integration tests - Use Google's API with test account (CI only)
- Contract tests - Verify our code matches Google's API schema
File Structure (Proposed)¶
packages/gmail/src/gsuite_gmail/
├── __init__.py
├── client.py # High-level Gmail client
├── parser.py # NEW: Parse API responses
├── message.py # Message entity
├── label.py # Label entity
├── query.py # Query builder
├── thread.py # Thread entity
└── errors.py # NEW: Gmail-specific error handling