django_spire/core/management/commands/spire_opencode_pkg/skills/service-layer/SKILL.md
Service layer best practices on django models
npx skillsauth add stratusadv/django-spire service-layerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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() | 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. |
Service Pipeline Pattern:
By channeling all persistence through save_model_obj(), developers ensure that:
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:
"""Scan for overdue tasks and send notifications. Returns count sent.
Designed for class-level calls: Task.services.automation.send_overdue_notifications().
"""
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)
from __future__ import annotations at the top of service filesif TYPE_CHECKING: blocks to avoid circular importsdevelopment
How to implement table templates in Django Spire.
development
How to implement tab templates in Django Spire.
development
Best practices for seeding django models
development
Best practice for working with Django models