Trigger
You need to add business logic that doesn't belong in a model, view, or signal.
Summary (Human)
Business logic lives in [app]/services/[domain].py. Models are pure data. Views handle HTTP. Signals call services. Services contain the rules.
Procedure (Claude)
1. Create service module
myapp/
├── services/
│ ├── __init__.py
│ └── order_service.py ← one file per domain
2. Write stateless functions (not classes, unless caching)
# myapp/services/order_service.py
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
def calculate_order_total(order):
"""Pure calculation — no side effects."""
return sum(item.quantity * item.unit_price for item in order.items.all())
def complete_order(order, completed_by):
"""State transition with side effects — uses atomic."""
with transaction.atomic():
order.status = 'COMPLETED'
order.completed_at = timezone.now()
order.completed_by = completed_by
order.total = calculate_order_total(order)
order.save(update_fields=['status', 'completed_at', 'completed_by', 'total', 'updated_at'])
# Create financial record
Transaction.objects.create(
tenant=order.tenant,
amount=order.total,
transaction_type='INCOME',
description=f'Order {order.reference}',
)
return order
3. For cached lookups, use a service class
# core/services/settings_service.py
from django.core.cache import cache
class TenantSettingsService:
CACHE_TTL = 300 # 5 minutes
@staticmethod
def get_threshold(tenant, field_name):
cache_key = f'tenant_settings:{tenant.id}:{field_name}'
value = cache.get(cache_key)
if value is None:
value = getattr(tenant.settings, field_name)
cache.set(cache_key, value, TenantSettingsService.CACHE_TTL)
return value
@staticmethod
def invalidate(tenant):
# Called from TenantSettings post_save signal
cache.delete_pattern(f'tenant_settings:{tenant.id}:*')
4. Signals call services — never the reverse
# CORRECT: signal → service
@receiver(post_save, sender=Sale, dispatch_uid='sale_record_revenue')
def record_revenue_on_sale(sender, instance, created, **kwargs):
if not created:
return
from apps.finance.services.revenue_service import record_sale_revenue
record_sale_revenue(instance)
# WRONG: service importing signal
def record_sale_revenue(sale):
from apps.myapp.signals import trigger_notification # circular, fragile
5. Views call services for complex operations
class OrderViewSet(BaseTenantViewSet):
@action(detail=True, methods=['post'])
def complete(self, request, pk=None):
order = self.get_object()
order = complete_order(order, completed_by=request.user)
return Response(OrderReadSerializer(order).data)
Output
- Service module in
[app]/services/[domain].py
- Stateless functions for pure logic, class for cached lookups
- Signals and views call services, not the reverse
Edge Cases
- Circular imports: use late imports inside functions (
from apps.x.services.y import z)
- Testing services: test service functions directly, not through signals/views
- Tenant context: pass tenant explicitly to service functions, don't rely on request context
Changelog
v1.0 — 2026-04-02
- Seeded from Shira (12+ service modules: milk, breeding, health, billing, scheduling, economics, growth, inventory, KPI, settings, team, email verification)
- Seeded from MyChama (MpesaDarajaAPI, PlatformBillingService, NotificationService, MessagingService)
Source: upstate-web-co/uwc-django-skills — distributed by TomeVault.
1---2name: django-service3description: Apply this skill when organizing business logic in Django. Covers the service layer pattern — extracting business logic from views and models into dedicated service classes for testability, reusability, and separation of concerns. Triggered by phrases like 'service layer', 'business logic', 'fat models', 'where to put logic', or when refactoring Django views. Use when this capability is needed.4---56## Trigger7You need to add business logic that doesn't belong in a model, view, or signal.89## Summary (Human)10Business logic lives in `[app]/services/[domain].py`. Models are pure data. Views handle HTTP. Signals call services. Services contain the rules.1112## Procedure (Claude)1314### 1. Create service module15```16myapp/17├── services/18│ ├── __init__.py19│ └── order_service.py ← one file per domain20```2122### 2. Write stateless functions (not classes, unless caching)23```python24# myapp/services/order_service.py25from decimal import Decimal26from django.db import transaction27from django.utils import timezone2829def calculate_order_total(order):30 """Pure calculation — no side effects."""31 return sum(item.quantity * item.unit_price for item in order.items.all())3233def complete_order(order, completed_by):34 """State transition with side effects — uses atomic."""35 with transaction.atomic():36 order.status = 'COMPLETED'37 order.completed_at = timezone.now()38 order.completed_by = completed_by39 order.total = calculate_order_total(order)40 order.save(update_fields=['status', 'completed_at', 'completed_by', 'total', 'updated_at'])41 # Create financial record42 Transaction.objects.create(43 tenant=order.tenant,44 amount=order.total,45 transaction_type='INCOME',46 description=f'Order {order.reference}',47 )48 return order49```5051### 3. For cached lookups, use a service class52```python53# core/services/settings_service.py54from django.core.cache import cache5556class TenantSettingsService:57 CACHE_TTL = 300 # 5 minutes5859 @staticmethod60 def get_threshold(tenant, field_name):61 cache_key = f'tenant_settings:{tenant.id}:{field_name}'62 value = cache.get(cache_key)63 if value is None:64 value = getattr(tenant.settings, field_name)65 cache.set(cache_key, value, TenantSettingsService.CACHE_TTL)66 return value6768 @staticmethod69 def invalidate(tenant):70 # Called from TenantSettings post_save signal71 cache.delete_pattern(f'tenant_settings:{tenant.id}:*')72```7374### 4. Signals call services — never the reverse75```python76# CORRECT: signal → service77@receiver(post_save, sender=Sale, dispatch_uid='sale_record_revenue')78def record_revenue_on_sale(sender, instance, created, **kwargs):79 if not created:80 return81 from apps.finance.services.revenue_service import record_sale_revenue82 record_sale_revenue(instance)8384# WRONG: service importing signal85def record_sale_revenue(sale):86 from apps.myapp.signals import trigger_notification # circular, fragile87```8889### 5. Views call services for complex operations90```python91class OrderViewSet(BaseTenantViewSet):92 @action(detail=True, methods=['post'])93 def complete(self, request, pk=None):94 order = self.get_object()95 order = complete_order(order, completed_by=request.user)96 return Response(OrderReadSerializer(order).data)97```9899## Output100- Service module in `[app]/services/[domain].py`101- Stateless functions for pure logic, class for cached lookups102- Signals and views call services, not the reverse103104## Edge Cases105- **Circular imports:** use late imports inside functions (`from apps.x.services.y import z`)106- **Testing services:** test service functions directly, not through signals/views107- **Tenant context:** pass tenant explicitly to service functions, don't rely on request context108109## Changelog110### v1.0 — 2026-04-02111- Seeded from Shira (12+ service modules: milk, breeding, health, billing, scheduling, economics, growth, inventory, KPI, settings, team, email verification)112- Seeded from MyChama (MpesaDarajaAPI, PlatformBillingService, NotificationService, MessagingService)113114---115> Source: [upstate-web-co/uwc-django-skills](https://github.com/upstate-web-co/uwc-django-skills) — distributed by [TomeVault](https://tomevault.io).116<!-- tomevault:4.0:skill_md:2026-06-15 -->