Parayla ilgili çoğu Django uygulaması, er ya da geç döviz kurlarına ihtiyaç duyar — üç para biriminde fatura kesin, Stripe ödemelerini ana paraya geri çevirin ya da ödeme sayfasında yerelleştirilmiş fiyat gösterin. Doğru cevap Django için bir döviz kuru API'si takmak ve FX'i çözülmüş bir problem gibi ele almaktır. Yanlış cevaplar: settings.py içinde kurları sabit kodlamak ya da her istekte upstream'e gitmektir.
Bu rehber, production-grade bir entegrasyonu baştan sona gezdiriyor. Django 5.x üzerinde Finexly API, sıcak okumalar için Django cache framework'ü, kalıcılık için bir model, planlı tazeleme için bir management command, kuruşları kayan noktada kaybetmemek için Decimal hassasiyet, Django Ninja üzerinden REST endpoint, htmx-destekli UI ve ağa hiç dokunmayan pytest + responses testleri kullanarak küçük bir döviz çevirici inşa edeceğiz. Sonunda elinizde her Django projesine takılabilir bir servis katmanı mimarisi olacak — SaaS faturalama, e-ticaret, muhasebe, paranın sınır geçtiği her yer.
Neden adanmış bir döviz API'si sabit kodlanmış kurları yener
settings.py içinde kur sabit kodlamak ilk yanlış cevap. İkincisi her view'da upstream'i çağırmak. Doğru kullanılan adanmış bir döviz API'si, sabit değerlerin veremeyeceği dört şey verir:
- İhtiyaç anında tazelik. Kurlar piyasa saatlerinde sürekli değişir. 24 saatlik bayat bir kur bile USD/JPY veya EUR/TRY gibi volatil çiftlerde %1–2 hareket edebilir — bir SaaS planının marjını silmek için yeter.
- Geniş kapsama. Finexly, gelişen piyasalar ve CBDC referans kurları dahil 170+ para birimini kapsar. Pek çok açık kaynak Django paketiyle gelen ECB beslemesi yaklaşık 32 majörü kapsar. Tek bir kullanıcı Arjantin pesosu veya Türk lirasıyla ödüyorsa, bu boşluk önemlidir.
- Tek bir sözleşme.
latest,historicalveconvertendpoint'lerinde tek bir JSON biçimi — üç farklı upstream beslemesini bantla birbirine yapıştırmak yok. - Tahmin edilebilir kota. Üzerine düşünebileceğiniz, dokümante edilmiş bir limit, "ECB IP'mizi çok poll ettiğimiz için engelledi" demek yerine.
Hâlâ seçenekleri tartıyorsanız, 2026 için ücretsiz vs ücretli döviz API karşılaştırması ve ExchangeRate-API vs CurrencyLayer vs Finexly yazıları alternatifleri detayıyla gezer.
Ne inşa edeceğiz
Beş işi iyi yapan, fikir sahibi küçük bir Django modülü fx:
- Tipli bir servis sınıfıyla Finexly'den herhangi bir baz para için en güncel kurları çeker.
- Sıcak okumaları Django cache'inde (production'da Redis, dev'de locmem) tutar.
- Her fetch'i bir
ExchangeRatemodeline kalıcı yazar; upstream bir an gitse bile ödeme çökmez. - Bir management command + cron (veya Celery beat) ile kurları planlı yeniler.
- Bir JSON dönüştürme endpoint'i ve htmx-destekli HTML formu sunar.
Mevcut herhangi bir Django projesine yeniden kullanılabilir bir app olarak takın.
Ön koşullar
Python 3.11+, Django 5.0+ ve bir Finexly API anahtarına ihtiyacınız var. Finexly kayıt sayfasından ücretsiz alın — ücretsiz katman ayda 1.000 istek verir, doğru cache'lerseniz bir MVP için fazlasıyla yeter.
Adım 1: Django projesini kurun
python -m venv .venv
source .venv/bin/activate
pip install "django>=5.0,<6.0" httpx python-decouple django-ninja
django-admin startproject site .
python manage.py startapp fxYeni app'i site/settings.py içindeki INSTALLED_APPS'e ekleyin:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"fx",
]requests yerine httpx kullanıyoruz çünkü tek bir istemciyle timeout'lar, httpx.HTTPTransport(retries=...) üzerinden retries ve daha sonra değiştirebileceğimiz bir async istemci sunuyor. Ortam değişkenleri için python-decouple, REST endpoint için django-ninja — ikisi de minimal ve yoldan çekiliyor.
Adım 2: Ortam değişkenlerini ayarlayın
Proje kökünde bir .env oluşturun:
DEBUG=True
SECRET_KEY=change-me-in-production
FINEXLY_API_KEY=your_finexly_api_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USDSonra site/settings.py'ye bağlayın:
from decouple import config
SECRET_KEY = config("SECRET_KEY")
DEBUG = config("DEBUG", default=False, cast=bool)
FINEXLY_API_KEY = config("FINEXLY_API_KEY")
FINEXLY_BASE_URL = config("FINEXLY_BASE_URL", default="https://finexly.com/api/v1")
FX_CACHE_TTL = config("FX_CACHE_TTL", default=900, cast=int)
FX_DEFAULT_BASE = config("FX_DEFAULT_BASE", default="USD")
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
}
}Dev'de Redis yoksa RedisCache'i LocMemCache ile değiştirin — kodun geri kalanı umursamıyor.
15 dakikalık TTL (900 saniye) çoğu tüketici-yönelik uygulama için makul bir varsayılan. Yüksek frekanslı işlem için sub-saniye gerekir; muhasebe raporları için 6 saatlik TTL bile iyi. Önbellekleme ve hata yönetimi en iyi uygulamaları yazımız kendi durumunuz için doğru sayıyı seçme yolunu tartışır.
Adım 3: ExchangeRate modeli
Kalıcılık önemli, çünkü upstream'e ulaşılamadığında bir fallback ve finans "3 Nisan'daki o faturada hangi kuru kullandık?" diye sorduğunda denetim izi sağlar. fx/models.py oluşturun:
from decimal import Decimal
from django.db import models
class ExchangeRate(models.Model):
base = models.CharField(max_length=3, db_index=True)
quote = models.CharField(max_length=3, db_index=True)
rate = models.DecimalField(max_digits=20, decimal_places=10)
fetched_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
indexes = [
models.Index(fields=["base", "quote", "-fetched_at"]),
]
constraints = [
models.CheckConstraint(
check=models.Q(rate__gt=Decimal("0")),
name="rate_positive",
),
]
def __str__(self) -> str:
return f"{self.base}/{self.quote} = {self.rate} @ {self.fetched_at:%Y-%m-%d %H:%M}"İki tasarım kararına dikkat. Birincisi, FloatField değil DecimalField — kayan nokta hatası binlerce dönüşümde birikir ve fatura başına 0,03 $ sapan defterleri finansa anlatan mühendis olmak istemezsiniz. On ondalık basamak gösterim için fazla, FX depolama için standarttır. İkincisi, (base, quote, -fetched_at) birleşik dizin "bana en son USD/EUR kurunu ver" sorgusunu sort yerine tek bir B-tree seek'e çevirir.
Migration'ı çalıştırın:
python manage.py makemigrations fx
python manage.py migrateAdım 4: Servis katmanı
Servis sınıfı, upstream ile konuşan tüm mantığın yaşadığı yerdir. View'lar asla doğrudan httpx çağırmaz. fx/services.py oluşturun:
import logging
from decimal import Decimal
from typing import Mapping
import httpx
from django.conf import settings
from django.core.cache import cache
logger = logging.getLogger(__name__)
class ExchangeRateError(Exception):
"""Upstream fetch failed and no usable fallback exists."""
class ExchangeRateService:
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
cache_ttl: int | None = None,
default_base: str | None = None,
) -> None:
self.api_key = api_key or settings.FINEXLY_API_KEY
self.base_url = base_url or settings.FINEXLY_BASE_URL
self.cache_ttl = cache_ttl or settings.FX_CACHE_TTL
self.default_base = default_base or settings.FX_DEFAULT_BASE
def latest(self, base: str | None = None) -> Mapping[str, Decimal]:
base = (base or self.default_base).upper()
cache_key = f"fx:latest:{base}"
cached = cache.get(cache_key)
if cached is not None:
return cached
rates = self._fetch_latest(base)
cache.set(cache_key, rates, self.cache_ttl)
self._persist(base, rates)
return rates
def convert(self, amount: Decimal, frm: str, to: str) -> Decimal:
frm, to = frm.upper(), to.upper()
if frm == to:
return amount
rates = self.latest(frm)
if to not in rates:
raise ExchangeRateError(f"Currency {to} not found in {frm} rates")
return (amount * rates[to]).quantize(Decimal("0.01"))
def _fetch_latest(self, base: str) -> dict[str, Decimal]:
url = f"{self.base_url}/latest"
headers = {"Authorization": f"Bearer {self.api_key}"}
transport = httpx.HTTPTransport(retries=2)
try:
with httpx.Client(transport=transport, timeout=8.0) as client:
response = client.get(url, params={"base": base}, headers=headers)
response.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("Finexly fetch failed for %s: %s", base, exc)
fallback = self._fallback_from_db(base)
if fallback is not None:
return fallback
raise ExchangeRateError(str(exc)) from exc
payload = response.json()
rates = payload.get("rates")
if not isinstance(rates, dict):
raise ExchangeRateError("Malformed response: missing 'rates'")
return {quote: Decimal(str(rate)) for quote, rate in rates.items()}
def _fallback_from_db(self, base: str) -> dict[str, Decimal] | None:
from .models import ExchangeRate
recent = (
ExchangeRate.objects.filter(base=base)
.order_by("quote", "-fetched_at")
.distinct("quote")
)
rates = {row.quote: row.rate for row in recent}
return rates or None
def _persist(self, base: str, rates: Mapping[str, Decimal]) -> None:
from .models import ExchangeRate
ExchangeRate.objects.bulk_create(
[ExchangeRate(base=base, quote=q, rate=r) for q, r in rates.items()],
batch_size=200,
)Burada çok şey oluyor, dağıtalım. latest() tek genel okuma yoludur: cache'i dene, upstream'e geri düş, dönerken kalıcı yaz. convert() aynı para birimi sınır durumunu ele alır (1 ile gereksiz çarpıp yuvarlama riski almayın) ve sonucu iki ondalığa quantize eder — bu bir sunum tercihi; depolama için daha fazla hassasiyet tutun. _fallback_from_db production emniyet ağıdır: Finexly 503 döndürürse, kendi DB'mizden quote başına en yeni kuru sunarız. Ödeme üçüncü taraf sorunu yüzünden çökmez.
httpx.HTTPTransport üzerindeki retries=2, geçici 502/503'lerin büyük çoğunluğunu otomatik yakalar. 8 saniyelik timeout muhafazakar — 5 saniyenin altı kıtaları aşıyorsanız çok dar, 10 saniyenin üstü titrek bir upstream gecikme bütçenizi mahveder.
Adım 5: Dönüştürme view'i
Şimdi view'lar. fx/views.py oluşturun:
from decimal import Decimal, InvalidOperation
from django.http import HttpRequest, JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from .services import ExchangeRateError, ExchangeRateService
@require_http_methods(["GET", "POST"])
def convert_view(request: HttpRequest):
context: dict = {"result": None}
if request.method == "POST":
service = ExchangeRateService()
try:
amount = Decimal(request.POST.get("amount", "0"))
frm = request.POST.get("from", "USD")
to = request.POST.get("to", "EUR")
converted = service.convert(amount, frm, to)
context.update({"result": converted, "from": frm, "to": to, "amount": amount})
except (InvalidOperation, ExchangeRateError) as exc:
context["error"] = str(exc)
template = "fx/_result.html" if request.headers.get("HX-Request") else "fx/convert.html"
return render(request, template, context)HX-Request başlığını kontrol etmek htmx paternidir: aynı view, iki şablon. Normal bir POST tüm sayfayı; htmx-tetikli bir POST yalnızca sonuç parçacığını döndürür ve htmx onu DOM'a sokuştur. Kullanıcı dönüştürülmüş miktarı yerinde belirir görür, yazdığınız JavaScript miktarı sıfır.
Adım 6: htmx şablonları
fx/templates/fx/convert.html oluşturun:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Currency Converter</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 4rem auto; }
input, select, button { padding: 0.5rem; font-size: 1rem; }
.result { margin-top: 1rem; font-size: 1.4rem; }
</style>
</head>
<body>
<h1>Currency Converter</h1>
<form hx-post="{% url 'fx:convert' %}" hx-target="#result" hx-swap="innerHTML">
{% csrf_token %}
<input type="number" name="amount" step="0.01" required placeholder="Amount">
<input type="text" name="from" maxlength="3" required placeholder="USD" value="USD">
<input type="text" name="to" maxlength="3" required placeholder="EUR" value="EUR">
<button type="submit">Convert</button>
</form>
<div id="result">{% include "fx/_result.html" %}</div>
</body>
</html>Kısmi fx/templates/fx/_result.html:
{% if error %}
<p class="error">{{ error }}</p>
{% elif result %}
<p class="result">{{ amount }} {{ from }} = <strong>{{ result }} {{ to }}</strong></p>
{% endif %}fx/urls.py içinde bağlayın:
from django.urls import path
from . import views
app_name = "fx"
urlpatterns = [
path("convert/", views.convert_view, name="convert"),
]site/urls.py içinde:
from django.urls import include, path
urlpatterns = [
path("", include("fx.urls")),
]Adım 7: Django Ninja ile REST endpoint
JSON tüketicileri için — mobil uygulama, arka plan worker'ı, frontend SPA — tipli bir endpoint sunun. fx/api.py oluşturun:
from decimal import Decimal
from ninja import NinjaAPI, Schema
from ninja.errors import HttpError
from .services import ExchangeRateError, ExchangeRateService
api = NinjaAPI(title="FX API")
class ConvertOut(Schema):
amount: Decimal
from_: str
to: str
rate: Decimal
result: Decimal
@api.get("/convert", response=ConvertOut)
def convert(request, amount: Decimal, from_: str, to: str):
service = ExchangeRateService()
try:
rates = service.latest(from_)
rate = rates[to.upper()]
result = service.convert(amount, from_, to)
except (ExchangeRateError, KeyError) as exc:
raise HttpError(502, f"FX unavailable: {exc}") from exc
return ConvertOut(amount=amount, from_=from_.upper(), to=to.upper(), rate=rate, result=result)site/urls.py'ye ekleyin:
from fx.api import api
urlpatterns = [
path("", include("fx.urls")),
path("api/", api.urls),
]Artık GET /api/convert?amount=100&from_=USD&to=EUR güçlü tipli bir JSON payload döndürür. Django Ninja /api/docs adresinde OpenAPI şemasını ücretsiz üretir — mobil takım sorular sormaya başladığında işe yarar.
Adım 8: Management command ile planlı tazeleme
Çoğu uygulama için akıllı patern, kullanıcılar gelmeden cache'i ısıtan periyodik bir arka plan tazelemesi'dir. fx/management/commands/refresh_rates.py oluşturun:
from django.core.management.base import BaseCommand
from fx.services import ExchangeRateError, ExchangeRateService
class Command(BaseCommand):
help = "Refresh exchange rates from Finexly for the configured base currencies."
def add_arguments(self, parser):
parser.add_argument("--bases", nargs="+", default=["USD", "EUR", "GBP"])
def handle(self, *args, **options):
service = ExchangeRateService()
for base in options["bases"]:
try:
rates = service.latest(base)
self.stdout.write(self.style.SUCCESS(
f"{base}: refreshed {len(rates)} rates"
))
except ExchangeRateError as exc:
self.stderr.write(self.style.ERROR(f"{base}: {exc}"))Django'nun komutu bulması için boş fx/management/__init__.py ve fx/management/commands/__init__.py da gerek.
cron (veya zaten Celery çalıştırıyorsanız Celery beat) ile planlayın:
*/15 * * * * cd /app && /app/.venv/bin/python manage.py refresh_rates --bases USD EUR GBP JPYHer 15 dakikada bir dört baz tazelenir. Her tazeleme bir upstream çağrısı — 4 çağrı × 96 (günlük aralık sayısı) = günde 384 çağrı, saatlik aralığa düşerseniz Finexly'nin aylık 1.000 ücretsiz limitine rahatça sığar. 15 dakikanın altında tazelik gerekirse fiyatlandırma planları sayfasından üst kademe kotalara bakın.
Adım 9: Ağa hiç dokunmayan testler
Servis katmanının tüm anlamı onu mock edebilmektir. responses ve pytest-django kurun:
pip install pytest pytest-django responsespytest.ini oluşturun:
[pytest]
DJANGO_SETTINGS_MODULE = site.settings
python_files = tests.py test_*.py *_tests.pySonra fx/tests/test_services.py:
from decimal import Decimal
import pytest
import responses
from django.core.cache import cache
from fx.services import ExchangeRateError, ExchangeRateService
@pytest.fixture(autouse=True)
def _clear_cache():
cache.clear()
yield
cache.clear()
@responses.activate
def test_latest_returns_rates_from_upstream(settings):
responses.add(
responses.GET,
f"{settings.FINEXLY_BASE_URL}/latest",
json={"base": "USD", "rates": {"EUR": 0.92, "GBP": 0.79}},
status=200,
)
service = ExchangeRateService()
rates = service.latest("USD")
assert rates["EUR"] == Decimal("0.92")
assert rates["GBP"] == Decimal("0.79")
@responses.activate
def test_convert_quantizes_to_cents(settings):
responses.add(
responses.GET,
f"{settings.FINEXLY_BASE_URL}/latest",
json={"base": "USD", "rates": {"EUR": Decimal("0.9234")}},
status=200,
)
service = ExchangeRateService()
result = service.convert(Decimal("100"), "USD", "EUR")
assert result == Decimal("92.34")
@responses.activate
def test_upstream_failure_raises_when_no_fallback(settings):
responses.add(
responses.GET,
f"{settings.FINEXLY_BASE_URL}/latest",
status=503,
)
service = ExchangeRateService()
with pytest.raises(ExchangeRateError):
service.latest("USD")
def test_same_currency_returns_input():
service = ExchangeRateService()
assert service.convert(Decimal("100"), "USD", "USD") == Decimal("100")Çalıştırın:
pytest -qDört test, sıfır ağ çağrısı, toplam sub-saniye çalışma süresi. Tüm mesele bu.
Production notları
FX canlıyken Django uygulamalarını ısıran birkaç şey:
- DB kalıcılığı sizin fallback'inizdir.
_persistadımını atlarsanız, upstream kesintisi ödemeyi düşürür. Atlamayın. - Cache stampede. 100 istek soğuk bir cache'e aynı anda vurursa, 100 upstream çağrısı atarsınız.
django-redis'inget_or_set'ini kilitle kullanın veya trafiğiniz düşükse trade-off'u kabullenin. - Hafta sonu kur sürüklenmesi. FX piyasaları kapanır. Çoğu API (Finexly dahil) tüm hafta sonu boyunca Cuma kapanışını sunar; kurlar pazartesi açılışına kadar hareket etmez. Hafta sonu işlemlerini takas ediyorsanız politikayı belgeleyin.
- Denetim izi.
ExchangeRatetablosu hızlı büyür. Son 7 günepartialindeks ekleyin veya uyum izin veriyorsa daha eski satırları aylık özete sarın.
Bu paternleri daha derinden incelemek isterseniz, döviz API caching ve hata yönetimi rehberi stampede koruması, circuit breaker'lar ve zarif düşüşü detaylıca işler.
Sık sorulan sorular
Django ile en iyi hangi döviz kuru API'si çalışır?
Herhangi bir REST API çalışır, ama ergonomi farklıdır. Finexly, Python dict'ine temiz eşlenen düz bir {rates: {...}} formu döndürür, 170+ para birimini destekler ve aylık 1.000 çağrılık ücretsiz tier ile gelir — MVP için yeter. Fixer ve Open Exchange Rates gibi alternatiflerin farklı kota yapıları vardır; karşılaştırma yazımız trade-off'ları gezer.
Django'da kurlar için Decimal mı Float mı?
Depolama için her zaman DecimalField, iş mantığında her zaman Decimal. Float, binlerce dönüşümde birikip kullanıcıya görünür hatalara dönen yuvarlama hataları getirir. Performans bedeli ihmal edilebilir.
Kurları ne sıklıkla tazelemeli? Kullanım durumuna bağlı. Yerelleştirilmiş fiyat gösteren tüketici uygulamaları için 15 dakikada bir yeter. Muhasebe raporları için saatlik iyidir. Trading veya gerçek zamanlı hedging için sub-saniye WebSocket beslemeleri gerekir — bu patern için REST vs WebSocket yazımıza bakın.
Django REST Framework ile Django Ninja yerine bunu kullanabilir miyim?
Evet. Servis katmanı framework-bağımsızdır. fx/api.py'yi ExchangeRateService().convert(...) çağırıp sonucu serileştiren bir DRF APIView ile değiştirin. Model, servis, management command ve testler aynen kalır.
API yanıtında olmayan para birimlerini nasıl ele alırım?
İki patern. Ya açık bir ExchangeRateError fırlatıp kullanıcıya gösterin (daha temiz UX), ya da USD üzerinden bir cross-rate'e geri düşün (daha hoşgörülü ama hata ayıklaması zor). Çoğu uygulama için erken fırlatma doğru seçim.
Finexly'yi ücretsiz deneyin
Django projenize gerçek zamanlı döviz kurlarını entegre etmeye hazır mısınız? Ücretsiz Finexly API anahtarınızı alın — kredi kartı gerekmez. Ayda 1.000 ücretsiz istek, 170+ para birimi için gerçek zamanlı kurlar ve Python'a temiz eşlenen bir JSON sözleşmesiyle başlayın. İhtiyaç doğdukça fiyatlandırma planlarımızla ölçeklendirin.
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →