Skip to content

DDD Service — External API Calls

How to integrate external APIs (stock exchanges, payment gateways, third-party services) using the ports-and-adapters pattern.

See also: Core concepts · Usage examples · Bank balance alert


Overview

When fetching data from external services, apply the same hexagonal pattern:

Layer Responsibility
Domain (ports.py) Define an interface describing what you need
Infrastructure Implement the adapter that calls the external API
Application (use_cases.py) Orchestrate by injecting the port

Example: Stock Exchange Historical Data

Domain Layer

modules/market_data/domain/entities.py

from dataclasses import dataclass
from datetime import date


@dataclass
class OHLCV:
    """Open-High-Low-Close-Volume candle."""
    symbol: str
    date: date
    open: float
    high: float
    low: float
    close: float
    volume: int

modules/market_data/domain/ports.py

from abc import ABC, abstractmethod
from datetime import date
from .entities import OHLCV


class StockDataPort(ABC):
    """Port for fetching historical market data."""

    @abstractmethod
    def get_historical_data(self, symbol: str, start: date, end: date) -> list[OHLCV]:
        """Fetch historical OHLCV data for a symbol."""

Infrastructure Layer

modules/market_data/infrastructure/stock_api_adapter.py

import requests
from datetime import date
from ..domain.entities import OHLCV
from ..domain.ports import StockDataPort


class AlphaVantageAdapter(StockDataPort):
    """Adapter for Alpha Vantage stock API."""

    def __init__(self, api_key: str):
        self._api_key = api_key
        self._base_url = "https://www.alphavantage.co/query"

    def get_historical_data(self, symbol: str, start: date, end: date) -> list[OHLCV]:
        params = {
            "function": "TIME_SERIES_DAILY",
            "symbol": symbol,
            "apikey": self._api_key,
            "outputsize": "full",
        }
        response = requests.get(self._base_url, params=params)
        response.raise_for_status()
        return self._parse_response(response.json(), symbol, start, end)

    def _parse_response(self, data: dict, symbol: str, start: date, end: date) -> list[OHLCV]:
        time_series = data.get("Time Series (Daily)", {})
        results = []
        for date_str, values in time_series.items():
            d = date.fromisoformat(date_str)
            if start <= d <= end:
                results.append(OHLCV(
                    symbol=symbol,
                    date=d,
                    open=float(values["1. open"]),
                    high=float(values["2. high"]),
                    low=float(values["3. low"]),
                    close=float(values["4. close"]),
                    volume=int(values["5. volume"]),
                ))
        return sorted(results, key=lambda x: x.date)

Application Layer

modules/market_data/application/use_cases.py

from datetime import date
from ..domain.ports import StockDataPort
from ..domain.entities import OHLCV


class GetHistoricalPrices:
    def __init__(self, stock_data: StockDataPort):
        self._stock_data = stock_data

    def execute(self, symbol: str, start: date, end: date) -> list[OHLCV]:
        return self._stock_data.get_historical_data(symbol, start, end)

Wiring it together

modules/market_data/main.py

import os
from datetime import date
from .infrastructure.stock_api_adapter import AlphaVantageAdapter
from .application.use_cases import GetHistoricalPrices


def run_demo():
    api_key = os.getenv("ALPHA_VANTAGE_API_KEY", "demo")

    adapter = AlphaVantageAdapter(api_key)
    get_prices = GetHistoricalPrices(adapter)

    prices = get_prices.execute(
        symbol="AAPL",
        start=date(2026, 1, 1),
        end=date(2026, 1, 31),
    )

    for candle in prices:
        print(f"{candle.date}: O={candle.open:.2f} H={candle.high:.2f} L={candle.low:.2f} C={candle.close:.2f}")


if __name__ == "__main__":
    run_demo()


Swapping providers

The beauty of this pattern — switch from Alpha Vantage to Yahoo Finance without touching use-cases:

class YahooFinanceAdapter(StockDataPort):
    """Alternative adapter using Yahoo Finance."""

    def get_historical_data(self, symbol: str, start: date, end: date) -> list[OHLCV]:
        # Different API, same interface
        import yfinance as yf
        ticker = yf.Ticker(symbol)
        df = ticker.history(start=start, end=end)
        return [
            OHLCV(
                symbol=symbol,
                date=idx.date(),
                open=row["Open"],
                high=row["High"],
                low=row["Low"],
                close=row["Close"],
                volume=int(row["Volume"]),
            )
            for idx, row in df.iterrows()
        ]
# Swap with zero changes to use-case
adapter = YahooFinanceAdapter()  # instead of AlphaVantageAdapter
get_prices = GetHistoricalPrices(adapter)

Testing with mocks

import pytest
from unittest.mock import Mock
from datetime import date
from modules.market_data.application.use_cases import GetHistoricalPrices
from modules.market_data.domain.entities import OHLCV


def test_get_historical_prices():
    mock_adapter = Mock()
    mock_adapter.get_historical_data.return_value = [
        OHLCV("AAPL", date(2026, 1, 15), 150.0, 155.0, 149.0, 154.0, 1000000)
    ]

    use_case = GetHistoricalPrices(mock_adapter)
    result = use_case.execute("AAPL", date(2026, 1, 1), date(2026, 1, 31))

    assert len(result) == 1
    assert result[0].symbol == "AAPL"
    mock_adapter.get_historical_data.assert_called_once_with(
        "AAPL", date(2026, 1, 1), date(2026, 1, 31)
    )

Benefits

Benefit Description
Isolation Domain stays pure and testable (no HTTP dependencies)
Swappability Easy to switch providers (Alpha Vantage → Yahoo Finance → Bloomberg)
Testability Mock the port in tests without hitting real APIs
Resilience Add retries, caching, circuit breakers in the adapter without touching domain