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

Cons

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

Service Layers Work Well For

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:

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.

All posts