Skip to content

Service Layer

Purpose: provide every domain model with a predictable “service layer” so business rules live next to 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
  • easy access from model task.services.notification.send_created()
  • keeps code modular for easy testing
  • avoids circular‑import headaches through future annotations and TYPE_CHECKING guards

The example below uses a simple Task model.


2 · What the BaseService Gives You

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()

3 · Building a TaskService

3.1 Files & Directories

tasks/
├── models.py
└── services/
    ├── service.py              # TaskService (primary)
    └── notification_service.py  # TaskNotificationService (secondary)

3.2 The Model

# 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

3.3 The Service (and sub‑service!)

# tasks/service/services.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.service.notification_service import TaskNotificationService
    from app.tasks.service.processor_service import TaskProcessorService

class TaskService(BaseDjangoModelService['Task']):    
    # target model — must be first
    obj: Task 

    # followed by all sub services
    notification = TaskNotificationService()
    processor = TaskProcessorService()
# tasks/service/services.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 TaskProcessorService(BaseDjangoModelService['Task']):    
    obj: Task 

    def mark_done(self) -> Task:
        self.obj.is_done = True
        self.obj.save()            
        return self.obj

4 · Common Service Files

File path Class Responsibility
service/services.py TaskService Parent service class that links sub services
service/notification_service.py TaskNotificationService Deliver messages triggered by task events
service/transformation_service.py TaskTransformationService Turn objects into new forms of other objects
service/processor_service.py TaskProcessorService Processes actions on that object

Each secondary service begins with task: Task so it plugs into the same descriptor system.


5 · Class‑ vs Instance‑Level Access

from app.tasks.models import Task

# Instance‑level use – operate on one concrete record
task = Task.objects.get(pk=42)
after = task.services.mark_done()  

# 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.automation.clean_dead_tasks()

When to pick which

Use‑case Call form Why it makes sense
Work on one existing row task.services.mark_done() You already have the instance; the service mutates it and persists changes.
Run bulk / maintenance logic or logic before a row exists Task.services.automation.clean_dead_tasks() You need the behaviour but not a specific row to start from; the service will create its own temporary Task or iterate over many.

6 · Accessing Model Class in Service

  • When working inside a service, you may need access to the model class itself to perform database queries.

  • The service initialization automatically provides this by setting the model class as an attribute matching the model name.

In the background, our base service sets the target object class as an attribute by the class name.

Here's how to use it:

# tasks/service/services.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')
        ...