services
Services + svcs Dependency Injection
This project separates Django's framework concerns from business logic using a plain service layer, wired with the svcs service locator. The result:
- Views are one-liners. They pull a wired service and call a method.
- Services contain the business logic. They take repositories (and other services) via
__init__, call methods on them, and return DTOs. - Services never import Django ORM or models. Every test can run without a database.
- One registry, one
get[T]()helper. The same call works in views, tasks, commands, anywhere.
Why svcs Instead of Module-Level Singletons or a Custom Container
svcsis a tiny, typed, well-maintained service locator — no metaclasses, no decorators, no framework coupling.- Factories are lazy: a service is constructed only when something asks for it.
- Generic
get[T](type[T]) -> Tpreserves types through IDE/type-checker inference. - Swapping an implementation in tests is a one-line factory override.
- No import-order gymnastics: the registry is populated once at startup and then used by name.
The Registry — src/project/services.py
import svcs
from products.repositories.product import ProductRepository
from products.services.product import ProductService
from orders.repositories.order import OrderRepository
from orders.services.order import OrderService
registry = svcs.Registry()
# --- Repositories ---------------------------------------------------------
registry.register_factory(ProductRepository, ProductRepository)
registry.register_factory(OrderRepository, OrderRepository)
# --- Services (factories pull repos from the container) ------------------
def _product_service_factory(container: svcs.Container) -> ProductService:
repo = container.get(ProductRepository)
return ProductService(repo)
def _order_service_factory(container: svcs.Container) -> OrderService:
repo = container.get(OrderRepository)
product_repo = container.get(ProductRepository)
return OrderService(repo, product_repo)
registry.register_factory(ProductService, _product_service_factory)
registry.register_factory(OrderService, _order_service_factory)
def get[T](service_type: type[T]) -> T:
"""Resolve a service from the registry. Works anywhere — views, tasks, commands, tests."""
return svcs.Container(registry).get(service_type)
Patterns to follow:
- Repositories register themselves. Use
register_factory(Repo, Repo)— the class is its own factory because repositories take no constructor arguments. - Services register via a named factory.
_<entity>_service_factory(container)resolves every dependency from the container and hands it to the service's__init__. No hidden imports, no module-level singletons. - Register in dependency order. Repos before services, lower-level services before higher-level ones.
svcsdoesn't enforce this, but it keeps the file readable. - One registry per project. Don't create ad-hoc registries — everything goes through
project.services.registry.
Writing a Service
File: src/<app>/services/<entity>.py
from decimal import Decimal
from typing import List
from ..dtos.product import ProductDTO
from ..repositories.product import ProductRepository
class ProductService:
def __init__(self, repo: ProductRepository):
self.repo = repo
def create_product(self, name: str, price: Decimal, stock: int) -> ProductDTO:
return self.repo.create(name=name, price=price, stock=stock)
def get_product(self, product_id: str) -> ProductDTO:
return self.repo.get_by_id(product_id)
def list_products(self) -> List[ProductDTO]:
return self.repo.list_all()
Rules:
- Dependencies come in through
__init__. The service never instantiates its own repositories or services. If a service needs another service, pass it in. - Zero ORM. No
.objects, noF()/Q(), no model imports, noselect_related. All database access goes through a repository. - Every public method returns a DTO or
list[DTO]. Never a model instance, never a queryset. - ID arguments are
str. See theprefixed-ulidsskill. - Business rules live here. Validation, orchestration across repositories, invariant checks, error raising — all of it.
- Services are stateless. They hold references to their dependencies and nothing else. No caches, no counters, no module-level state.
- Raise plain exceptions. Use
ValueError,PermissionError, domain-specific exceptions — notHttp404or anything Django-flavored. The view layer turns them into HTTP responses.
Cross-Entity Logic
When a service method touches more than one aggregate — e.g. creating an order that decrements product stock — inject both repositories and orchestrate them. Example from OrderService:
class OrderService:
def __init__(self, repo: OrderRepository, product_repo: ProductRepository):
self.repo = repo
self.product_repo = product_repo
def create_order(self, items: List[Dict[str, Any]]) -> OrderDTO:
for item in items:
product = self.product_repo.get_by_id(item["product_id"])
if product.stock < item["quantity"]:
raise ValueError(
f"Insufficient stock for product {product.name}: "
f"requested {item['quantity']}, available {product.stock}"
)
order = self.repo.create(items=items)
for item in items:
self.product_repo.decrement_stock(item["product_id"], item["quantity"])
return order
Notes:
- The service orchestrates two repositories but touches zero ORM.
- If a multi-repo write needs atomicity, wrap it in
with transaction.atomic():— that's one of the very fewdjango.dbimports allowed in a service. - Never call another service from inside a service unless that service is explicitly injected. No hidden
get(...)calls inside service methods.
Resolving a Service
From a view (django-ninja)
from project.services import get
from project.types import AuthedRequest
@products_router.post("/", response={201: ProductDTO})
def create_product(request: AuthedRequest, payload: CreateProductIn):
service = get(ProductService)
return Status(201, service.create_product(**payload.dict()))
Annotating request as AuthedRequest (defined in src/project/types.py) makes the auth contract explicit and narrows request.user to a guaranteed-authenticated Django User. This is a typing contract, not runtime enforcement — auth is still expected to be wired via middleware or ninja's auth= parameter.
From a Celery task
from celery import shared_task
from project.services import get
from products.services.product import ProductService
@shared_task
def reprice_product(product_id: str, new_price: str) -> None:
service = get(ProductService)
service.update_price(product_id, Decimal(new_price))
From a management command
from django.core.management.base import BaseCommand
from project.services import get
from products.services.product import ProductService
class Command(BaseCommand):
def handle(self, *args, **options):
service = get(ProductService)
for dto in service.list_products():
self.stdout.write(dto.name)
The same get() call works in all three contexts because the registry is global and the container is cheap to construct.
Testing
Services are tested without a database. Pass in a MagicMock for each repository, configure its return values, and assert on the service's behavior.
from decimal import Decimal
from unittest.mock import MagicMock
import pytest
from products.dtos.product import ProductDTO
from products.services.product import ProductService
def test_create_product_delegates_to_repo():
repo = MagicMock()
expected = ProductDTO(id="prd_fake", name="Widget", price=Decimal("9.99"), stock=5)
repo.create.return_value = expected
service = ProductService(repo)
result = service.create_product(name="Widget", price=Decimal("9.99"), stock=5)
assert result is expected
repo.create.assert_called_once_with(name="Widget", price=Decimal("9.99"), stock=5)
def test_create_order_rejects_insufficient_stock():
order_repo = MagicMock()
product_repo = MagicMock()
product_repo.get_by_id.return_value = ProductDTO(
id="prd_fake", name="Widget", price=Decimal("9.99"), stock=1
)
service = OrderService(order_repo, product_repo)
with pytest.raises(ValueError, match="Insufficient stock"):
service.create_order(items=[{"product_id": "prd_fake", "quantity": 5}])
order_repo.create.assert_not_called()
This is the most important test layer — it proves the business logic is correct independently of Django, migrations, fixtures, or the database. If a service's tests need @pytest.mark.django_db, something has leaked: find the ORM call and push it back into a repository.
Overriding a Service in Tests
For integration tests that go through the API, override a factory to substitute a fake or a stub:
from project.services import registry
from products.services.product import ProductService
@pytest.fixture
def fake_product_service():
fake = MagicMock(spec=ProductService)
registry.register_factory(ProductService, lambda _: fake)
yield fake
# svcs re-registers on next call; reset to the real factory if other tests need it.
Common Mistakes
- Importing models in a service. If you see
from app.models import X, the service is doing ORM work. Move it to the repository. - Calling
SomeRepository()inside a service method. Inject it via__init__and hold the reference. - Returning querysets or model instances from a service. Always return DTOs.
- Putting business logic in the view. The view should only decode input, call
get(SomeService).method(...), and pass the result back. - Registering a service with
register_factory(Service, Service). That only works for repositories because they take no arguments. Services need a factory that resolves their dependencies. - Reaching into
request.userfrom the service. Pass the caller's identity as an explicit argument (user_id: str) so the service stays framework-agnostic.
Verify
- Every service under
src/<app>/services/has an__init__that takes its dependencies explicitly. - No file under
src/<app>/services/imports fromdjango.db.models,<app>.models, or uses.objects. - Every service in the project is registered in
src/project/services.pywith a factory that resolves its dependencies from the container. - Service tests do not use
@pytest.mark.django_db.
uv run ruff check src
uv run pyrefly check src
uv run pytest
More from dvf/opinionated-django
architecture
Implement a Django feature following the opinionated architecture — prefixed ULID IDs, repository pattern, Pydantic DTOs, svcs service locator, project-scoped django-ninja API, Celery reliable signals, and layered tests. Use when the user asks to add a new entity, endpoint, app, or business logic in a Django project that follows these conventions.
11lint
Run linting, formatting, and static type checks on a Django project using ruff and pyrefly, and fix any issues found. Use after making code changes, before committing, or whenever the user asks to lint, format, or type-check the codebase.
10pytest
Set up and write pytest tests for an op-django project — pytest-django configuration, Celery eager mode for reliable-signal tests, freezegun for time-sensitive logic, shared conftest fixtures for DTOs and svcs overrides, and the three-layer test convention (repository against a real DB, service against mocked repos, API through HTTP). Use when adding tests to a new project, writing tests for a new feature, setting up test infrastructure, or explaining how tests should be organized.
10scaffold
Set up a Django project into the op-django layout so the architecture, signals, and settings skills have a foundation to build on. Use when starting a new project from scratch, or when converting an existing Django project to follow this opinionated structure. Creates the src/project/ shell (ids, services registry, api, reliable signals), installs dependencies with uv, and establishes the per-app directory conventions.
10signals
Add reliable signals (async side-effects via Celery) to a Django feature. Use when a business operation needs to trigger post-commit work like notifications, cache invalidation, analytics, or cross-service coordination — any time the user mentions side-effects, events, or async processing tied to a business action.
10settings
Organize Django settings.py into clearly sectioned blocks with banner-style headers. Use proactively whenever modifying src/project/settings.py — adding new settings, removing settings, or restructuring sections.
10