Skip to content

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())
- Bad:
# 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)