The Selector Pattern¶
What is a Selector?¶
A Selector is a read-only component that retrieves data using dynamic queries.
The Selector pattern is a recognized pattern in the Django community, popularized by the HackSoft Django Styleguide and implemented in libraries like ai-django-core. It separates read operations from write operations, aligning with CQRS (Command Query Responsibility Segregation) principles.
Instead of creating dozens of repository methods for each query variation, a Selector provides one flexible interface.
Problems It Solves¶
Problem 1: Fetching More Data Than Needed¶
Traditional approach:
# Repository always returns full entities
posts = repository.get_published()
# Each post has:
# - id, title, slug (needed)
# - content: 50KB of text (NOT needed for a list)
# - metadata, tags, timestamps (NOT needed)
# - author, categories (NOT needed)
# For 20 posts = 1MB+ of unnecessary data
With Selector:
from fc_selector.django.selector import ODataSelector, QueryBuilder
posts = selector.get_many(
QueryBuilder()
.select("id", "title", "slug") # Only what you need
.filter("status eq 'published'")
)
# Database fetches ONLY id, title, slug
# ~1KB instead of 50KB per post
Problem 2: N+1 Queries¶
Traditional approach:
posts = repository.get_all() # 1 query
for post in posts:
print(post.author.name) # +1 query (lazy load)
print(post.categories) # +1 query (lazy load)
# 20 posts = 1 + 20 + 20 = 41 queries!
With Selector:
posts = selector.get_many(
QueryBuilder()
.expand("author", "categories") # Eager load
)
# 1-2 queries total (JOIN or prefetch)
Problem 3: Repository Method Explosion¶
Traditional approach:
class PostRepository:
def get_all(self): ...
def get_by_id(self, id): ...
def get_published(self): ...
def get_drafts(self): ...
def get_by_author(self, author_id): ...
def get_published_by_author(self, author_id): ...
def get_featured(self): ...
def get_published_featured(self): ...
def get_by_category(self, category_id): ...
def get_recent(self, limit): ...
def get_popular(self): ...
def get_by_status(self, status): ...
def get_by_date_range(self, start, end): ...
# ... 30+ methods for every combination
With Selector:
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
# ONE selector handles ALL queries:
selector.get_many(query.filter("status eq 'published'"))
selector.get_many(query.filter("status eq 'draft'"))
selector.get_many(query.filter("author/id eq 5"))
selector.get_many(query.filter("status eq 'published' and author/id eq 5"))
selector.get_many(query.filter("featured eq true"))
selector.get_many(query.filter("categories/any(c: c/id eq 3)"))
selector.get_many(query.filter("created_at gt 2024-01-01"))
# Infinite combinations, zero new methods
Problem 4: Inconsistent URL Parameters¶
Traditional approach - every project invents its own:
# Project A
GET /api/posts/?fields=id,title&author=5&sort=-created_at
# Project B
GET /api/posts/?include=id,title&filter[author]=5&order=created_at:desc
# Project C
GET /api/posts/?columns=id,title&author_id=5&orderby=created_at&dir=desc
# Developers must learn a new syntax for each API
With Selector (OData standard):
# Same syntax everywhere
GET /api/posts/?$select=id,title&$filter=author/id eq 5&$orderby=created_at desc
# OData is an industry standard:
# - Well documented
# - Same syntax in Microsoft, SAP, Salesforce APIs
# - Clients already know it
Problem 5: Coupling API to ORM¶
Traditional approach:
def get_posts():
return Post.objects.filter(status='published')
# Returns QuerySet - leaks Django ORM to callers
# Callers can: .delete(), .update(), lazy load, etc.
With Selector:
def get_posts():
return selector.get_many(query)
# Returns List[BlogPostDTO] - pure data
# No ORM methods, no lazy loading, no mutations
Problem 6: Manual Query Optimization¶
Traditional approach:
# Developer must remember to optimize every query
posts = Post.objects.filter(
status='published'
).select_related(
'author'
).prefetch_related(
'categories'
).only(
'id', 'title', 'excerpt', 'author__name'
).order_by(
'-created_at'
)[:20]
# Easy to forget .select_related() → N+1
# Easy to forget .only() → over-fetching
With Selector:
posts = selector.get_many(
QueryBuilder()
.select("id", "title", "excerpt")
.expand("author($select=name)")
.filter("status eq 'published'")
.orderby("created_at desc")
.top(20)
)
# Automatic:
# - .only() from $select
# - .select_related() from $expand
# - .prefetch_related() for M2M
Summary of Problems Solved¶
| Problem | Traditional | FC Selector |
|---|---|---|
| Over-fetching data | Fetch all fields always | $select fetches only needed |
| N+1 queries | Manual select_related | $expand auto-optimizes |
| Method explosion | 30+ methods per entity | 1 selector, infinite queries |
| URL syntax | Different per project | Standard OData |
| ORM coupling | QuerySet exposed | DTOs returned |
| Manual optimization | Remember .only(), etc. | Automatic |
What is a Selector?¶
A Selector is simply:
- Read-only - No create, update, delete
- Dynamic queries - OData syntax for flexible filtering
- Returns DTOs - Not ORM models
- Auto-optimized - select_related, only, etc. applied automatically
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost # Django model
dto_class = BlogPostDTO # What it returns
expandable_fields = { # Relations that can be expanded
'author': AuthorDTO,
'categories': CategoryDTO,
}
That's it. One class, infinite queries, automatic optimization.