10 Python Patterns, Part 6: The Mapper Pattern in Django
In our previous post, we used the Repository Pattern to hide where data comes from. However, even with Repositories, we often still pass "Fat" Django ORM models into our business logic. This creates a "leaky abstraction" where your business logic depends on the database structure. The Mapper Pattern solves this by converting database rows into clean, simple Python objects.
The Business Use Case: A Loyalty Discount System
Imagine you are building a loyalty system. A user gets a discount based on their "Premium" status and their total lifetime spend. In a standard Django app, this logic lives inside a service that takes a User model instance. Because the model is "live," any logic you write is tethered to the database schema.
The Problem: The "Fat" Model Dependency
Django models are powerful, but they are "heavy." They carry database connections and hidden state. If your business logic expects a Django User object, you cannot easily test it without a database.
The WorkFlow: Before the Mapper Pattern
In this workflow, the service layer is married to the ORM. Testing this requires a database hit because the model is a live object.
# services.py
def calculate_loyalty_discount(user_model):
# The "Bad" Way: Logic is tied to the ORM model
# This triggers a database sub-query if 'profile' isn't prefetched!
if user_model.profile.is_premium and user_model.total_spent > 1000:
return 0.20
return 0.05
The Solution: The Mapper Pattern
The Mapper Pattern takes data from the Repository and "maps" it into a Domain Object. Your business logic only knows about this clean object.
Step 1: Define the Domain Object
We create a "Domain Entity"—a pure Python class with no knowledge of Django.
# domain.py
from dataclasses import dataclass
@dataclass
class UserEntity:
id: int
is_premium: bool
total_spent: float
email: str
Step 2: Create the Mapper Logic
The Mapper translates the "dirty" Django Model into our "clean" Domain Entity.
# mappers.py
from .domain import UserEntity
class UserMapper:
@staticmethod
def to_domain(user_model) -> UserEntity:
return UserEntity(
id=user_model.id,
is_premium=user_model.profile.is_premium,
total_spent=user_model.total_spent,
email=user_model.email
)
Step 3: The New Workflow (The "Clean" Way)
The Repository uses the Mapper, and the Service consumes the clean Entity. Notice how the service no longer cares about the database.
# repositories.py
class UserRepository:
def get_user_entity(user_id) -> UserEntity:
user_model = User.objects.get(id=user_id)
return UserMapper.to_domain(user_model)
# services.py
def calculate_loyalty_discount(user_entity: UserEntity):
# Pure Python logic. No DB hits. No prefetches required.
if user_entity.is_premium and user_entity.total_spent > 1000:
return 0.20
return 0.05
Why it's Better for Big Projects
- Pure Business Logic: Your services work with simple Python classes. They don't care about
.save()or.filter(). - Lightning Fast Tests: You can test your logic by passing a simple
UserEntity(id=1, is_premium=True, total_spent=1500, email="test@test.com"). No database or migrations required. - Strict Contracts: Using tools like Pydantic for your domain objects ensures data is validated the moment it enters your logic layer.
The Mapper Pattern ensures that your core business rules are protected from the ever-changing structure of your database. By translating "Database Models" into "Domain Entities," you achieve a level of decoupling essential for enterprise-grade applications.
Architecture Note: To keep your codebase clean, avoid putting business logic inside your Mappers. Mappers should be "dumb" translators. For scalability, ensure your Service Layer receives these clean Entities to perform calculations. This prevents "leaky abstractions" and makes your background tasks and unit tests significantly easier to manage.