Django Best Practices: Fat Models vs Service Layers
django python architecture
“Fat models, skinny views” has been Django dogma for years. But as applications grow, some teams are moving to service layers. Here’s how to think about this architectural decision.
The Traditional Approach: Fat Models
Django’s philosophy: Models encapsulate data and behavior.
# models.py
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.CharField(max_length=20, default='pending')
total = models.DecimalField(max_digits=10, decimal_places=2)
def confirm(self):
"""Confirm the order and process payment."""
if self.status != 'pending':
raise ValueError("Can only confirm pending orders")
self.process_payment()
self.status = 'confirmed'
self.save()
self.send_confirmation_email()
def process_payment(self):
payment_gateway.charge(
amount=self.total,
user=self.user
)
def send_confirmation_email(self):
send_mail(
subject="Order Confirmed",
message=f"Your order #{self.id} is confirmed",
recipient_list=[self.user.email]
)
def cancel(self):
if self.status not in ('pending', 'confirmed'):
raise ValueError("Cannot cancel this order")
if self.status == 'confirmed':
self.refund()
self.status = 'cancelled'
self.save()
Pros
- Clear ownership: Order logic lives with Order
- Encapsulation: Data and behavior together
- Django convention: Follows the framework philosophy
- Easy to find: Logic is where you expect it
Cons
- Models become huge: 2000+ line model files
- Testing requires database: Model methods need DB access
- Circular imports: Models depending on each other
- Mixing concerns: Persistence + business logic + external services
The Alternative: Service Layer
Separate business logic into services:
# models.py
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.CharField(max_length=20, default='pending')
total = models.DecimalField(max_digits=10, decimal_places=2)
# Just data, no behavior
# services/order_service.py
class OrderService:
def __init__(self, payment_gateway, email_service):
self.payment_gateway = payment_gateway
self.email_service = email_service
def confirm_order(self, order: Order) -> Order:
if order.status != 'pending':
raise OrderError("Can only confirm pending orders")
self.payment_gateway.charge(
amount=order.total,
user=order.user
)
order.status = 'confirmed'
order.save()
self.email_service.send_confirmation(order)
return order
def cancel_order(self, order: Order) -> Order:
if order.status not in ('pending', 'confirmed'):
raise OrderError("Cannot cancel this order")
if order.status == 'confirmed':
self.payment_gateway.refund(order)
order.status = 'cancelled'
order.save()
return order
Usage
# views.py
def confirm_order(request, order_id):
order = get_object_or_404(Order, id=order_id)
order_service = OrderService(
payment_gateway=StripeGateway(),
email_service=EmailService()
)
try:
order_service.confirm_order(order)
return redirect('order_confirmed')
except OrderError as e:
messages.error(request, str(e))
return redirect('order_detail', order_id=order_id)
Dependency Injection
# For testing
def test_confirm_order():
mock_payment = Mock()
mock_email = Mock()
service = OrderService(mock_payment, mock_email)
order = Order(status='pending', total=100)
service.confirm_order(order)
mock_payment.charge.assert_called_once()
mock_email.send_confirmation.assert_called_once()
assert order.status == 'confirmed'
No database required. Dependencies are explicit.
Hybrid Approach
Keep simple logic in models, extract complex flows:
# models.py
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.CharField(max_length=20, default='pending')
total = models.DecimalField(max_digits=10, decimal_places=2)
# Simple, stateless methods: keep in model
@property
def is_cancellable(self):
return self.status in ('pending', 'confirmed')
@property
def items_count(self):
return self.items.count()
def mark_confirmed(self):
"""Just update status, no side effects."""
self.status = 'confirmed'
self.save()
# services/order_service.py
def confirm_order(order, payment_gateway, email_service):
"""Complex flow with external dependencies."""
if order.status != 'pending':
raise OrderError("Can only confirm pending orders")
payment_gateway.charge(order.total, order.user)
order.mark_confirmed()
email_service.send_confirmation(order)
When to Use Each
Fat Models Work Well For
- Small to medium apps
- Simple business logic
- Limited external integrations
- Teams familiar with Django conventions
Service Layers Work Well For
- Large applications (100+ models)
- Complex business workflows
- Many external services
- Teams wanting explicit dependencies
- Microservices architecture
Practical Guidelines
Model Methods Should
class Order(models.Model):
# ✅ DO: Query-related methods
def get_items_by_category(self, category):
return self.items.filter(category=category)
# ✅ DO: Computed properties
@property
def subtotal(self):
return sum(item.price for item in self.items.all())
# ✅ DO: Status checks
def can_be_cancelled(self):
return self.status in ('pending', 'confirmed')
# ❌ DON'T: External service calls
def charge_credit_card(self):
stripe.charge(self.total) # External dependency!
# ❌ DON'T: Complex workflows
def complete_checkout(self):
self.charge_payment()
self.send_email()
self.update_inventory()
self.notify_warehouse()
Services Should
class CheckoutService:
# ✅ DO: Orchestrate complex flows
def checkout(self, cart, user, payment_info):
order = self.create_order(cart, user)
self.process_payment(order, payment_info)
self.send_confirmation(order)
self.update_inventory(order)
return order
# ✅ DO: Accept dependencies explicitly
def __init__(self, payment_gateway, inventory_service, email_service):
self.payment = payment_gateway
self.inventory = inventory_service
self.email = email_service
Organizing Services
myapp/
├── models.py
├── views.py
├── services/
│ ├── __init__.py
│ ├── orders.py
│ ├── payments.py
│ └── notifications.py
├── repositories/ # Optional: data access abstraction
│ └── orders.py
└── domain/ # Optional: pure business logic
└── pricing.py
Testing Comparison
Fat Model Testing
# Requires database, harder to mock
class OrderModelTest(TestCase):
def test_confirm(self):
order = Order.objects.create(status='pending', total=100)
with patch('myapp.models.payment_gateway') as mock:
order.confirm()
mock.charge.assert_called_once()
Service Testing
# No database needed, explicit mocking
class OrderServiceTest(unittest.TestCase):
def test_confirm(self):
mock_payment = Mock()
mock_email = Mock()
service = OrderService(mock_payment, mock_email)
order = Mock(status='pending', total=100)
service.confirm_order(order)
mock_payment.charge.assert_called_once()
Final Thoughts
There’s no universally correct answer:
- Start with fat models (Django convention)
- Extract services when complexity grows
- Let the codebase guide you
The test: Can you explain where to find any piece of business logic? If yes, your architecture works.
Models hold data. Services hold workflows. Views hold HTTP.