services.py در جنگو: چرا منطق کسبوکار باید از ویو جدا شود؟
چرا services.py در جنگو یک ضرورت معماری است و چطور آن را بدون پیچیدگی اضافی پیادهسازی کنیم؟
چند سال پیش یک فروشگاه اینترنتی مبتنی بر جنگو را تحویل دادم که همه چیز در آن مرتب به نظر میرسید: تستها سبز، ساختار پوشهها تمیز و کدها با flake8 بدون خطا. شش ماه بعد همان مشتری با یک درخواست ساده برگشت؛ اضافهکردن یک تخفیف فصلی. وقتی وارد کد شدم، متوجه شدم منطق محاسبهی قیمت در سه جا تکرار شده: داخل ویو تسویه، داخل متد save() مدل سفارش، و داخل یک سیگنال post_save. هر سه نسخه کمی با هم تفاوت داشتند. همانجا بود که تصمیم گرفتم به services.py بهعنوان یک لایهی مستقل، جدیتر از یک عادت سلیقهای نگاه کنم.
services.py چیست و چرا در معماری جنگو مطرح شد؟
جنگو بهطور پیشفرض یک معماری MVT (Model-View-Template) دارد. این معماری برای پروژههای کوچک و متوسط فوقالعاده کارآمد است، اما وقتی دامنهی کسبوکار پیچیده میشود، جای منطق کسبوکار مشخص نیست. نه مدل کاملاً مالک آن است، نه ویو. این ابهام، پروژه را به سمت دو الگوی ضدتوسعه میبرد: Fat View و Fat Model.
اصطلاح services.py یک الزام رسمی از سمت خود جنگو نیست؛ یک کنوانسیون جامعه است که از معماری لایهای (Layered Architecture) و الگوهای DDD (Domain-Driven Design) الهام گرفته. در این نگاه، هر use case یک تابع یا کلاس سرویس میشود که میتواند چند مدل، چند رپازیتوری و چند سرویس دیگر را با هم هماهنگ کند. اگر با مفهوم تمپلت تگ در جنگو آشنا هستی، services.py همان چیزی است که تمپلت تگ هرگز نباید باشد: محل منطق کسبوکار.
سرویس، لایهای نیست که برای «تمیزتر شدن» ساخته شود. سرویس، جوابی است به یک سؤال مشخص: این use case از کدام موجودیتها استفاده میکند و چه تضمینهایی باید داشته باشد؟
برای درک بهتر ریشهی این الگو، میتوان به مسیر تکامل معماری وب از جنگو تا الگوهای امروزی نگاه کرد. آنچه در همهی این معماریها مشترک است، یک اصل ساده است: مسئولیتها باید جایی زندگی کنند که قابل تست، قابل تعویض و قابل استدلال باشند.
مشکل fat view و fat model؛ ریشهی واقعی ماجرا
در پروژههای واقعی، fat viewها بهآرامی رشد میکنند. اول یک ویو ۲۰ خطی است. بعد ۵۰ خط میشود. بعد یک شرط اضافه میشود برای کاربران VIP، بعد ایمیل تأییدیه، بعد ثبت لاگ. سه ماه بعد، ویوی ۳۰۰ خطی داری که هیچکس جرات نمیکند دستش بزند. اگر با ساختار یک اپ ردیابی مثل اپ ردیابی بازدیدکنندگان کار کرده باشی، میدانی حتی یک ویوی ۴۰ خطی هم میتواند با اضافهشدن چند شرط به سرعت غیرقابل نگهداری شود.
سمت دیگر ماجرا، fat model است. تیمهایی که از fat view فرار میکنند، معمولاً به دام fat model میافتند. مدلی که ۴۰ متد دارد، به دیتابیس و شبکه و ایمیل و کش وابسته است و تستکردنش یک ماجرای چندساعته است. اگر روی مدلهایی مثل مدل Visitor و Visit متدهایی بنویسی که به سرویس ایمیل وابستهاند، در عمل مدل را به یک لایهی چسبنده تبدیل کردهای که همه چیز را به هم میچسباند.
| نشانه | Fat View | Fat Model | راهحل سرویس |
|---|---|---|---|
| طول کد | ویوی ۲۰۰+ خط | مدل با ۳۰+ متد | تقسیم به use case |
| تست | نیاز به client و request | نیاز به دیتابیس واقعی | تست واحد مستقل |
| تکرار منطق | در چند ویو | در چند متد | یک نقطهی واحد |
| تغییر پذیری | ترس از شکستن | وابستگی چرخشی | تعویضپذیری |
ساختار پیشنهادی و اصول حاکم بر لایهی سرویس
قبل از نوشتن اولین سرویس، سه اصل را در ذهن داشته باش:
۱. یک سرویس، یک use case. اگر اسم تابع سرویس تو با «و» تمام میشود (مثلاً «سفارش ثبت کن و ایمیل بفرست») یعنی دو use case داری. آنها را جدا کن و از یک سرویس هماهنگکننده (orchestrator) استفاده کن.
۲. سرویس نباید به request وابسته باشد. هر جا request به سرویس تزریق میشود، یک علامت هشدار است. request را در ویو استخراج کن و فقط دادههای پاکشده را به سرویس بده. این قاعده، تست سرویسها را بدون client ممکن میکند. در ساختارهایی مثل Middleware سفارشی این وسوسه زیاد است که request را همهجا پاس بدهی، ولی در لایهی سرویس باید مقاومت کنی.
۳. سرویس نباید response برگرداند. سرویس داده برمیگرداند، نه JsonResponse و نه HttpResponse. این اصل، مرز بین لایهی ارائه و لایهی کسبوکار را حفظ میکند. اگر سرویسهایت را به یک اپ دیگر منتقل کردی، نباید هیچ importی از django.http داشته باشند.
معیار ساده برای تشخیص سرویس خوب: اگر بتوانی سرویس را در یک اسکریپت خط فرمان یا یک management command مثل کامند پاکسازی داده بدون هیچ تغییری صدا بزنی، سرویس را درست نوشتهای.
تفاوت services با utils و helpers
این سؤال در اکثر تیمهای جنگو مطرح میشود. تفاوت را میتوان در سه محور خلاصه کرد:
| محور | utils.py / helpers.py | services.py |
|---|---|---|
| دامنه | عمومی، مستقل از دامنه | وابسته به یک use case |
| حالت | معمولاً بدون حالت (stateless) | ممکن است حالت داشته باشد |
| وابستگی | تقریباً هیچ | به مدلها، رپازیتوری، ایمیل |
| تست | تست خالص | نیاز به mock یا دیتابیس |
تابعی مثل slugify_fa(text) که فقط یک رشته را تبدیل میکند، جای درستش utils است. تابعی مثل register_user(email, password) که چند مدل و چند مرحله دارد، سرویس است. اگر این دو را در یک فایل قاطی کنی، همان آشفتگی قبلی برمیگردد، فقط این بار در یک فایل با اسم شیک.
یک مثال عملی از همین پروژهی آنالیتیکس: تابعی که User-Agent را پارس میکند و مرورگر و سیستمعامل را از آن بیرون میکشد، ذاتاً یک helper است. اما سرویسی که یک بازدید جدید را در دیتابیس ثبت میکند و بازدیدهای قبلی همان سشن را میبندد، یک use case است. برای آنالیز User-Agent میتوانی به تشخیص بات از کاربر انسانی مراجعه کنی که همان منطق helper محور را نشان میدهد.
ساخت اولین سرویس در یک پروژهی واقعی
سناریو: یک فروشگاه اینترنتی میخواهیم که کاربر با کد تخفیف، سفارش ثبت میکند. سه مرحله دارد: اعتبارسنجی کد، محاسبهی قیمت نهایی، ثبت سفارش و ارسال ایمیل. کد fat view بدون سرویس به این شکل است:
def checkout_view(request):
cart = Cart.objects.get(user=request.user)
code = request.POST.get("discount_code")
discount = None
if code:
try:
discount = Discount.objects.get(code=code, is_active=True)
except Discount.DoesNotExist:
return JsonResponse({"error": "invalid"}, status=400)
if discount.expires_at and discount.expires_at < timezone.now():
return JsonResponse({"error": "expired"}, status=400)
if cart.total < discount.min_amount:
return JsonResponse({"error": "min_amount"}, status=400)
total = cart.total
if discount:
total = total - (total * discount.percent / 100)
order = Order.objects.create(
user=request.user,
total=total,
discount=discount,
)
for item in cart.items.all():
OrderItem.objects.create(order=order, product=item.product, qty=item.qty)
cart.items.all().delete()
cart.delete()
send_mail(
"Order placed",
f"Your order {order.id} placed",
"noreply@example.com",
[request.user.email],
)
return JsonResponse({"order_id": order.id})
این ویو مشکل دارد: نه تستپذیر است، نه قابل استفاده در API، نه قابل استفاده در یک management command برای بازپردازش سفارش. حالا نسخهی سرویسمحور:
# shop/services/checkout.py
from dataclasses import dataclass
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from shop.models import Cart, Order, OrderItem, Discount
from shop.services.mailer import send_order_confirmation
@dataclass
class CheckoutResult:
order_id: int
total: Decimal
discount_percent: int
class InvalidDiscountCode(ValidationError):
pass
class DiscountExpired(ValidationError):
pass
class CartBelowMinimum(ValidationError):
pass
def _resolve_discount(code: str, cart_total: Decimal) -> Discount | None:
if not code:
return None
try:
discount = Discount.objects.get(code=code, is_active=True)
except Discount.DoesNotExist:
raise InvalidDiscountCode("کد تخفیف نامعتبر است.")
if discount.expires_at and discount.expires_at < timezone.now():
raise DiscountExpired("کد تخفیف منقضی شده است.")
if cart_total < discount.min_amount:
raise CartBelowMinimum("مبلغ سبد کمتر از حد مجاز کد تخفیف است.")
return discount
def _apply_discount(total: Decimal, discount: Discount | None) -> Decimal:
if not discount:
return total
return total - (total * discount.percent / 100)
@transaction.atomic
def checkout_cart(user, discount_code: str = "") -> CheckoutResult:
cart = Cart.objects.select_for_update().get(user=user)
cart_total = cart.total
discount = _resolve_discount(discount_code, cart_total)
final_total = _apply_discount(cart_total, discount)
order = Order.objects.create(
user=user,
total=final_total,
discount=discount,
)
items = list(cart.items.all())
OrderItem.objects.bulk_create([
OrderItem(order=order, product=item.product, qty=item.qty)
for item in items
])
cart.items.all().delete()
cart.delete()
transaction.on_commit(lambda: send_order_confirmation(order.id))
return CheckoutResult(
order_id=order.id,
total=final_total,
discount_percent=(discount.percent if discount else 0),
)
حالا ویو به سادگی زیر میشود:
def checkout_view(request):
try:
result = checkout_cart(request.user, request.POST.get("discount_code", ""))
except ValidationError as e:
return JsonResponse({"error": str(e)}, status=400)
return JsonResponse({
"order_id": result.order_id,
"total": str(result.total),
})
سه چیز ارزشمند اینجا اتفاق افتاده. اول، منطق کسبوکار از لایهی ارائه جدا شده. دوم، سرویس میتواند در یک API، در یک API endpoint برای دریافت داده از مرورگر یا در یک management command بدون تغییر استفاده شود. سوم، transaction.on_commit تضمین میکند ایمیل فقط بعد از commit واقعی ارسال میشود، نه زمانی که هنوز ممکن است تراکنش rollback شود.
استفاده از
transaction.on_commitبرای side effectها یک قاعدهی طلایی است که در پروژههای واقعی جان بسیاری از سفارشها را نجات داده است.
نکتهی مهم دیگر: استفاده از select_for_update روی cart. این قفل، جلوی race condition در سناریوی خرید همزمان را میگیرد. اگر روی MySQL InnoDB کار میکنی، این قفل روی ردیف درست اعمال میشود.
مدیریت تراکنشها در لایهی سرویس
تراکنشها جایی هستند که سرویسها بیشترین ارزش را نشان میدهند. در جنگو، transaction.atomic میتواند بهصورت context manager یا decorator استفاده شود. دو نکتهی مهم:
نکتهی اول: تراکنش را در لایهی سرویس بگذار، نه در ویو. چون سرویس، مرز منطقی use case است. اگر تراکنش در ویو باشد و سرویس را در یک فایل دیگر صدا بزنی، ممکن است بدون تراکنش اجرا شود و نصف کار انجام شود.
نکتهی دوم: تراکنش را کوتاه نگه دار. هر I/O خارج از دیتابیس (HTTP، ایمیل، فراخوانی API) را از داخل تراکنش بیرون بکش. در پروژهای دیدم که یک تماس HTTP داخل تراکنش باعث شده بود یک سفارش ۸ ثانیه قفل شود. برای همین روشهای بهینه، در ساختارهایی که رویدادهای کاربر را از جاوااسکریپت دریافت میکنند، معمولاً write را از read جدا میکنیم.
تستنویسی سرویسها
مزیت واقعی سرویسها در لحظهی تست مشخص میشود. چون سرویس به request وابسته نیست، میتوانی بدون client تست کنی:
# tests/test_checkout.py
import pytest
from decimal import Decimal
from shop.services.checkout import checkout_cart, InvalidDiscountCode
@pytest.mark.django_db
def test_checkout_applies_valid_discount(user_factory, cart_factory, discount_factory):
cart = cart_factory(user=user_factory(), total=Decimal("100000"))
discount_factory(code="OFF10", percent=10, is_active=True, min_amount=0)
result = checkout_cart(cart.user, "OFF10")
assert result.total == Decimal("90000")
assert result.discount_percent == 10
@pytest.mark.django_db
def test_checkout_rejects_invalid_discount(user_factory, cart_factory):
cart = cart_factory(user=user_factory(), total=Decimal("100000"))
with pytest.raises(InvalidDiscountCode):
checkout_cart(cart.user, "NOPE")
این تستها سریعاند، به دیتابیس واقعی نیاز ندارند و میتوانند در parallel اجرا شوند. تست ویو، در مقابل، به client، به middlewareها و به session نیاز دارد. برای مثال، اگر روی رویداد خروج با sendBeacon تست بنویسی و آن را در ویو گذاشته باشی، هر بار به یک request ساختگی نیاز داری.
سرویسها در برابر سیگنالها
سیگنالها در جنگو ابزار قدرتمندی هستند، اما بهعنوان محل منطق کسبوکار، بیشتر شبیه یک تلهاند. سه مشکل اصلی:
۱. پنهانکاری. وقتی یک post_save روی مدل سفارش ثبت میکنی، هر کسی که سفارش میسازد نمیداند ایمیل هم فرستاده میشود. جریان اجرا از چشم خواننده پنهان میماند.
۲. دشواری تست. غیرفعالکردن سیگنالها در تست کار پیچیدهای است و باعث میشود تستها به هم وابسته شوند.
۳. بازگشتناپذیری. سیگنال بعد از save اجرا میشود، ولی اگر داخل تراکنش باشد و rollback شود، سیگنال قبلاً اجرا شده. اینجاست که سرویس با transaction.on_commit برنده است.
پس سیگنالها برای چه چیز خوبند؟ برای کارهای واقعاً واکنشی مثل ثبت لاگ ساده، پاکسازی کش، یا اطلاعرسانی به یک زیرساخت خارجی که تحمل شکست دارد. برای منطق کسبوکار، سرویس را انتخاب کن. این تفکیک دقیقاً همان الگویی است که در تفاوت Middleware و Context Processor هم دیده میشود: هر ابزار، جای خودش.
سرویسهای async در جنگو
از جنگو ۳.۱ به بعد، پشتیبانی async رسمی است. اما ORM جنگو هنوز بهطور کامل async نیست. برای نوشتن سرویس async، از sync_to_async استفاده کن:
from asgiref.sync import sync_to_async
from shop.services.checkout import checkout_cart
async def checkout_async_handler(user, code):
result = await sync_to_async(checkout_cart)(user, code)
return result
یک قاعدهی ساده: تا وقتی ORM جنگو کاملاً async نشده، سرویسهای دامنه را sync بنویس و فقط لایهی بیرونی را async کن. اگر سرویسی واقعاً به I/O خارجی غیرمستقیم وابسته است (مثل تماس با درگاه پرداخت)، آن را جدا نگه دار و از httpx.AsyncClient استفاده کن.
anti-patternهای رایج در services.py
در بازبینی کد پروژههای مختلف، این پنج anti-pattern را زیاد دیدهام:
۱. سرویسهایی که فقط یک wrapper روی ORM هستند. اگر سرویس تو فقط Model.objects.create() را صدا میزند، ارزش سرویس را از بین بردهای. سرویس باید یک use case را انتزاع کند، نه اینکه یک پوشش نازک روی ORM باشد.
۲. سرویسهای غولآسا. یک فایل سرویس با ۵۰ تابع، همان fat view است با اسم دیگر. آن را به پکیج services/ با زیرشاخهها بشکن: services/checkout.py، services/inventory.py، services/mailer.py.
۳. تزریق request به سرویس. این را قبلاً گفتم ولی چون زیاد تکرار میشود، باز میگویم. request را در ویو تمیز کن و فقط دادهها را بفرست.
۴. سرویسهایی که exception از نوع ValidationError پرتاب نمیکنند. اگر سرویس تو Exception عمومی پرتاب کند، ویو نمیتواند تصمیم بگیرد چه کد وضعیتی برگرداند. از exceptionهای خاص دامنه استفاده کن.
۵. تست نکردن سرویسها. بزرگترین اشتباه. اگر سرویس تست ندارد، کد fat view را فقط به یک جای دیگر منتقل کردهای.
پرسشهای پرتکرار دربارهی services.py در جنگو
آیا services.py بخشی از جنگو است؟ نه. یک کنوانسیون جامعه است و در مستندات رسمی جنگو به این اسم وجود ندارد. با این حال، در مستندات به مفهوم «لایهی منطق کسبوکار» اشارههای زیادی وجود دارد.
تفاوت services و selectors چیست؟ برخی تیمها الگوی CQRS سادهشده را پیاده میکنند: services/ برای write و selectors/ برای read. این جداسازی وقتی مفید است که کوئریهای پیچیده و مکرر داشته باشی. برای پروژههای کوچک، لازم نیست.
سرویسها در کدام اپ قرار بگیرند؟ در همان اپی که دامنه در آن است. اگر سرویس به چند اپ وابسته است، آن را در یک اپ core یا shared بگذار. هرگز سرویسهای دامنه را در یک اپ api نگه ندار.
آیا میتوان سرویسها را class-based نوشت؟ بله، ولی تا وقتی منطق حالت (state) ندارد، تابع ساده کافی است. class-based service زمانی توجیه دارد که میخواهی وابستگیها را در __init__ تزریق کنی یا چند متد هماهنگ داشته باشی.
سرویسها چطور به یکدیگر وابسته شوند؟ از تزریق وابستگی استفاده کن، نه import مستقیم. اگر CheckoutService به EmailService نیاز دارد، آن را در سازنده بپذیر:
class CheckoutService:
def __init__(self, mailer):
self.mailer = mailer
این کار تستپذیری را چند برابر میکند و اجازه میدهد در تست، یک mailer جعلی تزریق کنی.
آیا برای API و وب از همان سرویس استفاده کنیم؟ بله، دقیقاً همین هدف است. اگر سرویس درست نوشته شده باشد، همان سرویس در ویوی HTML و در DRF viewset بدون تغییر کار میکند.
چطور سرویسها را در پنل ادمین استفاده کنیم؟ در admin actionها یا override save_model، سرویس را صدا بزن. اگر پنل ادمین سفارشی مثل پنل ادمین با کارتهای quick view داری، همان سرویسها را در پشت ویوها صدا بزن.
آیا سرویسها را در Celery task استفاده کنیم؟ بله. task فقط یک wrapper نازک روی سرویس است. اگر task منطق کسبوکار داشته باشد، دوباره تکرار کردهای.
سرویسها چطور با multi-tenancy سازگار شوند؟ tenant را در سازنده یا در context بخواه، نه از request. اگر از django-tenants یا مشابه استفاده میکنی، tenant در connection database تنظیم میشود و سرویس نیازی به دانستن آن ندارد.
آیا سرویسها را در یک پکیج جدا نگه داریم؟ برای پروژههای بزرگ، بله. services/ را داخل همان اپ بگذار و در صورت نیاز، آن را به یک پکیج domain/ جداگانه منتقل کن.
کجا نباید از services استفاده کنیم؟
سه حالت که services.py به پروژه ضرر میزند:
۱. پروژههای کوچک و یکمنظوره. اگر پروژهات یک وبسایت شخصی است با ۵ ویو، اضافهکردن لایهی سرویس فقط overhead است. مدل و ویو کافی است.
۲. پروژههای CRUD محض. اگر هیچ منطق کسبوکار واقعی نداری و همه چیز CRUD است، سرویس تبدیل به یک پوشش بیفایده روی ORM میشود.
۳. تیمهای کوچک بدون انضباط. اگر تیم قواعد را رعایت نکند، services.py تبدیل به یک سطل زباله میشود که همه چیز در آن ریخته میشود. در این حالت، بهتر است ننویسی تا اینکه بد بنویسی.
نگاه مهندسی در مقیاس بزرگ
وقتی پروژه از چند صد هزار کاربر عبور میکند، لایهی سرویس دوباره متحول میشود. آنچه در مقیاس بزرگ تفاوت ایجاد میکند، سه چیز است:
۱. جداسازی write از read. بهجای اینکه یک سرویس هم بنویسد و هم بخواند، سرویسهای read را به یک replica دیتابیس هدایت کن. این کار با routing دیتابیس در جنگو انجام میشود. برای مثال، در ساختارهایی که API JSON برای آمار زنده میسازند، خواندنها باید از replica باشند.
۲. idempotency. سرویسهای پرداخت و سفارش باید idempotent باشند. اگر یک درخواست دوبار ارسال شود، سرویس نباید دو سفارش بسازد. این کار با یک کلید idempotency که در سازنده یا پارامتر سرویس پاس داده میشود، انجام میشود.
۳. observability. سرویسها نقطهی طبیعی برای ثبت metrics هستند. هر سرویس را با یک span در OpenTelemetry یا Sentry بپوشان. اینطوری در زمان کندی سیستم میتوانی دقیقاً بفهمی کدام use case کند است. برای نمونه، در پروژهای که بهینهسازی جنگو برای ترافیک بالا انجام دادیم، همین metricها نشان دادند که سرویس checkout_cart ۴۰٪ زمانش را در send_mail میگذراند و با انتقال به صف، P99 از ۲.۳ ثانیه به ۳۸۰ میلیثانیه رسید.
نکتهی آخر: سرویسها را هرگز به یک فریمورک یا کتابخانهی خاص گره نزن. آنها باید به پایتون خالص وابسته باشند. اگر روزی تصمیم گرفتی به FastAPI مهاجرت کنی، سرویسها باید بدون تغییر کار کنند.
پرسش نهایی برای خودت
اگر فردا مجبور شوی این پروژه را به یک تیم دیگر تحویل بدهی، آیا یک مهندس ارشد میتواند از روی نام سرویسها بفهمد سیستم چه کار میکند؟ اگر جواب منفی است، احتمالاً هنوز منطق کسبوکار در لایههای نادرست پخش شده. سرویس خوب، خودش را توضیح میدهد.
اگر این ساختار را در یک پروژهی واقعی پیاده کردهای و به مشکل خوردهای — مثلاً جایی که تزریق وابستگی دردسر ایجاد کرده یا تراکنشها به هم گره خوردهاند — خوشحال میشوم تجربهات را بشنوم. مخصوصاً اگر راهحلی پیدا کردهای که با قواعد رایج متفاوت است، چون همان تجربهها هستند که این بحث را جلو میبرند.