ADR-002: System Architecture¶
Status: Implemented Date: 2024-06-10 Context: To support long-term scalability and maintainability, the team adopted Clean Architecture and Domain-Driven Design.
Decision: - The system is organized into Domain, Application, and Infrastructure layers. - Each layer has a clear responsibility and dependencies point inward. - Domain: business logic, entities, repository interfaces. - Application: use cases, DTOs, API endpoints. - Infrastructure: external integrations (Firestore, Twilio, etc.).
Examples: - Good:
# Domain Layer
class UserMapping:
"""Core domain entity for mapping WhatsApp numbers to users."""
def __init__(self, user_id: str, whatsapp_number: str):
self.user_id = user_id
self.whatsapp_number = whatsapp_number
class UserMappingRepository:
"""Repository interface defining data access contract."""
async def get_by_whatsapp_number(self, number: str) -> UserMapping | None:
raise NotImplementedError
# Application Layer
class LinkAccountUseCase:
"""Use case for linking WhatsApp numbers to user accounts."""
def __init__(self, repository: UserMappingRepository):
self.repository = repository
async def execute(self, user_id: str, whatsapp_number: str) -> bool:
mapping = UserMapping(user_id, whatsapp_number)
return await self.repository.create(mapping)
# Infrastructure Layer
class FirestoreUserMappingRepository(UserMappingRepository):
"""Firestore implementation of the repository interface."""
def __init__(self, db: firestore.AsyncClient):
self.db = db
self.collection = self.db.collection("whatsapp_user_mappings")
async def get_by_whatsapp_number(self, number: str) -> UserMapping | None:
doc = await self.collection.where("whatsapp_number", "==", number).get()
if not doc:
return None
return UserMapping.model_validate(doc.to_dict())
# Mixed concerns and violated layering
class UserService:
def __init__(self, db, twilio_client):
self.db = db
self.twilio_client = twilio_client
def link_account(self, user_id, whatsapp_number):
# Directly interacts with infrastructure
self.db.insert(user_id, whatsapp_number)
self.twilio_client.send_message(whatsapp_number, "Account linked!")
def process_message(self, message):
# Business logic mixed with infrastructure
user = self.db.get_user_by_whatsapp(message.from_number)
if not user:
self.twilio_client.send_message(message.from_number, "Please link your account")
return
# More mixed concerns...
Cross-Cutting Concerns: - Logging: Use dependency injection to provide loggers to each layer. Loggers should be injected through constructors and used consistently across all layers.
# Domain Layer
class UserMapping:
def __init__(self, user_id: str, whatsapp_number: str, logger: Logger):
self.user_id = user_id
self.whatsapp_number = whatsapp_number
self.logger = logger
def validate(self) -> bool:
self.logger.info("Validating user mapping",
extra={"user_id": self.user_id, "whatsapp": self.whatsapp_number})
# Validation logic
return True
# Application Layer
class LinkAccountUseCase:
def __init__(self, repository: UserMappingRepository, logger: Logger):
self.repository = repository
self.logger = logger
async def execute(self, user_id: str, whatsapp_number: str) -> bool:
self.logger.info("Linking account",
extra={"user_id": user_id, "whatsapp": whatsapp_number})
# Implementation
-
Metrics: Collect metrics at infrastructure boundaries. Use a metrics collector to track performance, usage, and errors at the edges of the system.
# Infrastructure Layer class FirestoreUserMappingRepository(UserMappingRepository): def __init__(self, db: firestore.AsyncClient, metrics: MetricsCollector): self.db = db self.metrics = metrics self.collection = self.db.collection("whatsapp_user_mappings") async def get_by_whatsapp_number(self, number: str) -> UserMapping | None: with self.metrics.timer("firestore.get_by_whatsapp"): doc = await self.collection.where("whatsapp_number", "==", number).get() self.metrics.increment("firestore.queries", tags={"operation": "get_by_whatsapp", "result": "found" if doc else "not_found"}) if not doc: return None return UserMapping.model_validate(doc.to_dict()) -
Error Handling: Define domain-specific exceptions in domain layer. Use a hierarchy of exceptions to represent different types of errors and handle them appropriately at each layer.
# Domain Layer class UserMappingError(Exception): """Base exception for user mapping errors.""" pass class UserNotFoundError(UserMappingError): """Raised when a user mapping cannot be found.""" pass class InvalidWhatsAppNumberError(UserMappingError): """Raised when a WhatsApp number is invalid.""" pass # Application Layer class LinkAccountUseCase: async def execute(self, user_id: str, whatsapp_number: str) -> bool: try: if not self._validate_whatsapp_number(whatsapp_number): raise InvalidWhatsAppNumberError(f"Invalid number: {whatsapp_number}") # Implementation except UserMappingError as e: self.logger.error("Failed to link account", extra={"error": str(e), "user_id": user_id}) raise -
Configuration: Inject configuration through constructors. Use strongly-typed configuration objects to ensure type safety and validation.
# Infrastructure Layer class TwilioMessagingService: def __init__(self, config: TwilioConfig): self.account_sid = config.account_sid self.auth_token = config.auth_token self.phone_number = config.phone_number self.client = Client(self.account_sid, self.auth_token) # Application Layer class SendMessageUseCase: def __init__(self, messaging_service: TwilioMessagingService, config: MessageConfig): self.messaging_service = messaging_service self.max_retries = config.max_retries self.retry_delay = config.retry_delay -
Dependency Injection: Use a module system to compose the application. Dependencies should be created and wired together in a single place, making the system's structure clear and maintainable.
# Application Layer class WhatsAppModule: def __init__(self, config: AppConfig): self.config = config self.db = self._create_db() self.messaging = self._create_messaging() self.repository = self._create_repository() self.use_cases = self._create_use_cases() def _create_db(self) -> firestore.AsyncClient: return firestore.AsyncClient( project=self.config.firestore_project, credentials=self.config.firestore_credentials ) def _create_messaging(self) -> TwilioMessagingService: return TwilioMessagingService(self.config.twilio) def _create_repository(self) -> UserMappingRepository: return FirestoreUserMappingRepository( self.db, metrics=self.config.metrics ) def _create_use_cases(self) -> dict: return { "link_account": LinkAccountUseCase( repository=self.repository, logger=self.config.logger ), "send_message": SendMessageUseCase( messaging_service=self.messaging, config=self.config.messaging ) }
Consequences: - Positive: - Code is modular and testable - New integrations can be added with minimal changes - Maintenance and onboarding are simplified - Business logic is isolated from infrastructure concerns - Testing is easier with clear boundaries
- Negative (if violated):
- Business logic becomes coupled to infrastructure
- Testing becomes difficult due to mixed concerns
- Changes in one area affect multiple layers
- New features require changes across layers
- Technical debt accumulates quickly
References:
- See whatsapp_restructure_scratch.md (Architecture Redesign, Core Components)
- See docs/dev_practices.md (Architecture Principles)