Factus API implements clean architecture (also known as hexagonal architecture or ports and adapters pattern). This architectural style ensures that business logic is independent of external frameworks, databases, and APIs.
The domain layer contains business entities and defines contracts (interfaces) that outer layers must implement. It has no dependencies on external libraries.
# app/src/domain/models/auth_token.pyfrom pydantic import BaseModelclass AuthToken(BaseModel): """Pure domain model - no framework dependencies""" access_token: str token_type: str expires_in: int refresh_token: str | None = None
Key Insight: The domain layer defines what operations are needed (IAuthGateway) but not how they’re implemented. The implementation details live in the infrastructure layer.
The infrastructure layer implements the interfaces (ports) defined by the domain layer. This is where we interact with external APIs, databases, and frameworks.
Adapter (Implementation)
# app/src/infrastructure/gateways/factus_auth_gateway.pyimport httpxfrom app.src.domain.interfaces.auth_gateway import IAuthGatewayfrom app.src.domain.models.auth_token import AuthTokenfrom app.src.core.config import settingsclass FactusAuthGateway(IAuthGateway): """Adapter: implements the IAuthGateway port using httpx""" def __init__(self, base_url: str, client_id: str, client_secret: str): self.base_url = base_url.rstrip("/") self.client_id = client_id self.client_secret = client_secret async def authenticate(self, email: str, password: str) -> AuthToken: async with httpx.AsyncClient() as client: response = await client.post( f"{self.base_url}/oauth/token", data={ "grant_type": "password", "client_id": self.client_id, "client_secret": self.client_secret, "username": email, "password": password, }, headers={"Accept": "application/json"} ) if not response.is_success: raise Exception(self._parse_error(response, "Error de autenticación con Factus")) data = response.json() return AuthToken( access_token=data["access_token"], token_type=data["token_type"], expires_in=data["expires_in"], refresh_token=data.get("refresh_token") ) async def refresh_token(self, refresh_token: str) -> AuthToken: async with httpx.AsyncClient() as client: response = await client.post( f"{self.base_url}/oauth/token", data={ "grant_type": "refresh_token", "client_id": self.client_id, "client_secret": self.client_secret, "refresh_token": refresh_token, }, headers={"Accept": "application/json"} ) if not response.is_success: raise Exception(self._parse_error(response, "Error al refrescar el token de Factus")) data = response.json() return AuthToken( access_token=data["access_token"], token_type=data["token_type"], expires_in=data["expires_in"], refresh_token=data.get("refresh_token") ) def _parse_error(self, response: httpx.Response, default: str) -> str: try: error_data = response.json() return ( error_data.get("message") or error_data.get("error_description") or error_data.get("error") or default ) except Exception: return response.text or f"HTTP {response.status_code}"
# app/src/domain/interfaces/invoice_gateway.pyfrom abc import ABC, abstractmethodfrom app.src.domain.models.invoice import Invoice, InvoiceResponseclass IInvoiceGateway(ABC): @abstractmethod async def create_invoice(self, invoice: Invoice, token: str) -> InvoiceResponse: """Sends an invoice to the external API (Factus) and returns the validation response.""" pass @abstractmethod async def download_pdf(self, number: str, token: str) -> DownloadResponse: """Downloads the PDF of an invoice.""" pass @abstractmethod async def download_xml(self, number: str, token: str) -> DownloadResponse: """Downloads the XML of an invoice.""" pass
By defining interfaces in the domain layer, we can:
Mock gateways for testing
Swap implementations without changing business logic
Support multiple providers (e.g., different e-invoicing systems)