Service Layer
Purpose: give every domain model a predictable “service layer” so business rules live next to the data and are invoked as naturally as
Task.objects.
1 · Why a Service Layer?
Django’s flexibility often scatters business logic across views, utils, managers, and helpers. A dedicated service layer fixes that by pinning every rule to the model that owns it. Each model exposes a services descriptor that:
- groups validation, persistence, and side‑effects in one place
- is easy to reach from anywhere the model is reached —
task.services.factory.duplicate(request) - keeps code modular and easy to test
- avoids circular‑import headaches through future annotations and
TYPE_CHECKINGguards
The example below uses a simple Task model from the reference project.
2 · What the BaseDjangoModelService Gives You
| Method | Purpose |
|---|---|
save_model_obj(**field_data) |
The core save method. Updates self.obj from the kwargs, saves, and handles M2M fields — atomically. |
save_model_obj() emulates Django’s BaseModelForm.save() as closely as possible:
_set_non_m2m_fieldssets the given fields onself.obj(same logic asdjango.forms.models.construct_instance)self.obj.save()_set_m2m_fieldssets M2M fields (same logic asBaseModelForm._save_m2m())
Behaviours to know:
- it is wrapped in
@transaction.atomic, so a failed save rolls everything back - it performs no validation — data is assumed to have been validated upstream (e.g. by a form), matching how
BaseModelFormtreatscleaned_data - it also saves attribute changes made directly on the instance, so
save_model_obj()with no kwargs behaves likeModel.save() - foreign keys accept the
_idalias —save_model_obj(creator_id=42) - file-type fields are deferred until after the other fields so a callable
upload_tocan use them - it returns
tuple[Model, bool]— the saved object and whether it was created
# Returns (saved_obj, was_created)
task, created = task.services.save_model_obj(name='New task', status='new')
3 · Building a TaskService
3.1 Files & Directories
One package per model, one file per service:
task/
├── models.py
└── services/
├── __init__.py
├── service.py # TaskService (primary)
├── factory_service.py # TaskFactoryService (create new objects)
├── processor_service.py # TaskProcessorService (act on the object)
└── transformation_service.py # TaskTransformationService (turn it into something else)
3.2 The Model
# task/models.py
from __future__ import annotations
from typing import TYPE_CHECKING
from django.db import models
if TYPE_CHECKING:
from task.services.service import TaskService
class Task(models.Model):
name = models.CharField(max_length=200)
is_done = models.BooleanField(default=False)
services = TaskService()
def __str__(self) -> str:
return self.name
3.3 The Service (and sub‑services!)
# task/services/service.py
from __future__ import annotations
from typing import TYPE_CHECKING
from django_spire.contrib.constructor.service import BaseDjangoModelService
from task.services.factory_service import TaskFactoryService
if TYPE_CHECKING:
from task.models import Task
class TaskService(BaseDjangoModelService['Task']):
# target model — must be first
obj: Task
# followed by all sub services
factory = TaskFactoryService()
# task/services/factory_service.py
from __future__ import annotations
from typing import TYPE_CHECKING
from django_spire.contrib.constructor.service import BaseDjangoModelService
if TYPE_CHECKING:
from task.models import Task
class TaskFactoryService(BaseDjangoModelService['Task']):
obj: Task
def duplicate(self) -> Task:
new_task, _ = self.obj_class.services.save_model_obj(
name=f'{self.obj.name} (Copy)',
is_done=False,
)
return new_task
Every class in the chain must annotate obj with the same target model; the constructor validates that and raises ConstructorError on mismatch.
4 · Common Service Files
| File path | Class | Responsibility |
|---|---|---|
services/service.py |
TaskService |
Parent service class that links sub services |
services/factory_service.py |
TaskFactoryService |
Create new objects from an existing one |
services/processor_service.py |
TaskProcessorService |
Perform actions on that object |
services/transformation_service.py |
TaskTransformationService |
Turn the object into new forms of other objects |
Each secondary service begins with obj: Task so it plugs into the same descriptor system.
5 · Instance‑ vs Class‑Level Access
from task.models import Task
# Instance‑level use – operate on one concrete record
task = Task.objects.get(pk=42)
copy = task.services.factory.duplicate()
# Class‑level use – no row yet, or act on many rows
# The descriptor fabricates a "null" Task (pk = None) behind the scenes,
# applies defaults, then runs the service logic.
Task.services.factory.clean_dead_tasks()
When to pick which
| Use‑case | Call form | Why it makes sense |
|---|---|---|
| Work on one existing row | task.services.factory.duplicate() |
You already have the instance; the service mutates it and persists changes. |
| Run bulk / maintenance logic or logic before a row exists | Task.services.factory.clean_dead_tasks() |
You need the behaviour but not a specific row to start from; the service works from a null instance. |
6 · Accessing the Model Class from a Service
You may need the model class itself to run queries from inside a service. The service exposes it as self.obj_class:
# task/services/processor_service.py
from __future__ import annotations
from typing import TYPE_CHECKING
from django_spire.contrib.constructor.service import BaseDjangoModelService
if TYPE_CHECKING:
from task.models import Task
class TaskProcessorService(BaseDjangoModelService['Task']):
obj: Task
def mark_stale(self) -> list[Task]:
stale_tasks = self.obj_class.objects.filter(is_done=False, created_datetime__lt='2020-01-01')
return list(stale_tasks)
obj_class resolves to the model class for whatever instance the service is bound to, so it also works at class level.
7 · Exposing Services to Templates with Glue
Service methods can be bound straight into templates with Glue.attr:
from django_glue import Glue
from task.services.factory_service import TaskFactoryService
class TaskService(BaseDjangoModelService['Task']):
obj: Task
factory = Glue.attr(TaskFactoryService(), required_access=Glue.Access.CHANGE)
See the Django Glue documentation for the client side.