Skip to main content

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

PitfallCause / SymptomFix
Service knows about RequestHTTP concerns in business logicServices should never import from fastapi
Service calls db.execute() directlyBypasses repository abstractionAll DB calls go through repository methods
Service too thinJust passes through to repoIf service only calls one repo method, combine with repository
Too many service dependenciesConstructor grows unwieldyBreak into smaller, focused services
Service not tested directlyOnly tested via HTTPWrite direct service unit tests with mocked repos

What's Next