Service Layer Pattern
The service layer contains business logic — the "what should happen" — separate from the "how to store it" (repository) and the "how to expose it over HTTP" (router). It makes your code testable without an HTTP client or a real database.
Learning Focus
By the end of this lesson you can: write a service class that orchestrates repositories and external services, inject it via Depends, and test it independently from the HTTP layer.
Order Service Example
app/services/order_service.py
from app.repositories.order_repo import OrderRepository
from app.repositories.product_repo import ProductRepository
from app.models.order import OrderCreate, OrderResponse
from app.core.exceptions import ProductNotFoundError, InsufficientStockError
class OrderService:
def __init__(
self,
order_repo: OrderRepository,
product_repo: ProductRepository,
) -> None:
self._orders = order_repo
self._products = product_repo
async def place_order(self, data: OrderCreate, user_id: int) -> OrderResponse:
"""Business rule: validate stock, calculate total, create order."""
total = 0.0
for line in data.lines:
product = await self._products.get(line.product_id)
if not product:
raise ProductNotFoundError(line.product_id)
if product.stock < line.quantity:
raise InsufficientStockError(line.product_id, line.quantity, product.stock)
total += line.quantity * product.price
order = await self._orders.create(
user_id=user_id,
lines=[l.model_dump() for l in data.lines],
total_amount=round(total, 2),
status="pending",
)
return OrderResponse.model_validate(order)
async def cancel_order(self, order_id: int, user_id: int) -> None:
"""Business rule: only pending orders can be cancelled."""
order = await self._orders.get(order_id)
if not order or order.user_id != user_id:
raise OrderNotFoundError(order_id)
if order.status != "pending":
raise OrderNotCancellableError(order_id, order.status)
await self._orders.update(order_id, {"status": "cancelled"})
Injecting the Service
app/routers/orders.py
from typing import Annotated
from fastapi import APIRouter, Depends
from app.services.order_service import OrderService
from app.repositories.order_repo import OrderRepository
from app.repositories.product_repo import ProductRepository
from app.dependencies.auth import CurrentUser
router = APIRouter(prefix="/orders", tags=["orders"])
def get_order_service(db: DBSession) -> OrderService:
return OrderService(
order_repo=OrderRepository(db),
product_repo=ProductRepository(db),
)
OrderSvc = Annotated[OrderService, Depends(get_order_service)]
@router.post("/", response_model=OrderResponse, status_code=201)
async def place_order(
data: OrderCreate,
svc: OrderSvc,
user: CurrentUser,
) -> OrderResponse:
return await svc.place_order(data, user.id)
@router.delete("/{order_id}", status_code=204)
async def cancel_order(order_id: int, svc: OrderSvc, user: CurrentUser) -> None:
await svc.cancel_order(order_id, user.id)
Testing the Service Without HTTP
tests/test_order_service.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from app.services.order_service import OrderService
from app.core.exceptions import InsufficientStockError
@pytest.fixture
def order_service():
order_repo = AsyncMock()
product_repo = AsyncMock()
return OrderService(order_repo, product_repo), order_repo, product_repo
@pytest.mark.asyncio
async def test_place_order_insufficient_stock(order_service):
svc, order_repo, product_repo = order_service
product_repo.get.return_value = MagicMock(stock=1, price=9.99)
from app.models.order import OrderCreate, OrderLine
data = OrderCreate(lines=[OrderLine(product_id=1, quantity=5)])
with pytest.raises(InsufficientStockError):
await svc.place_order(data, user_id=1)
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
Service knows about Request | HTTP concerns in business logic | Services should never import from fastapi |
Service calls db.execute() directly | Bypasses repository abstraction | All DB calls go through repository methods |
| Service too thin | Just passes through to repo | If service only calls one repo method, combine with repository |
| Too many service dependencies | Constructor grows unwieldy | Break into smaller, focused services |
| Service not tested directly | Only tested via HTTP | Write direct service unit tests with mocked repos |