ワンクリックで
service-layer
Service layer best practices on django models
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Service layer best practices on django models
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Best practices for writing Django tests (updated template)
GlueFetchHelper patterns for making AJAX calls in templates
Advanced seeding patterns for linking specific foreign keys
Apply DjangoSpire app CSS variables in templates instead of inline styles
Django URL configuration patterns, namespaces, and nested structure conventions
Best practices for AI bots, prompts, and intel in scope_of_work
| name | service-layer |
| description | Service layer best practices on django models |
No Docstrings or Comments: This skill follows the project's python-style guidelines. NEVER add docstrings to functions, methods, or classes. NEVER add comments (inline or block). Code must be self-explanatory through clear naming and type hints only. See the python-style skill for complete guidelines.
The service layer provides each domain model with a predictable "service layer" so that business rules live next to the data and can be invoked as naturally as Task.objects.
Django's flexibility often scatters business logic across views, utils, managers, and helpers. A dedicated service layer centralizes business rules by pinning them to the model that owns them. Each model exposes a services descriptor that:
task.services.notification.send_created()The example below demonstrates using a simple Task model to showcase service layer functionality:
| Method | Purpose |
|---|---|
validate_model_obj() | Runs full_clean() on the target object and raises if validation fails. |
save_model_obj(**field_data) | Calls validate_model_obj() and then save(). This is the primary pipeline used for saving objects. Avoid calling form.save() or object.save() directly; pass everything through save_model_obj() to maintain a controlled pipeline for model persistence. Returns a tuple (obj, is_created). |
Service Pipeline Pattern:
By channeling all persistence through save_model_obj(), developers ensure that:
Using save_model_obj():
Always use save_model_obj() with named field parameters instead of setting attributes and calling .save():
# ❌ Avoid: Direct attribute setting and save
company_user.is_active = True
company_user.save()
# ✅ Prefer: Using save_model_obj with field names
obj, is_created = company_user.services.save_model_obj(is_active=True)
return obj
This ensures validation and any request noise handling is properly applied.
tasks/
├── models.py
└── services/
├── service.py # TaskService (primary)
└── notification_service.py # TaskNotificationService (secondary)
# tasks/models.py
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import models
if TYPE_CHECKING:
from app.tasks.services.service import TaskService
class Task(models.Model):
title = models.CharField(max_length=200)
is_done = models.BooleanField(default=False)
services = TaskService()
def __str__(self) -> str:
return self.title
# tasks/services/service.py
from __future__ import annotations
from typing import TYPE_CHECKING
from django_spire.contrib.service import BaseDjangoModelService
if TYPE_CHECKING:
from app.tasks.models import Task
from app.tasks.services.notification_service import TaskNotificationService
from app.tasks.services.processor_service import TaskProcessorService
class TaskService(BaseDjangoModelService['Task']):
# target model — must be declared first
obj: Task
# followed by all sub-services that are related to the model
notification = TaskNotificationService()
processor = TaskProcessorService()
The table below outlines the typical file structure and responsibilities within the service layer:
| File Path | Class | Responsibility |
|---|---|---|
| services/service.py | TaskService | Parent service that composes sub-services and exposes high-level operations |
| services/notification_service.py | TaskNotificationService | Notifications, messaging, and side-effect integrations (e.g., emails, webhooks) |
| services/processor_service.py | TaskProcessorService | Core task processing logic and workflows |
| services/transformation_service.py (optional) | TaskTransformationService | Data transforms or enrichment supporting other services |
Each service should define the target object type (e.g., obj: Task) for instance-level operations and may also provide class-level utilities for bulk or maintenance tasks. Keep responsibilities focused and cohesive.
from app.tasks.models import Task
# Instance-level usage – operate on one concrete record
task = Task.objects.get(pk=42)
after = task.services.mark_done()
# Class-level usage – when no specific record rows exist, uses "null" task (pk = None)
Task.services.automation.clean_dead_tasks()
| Use-Case | Call form | Rationale |
|---|---|---|
| Work on one existing row in db | task.services.mark_done() | Direct operations on instance, mutate and persist changes. |
| Run bulk/maintenance logic when no row exists yet | Task.services.automation.clean_dead_tasks() | Utilizes natural behavior without specific task, service creates temporary task containment for behavior. |
When embedded within a service layer, access to the model class is available for database queries and manipulations. Service initialization sets the model class as an attribute matching the model name. In the background, our base service sets the target object class as the attribute by the given class name.
# tasks/services/service.py
from __future__ import annotations
from typing import TYPE_CHECKING
from django_spire.contrib.service import BaseDjangoModelService
if TYPE_CHECKING:
from app.tasks.models import Task
class TaskAutomationService(BaseDjangoModelService['Task']):
obj: Task
def mark_stale(self) -> Task:
stale_tasks = self.obj_class.objects.filter(created_date__lte='2020-01-01')
# ...
The service layer inherits from a base class that exposes obj_class, giving you access to the model class for queries and bulk operations. Use this for marking, processing, or reversing events at runtime as necessary for asset management and orchestration.
As services grow, it’s acceptable—and often desirable—to keep small, private helper methods inside the service to support readability and cohesion. When the logic becomes complex or domain-rich, extract that logic into a dedicated class (or small class cluster) placed in a subdirectory of the related app, and let the service compose and orchestrate it.
Keep simple, service-specific helpers private to the file. Use a leading underscore to signal intent.
# tasks/services/processor_service.py
from __future__ import annotations
from django_spire.contrib.service import BaseDjangoModelService
if TYPE_CHECKING:
from app.tasks.models import Task
class TaskProcessorService(BaseDjangoModelService['Task']):
obj: Task
def mark_done(self) -> Task:
self._ensure_can_mark_done()
self.obj.is_done = True
return self.save_model_obj()
# private, service-local validation
def _ensure_can_mark_done(self) -> None:
if not self.obj.title:
raise ValueError("Cannot mark done without a title")
When recurring workflows or time-based checks are needed (e.g., scanning for overdue tasks and notifying users), model-scoped automation services are a good fit. They coordinate queries and delegate messaging to a notification service.
Recommended layout:
tasks/
├── models.py
└── services/
├── service.py # TaskService (orchestrator/parent)
├── notification_service.py # TaskNotificationService (emails, webhooks, etc.)
└── automation_service.py # TaskAutomationService (overdue scans, scheduled jobs)
Implementation sketch (pseudocode):
# tasks/services/automation_service.py
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable
from django.utils import timezone
from django_spire.contrib.service import BaseDjangoModelService
if TYPE_CHECKING:
from app.tasks.models import Task
from app.tasks.services.notification_service import TaskNotificationService
class TaskAutomationService(BaseDjangoModelService['Task']):
obj: Task # instance when called from an object; null-like when invoked at class-level
# composed sub-service; defined on TaskService in service.py
notification: TaskNotificationService
def send_overdue_notifications(self, *, as_of=None) -> int:
now = as_of or timezone.now()
# Example criteria; adjust to your schema (due_at/assignee/status, etc.)
overdue_qs = self.obj_class.objects.filter(is_done=False, due_at__lt=now)
sent = 0
for batch in self._batched(overdue_qs.iterator(), size=200): # avoid loading everything at once
for t in batch:
# Delegate the actual message send to the notification service
self.notification.send_overdue(t)
sent += 1
return sent
# Private batching helper for memory-safe iteration
def _batched(self, items: Iterable, size: int):
batch = []
for item in items:
batch.append(item)
if len(batch) == size:
yield batch
batch = []
if batch:
yield batch
Views and jobs should call the service layer; services orchestrate notifications. For bulk automations, prefer class-level calls (e.g., from a management command or scheduled job):
# management command or scheduled job
from app.tasks.models import Task
def run_overdue_job():
sent = Task.services.automation.send_overdue_notifications()
print(f"Sent {sent} overdue notifications")
You can still expose instance-level helpers for one-off actions when appropriate:
def mark_and_notify_view(request, pk: int):
task = Task.objects.get(pk=pk)
if not task.is_done:
task.services.processor.mark_done()
task.services.notification.send_completed(task)
Path Readability: Method names should read like natural English when chained. The full path Model.services.subservice.method() should clearly explain what is happening.
CompanyUser.services.factory.create_or_activate() - Clear, reads like EnglishCompanyUser.services.factory.get_or_create_for_company_user() - Technical pattern, less readableBusiness Intent Over Technical Pattern: Name methods to describe the business action and outcome, not just the implementation pattern.
create_or_activate - Describes what happens: create new or activate existingget_or_create - Describes the Django pattern, not the business intentKeep automation under services/ as a peer to notification and processor services.
Views, jobs, and commands should call the service layer: Task.services.automation.send_overdue_notifications().
from __future__ import annotations at the top of service filesif TYPE_CHECKING: blocks to avoid circular importsPurpose: Create and initialize model instances with complex dependencies
Pattern:
class EpisodeFactoryService(BaseDjangoModelService['Episode']):
obj: Episode
def upload(self, podcast: Podcast, audio_file_path: Path):
# 1. Create related objects
transcription = Transcription.services.factory.create_from_audio_file_path(audio_file_path)
# 2. Create main object
self.obj.services.save_model_obj(
podcast=podcast,
transcription=transcription,
name=audio_file_path.name.split('.')[0],
audio_file_path=audio_file_path,
episode_number=podcast.episodes.all().count() + 1
)
# 3. Trigger transformations
self.obj.services.transformation.to_wav()
# 4. Create downstream objects
KeyConcept.services.factory.create_from_episode(self.obj)
return self.obj
class CompanyUserFactoryService(BaseDjangoModelService['CompanyUser']):
obj: CompanyUser
def create_or_activate(self, company: Company, user: User) -> CompanyUser:
try:
company_user = self.obj_class.objects.get(company=company, user=user)
obj, is_created = company_user.services.save_model_obj(is_active=True)
return obj
except self.obj_class.DoesNotExist:
return self.obj_class.objects.create(company=company, user=user)
Key Points:
save_model_obj() for persistence (not direct .save())save_model_obj(is_active=True)obj, is_created = ...create_or_activate reads clearly in EnglishPurpose: Process data using AI/LLM workflows
Pattern:
class EpisodeIntelligenceService(BaseDjangoModelService['Episode']):
obj: Episode
def generate_details(self) -> EpisodeIntel:
details_intel = bots.EpisodeBot().process(self.obj)
return details_intel
def find_quotes(self) -> EpisodeQuotesIntel:
details_intel = bots.EpisodeQuoteBot().process(self.obj)
return details_intel
Key Points:
Purpose: Convert data between formats or create derived files
Pattern:
class EpisodeTransformationService(BaseDjangoModelService['Episode']):
obj: Episode
def to_wav(self):
audio = AudioSegment.from_file(self.obj.audio_path, format="m4a")
audio.export(
self.obj.audio_path.with_name(f'{self.obj.audio_path.stem}.wav'),
format="wav"
)
def to_audacity_labels(self) -> str:
labels = "".join([
k.services.transformation.to_audacity_label()
for k in self.obj.key_concepts.all()
])
file_path = self.obj.audio_path.with_name('audacity_labels.txt')
with open(file_path.absolute(), "w") as f:
f.write(labels)
return labels
Key Points:
Purpose: Handle data processing, queries, and business logic
Pattern:
class TranscriptionProcessorService(BaseDjangoModelService['Transcription']):
obj: Transcription
@staticmethod
def transcribe_audio(audio_file_path: Path) -> AudioChunker:
with AudioChunker(audio_file_path) as chunker:
chunker.to_chunks()
for chunk in chunker:
chunk.transcribe()
return chunker
def find_chunk(self, start_seconds: float, end_seconds: float) -> str:
words = self.obj.words.filter(
start_seconds__gte=start_seconds,
end_seconds__lte=end_seconds,
)
return ' '.join([w.text for w in words])
Key Points:
@staticmethod for class-level operationsfrom __future__ import annotations
from django_spire.core.tests.test_cases import BaseTestCase
from unittest.mock import Mock, patch
from app.podcast.episode.tests.factories import create_test_episode
from app.podcast.tests.factories import create_test_podcast
from app.transcription.tests.factories import create_test_transcription
class EpisodeServiceTransformationTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
self.podcast = create_test_podcast()
self.transcription = create_test_transcription()
self.episode = create_test_episode(
podcast=self.podcast,
transcription=self.transcription
)
def test_to_wav(self):
self.episode.services.transformation.to_wav()
original_path = self.episode.audio_path
new_file = Path(original_path).with_suffix('.wav')
self.assertTrue(new_file.exists())
new_file.unlink() # Clean up
def test_to_audacity_labels(self):
self.episode.services.transformation.to_audacity_labels()
original_path = self.episode.audio_path
new_file = Path(original_path).with_name('audacity_labels.txt')
self.assertTrue(new_file.exists())
new_file.unlink() # Clean up
class EpisodeServiceFactoryTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
self.podcast = create_test_podcast()
self.transcription = create_test_transcription()
@patch('app.transcription.services.processor_service.TranscriptionProcessorService.transcribe_audio')
def test_upload(self, mock_transcribe_audio):
# Mock expensive operations
mock_chunker = create_test_audio_chunker()
mock_transcribe_audio.return_value = mock_chunker
episode = Episode.services.factory.upload(
podcast=self.podcast,
audio_file_path=test_audio_file_path()
)
self.assertIsNotNone(episode)
self.assertIsNotNone(episode.transcription)
self.assertGreater(episode.transcription.words.count(), 0)
self.assertGreater(episode.transcription.segments.count(), 0)
class EpisodeServiceIntelligenceTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
self.episode = create_test_episode(
podcast=create_test_podcast(),
transcription=create_test_transcription()
)
def test_generate_details(self):
details = self.episode.services.intelligence.generate_details()
self.assertIsNotNone(details)
# Assert specific properties of details
class TranscriptionServiceProcessorTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.transcription = create_test_transcription()
def test_find_chunk(self):
chunk = self.transcription.services.processor.find_chunk(2, 100)
self.assertIsNotNone(chunk)
self.assertIsInstance(chunk, str)
def upload(self, podcast: Podcast, audio_file_path: Path):
# Create transcription
transcription = Transcription.services.factory.create_from_audio_file_path(audio_file_path)
# Create episode
self.obj.services.save_model_obj(
podcast=podcast,
transcription=transcription,
name=audio_file_path.name.split('.')[0],
audio_file_path=audio_file_path,
episode_number=podcast.episodes.all().count() + 1
)
# Chain transformations
self.obj.services.transformation.to_wav()
KeyConcept.services.factory.create_from_episode(self.obj)
self.obj.services.transformation.to_audacity_labels()
return self.obj
class TranscriptionProcessorService(BaseDjangoModelService['Transcription']):
obj: Transcription
# Class-level operation (no specific instance)
@staticmethod
def transcribe_audio(audio_file_path: Path) -> AudioChunker:
# Use AudioChunker directly, not self.obj
# Instance-level operation (specific instance)
def find_chunk(self, start_seconds: float, end_seconds: float) -> str:
# Use self.obj for queries
words = self.obj.words.filter(...)
class EpisodeService(BaseDjangoModelService['Episode']):
obj: Episode
# Compose sub-services
factory = EpisodeFactoryService()
transformation = EpisodeTransformationService()
intelligence = EpisodeIntelligenceService()
class EpisodeTransformationService(BaseDjangoModelService['Episode']):
obj: Episode
def to_audacity_labels(self) -> str:
labels = self._generate_labels()
self._write_labels_file(labels)
return labels
def _generate_labels(self) -> str:
return "".join([
k.services.transformation.to_audacity_label()
for k in self.obj.key_concepts.all()
])
def _write_labels_file(self, labels: str):
file_path = self.obj.audio_path.with_name('audacity_labels.txt')
with open(file_path.absolute(), "w") as f:
f.write(labels)
{service_type}_service.pyfrom __future__ import annotationsTYPE_CHECKING guardsBaseDjangoModelService['ModelName']create_or_activate not get_or_create_for_user)save_model_obj(**field_data): Always pass field names as parameters and unpack the tuple: obj, is_created = obj.services.save_model_obj(field=value)service.py as sub-servicestest_{service_type}_service.pytest-app tool