25.6 Separation of Concerns
Table of Contents
Why Separation of Concerns Matters
Separation of Concerns (SoC) is the idea of splitting a system into parts where each part has one clear responsibility. In backend development this helps you:
- Understand code faster
- Change one part without breaking everything
- Test pieces independently
- Replace or improve parts over time
You will see SoC appear in many other chapters, for example Layered Architecture, Service Layer, Repository Pattern, and Domain-Driven Design. Here we focus on the general idea and how to apply it in everyday backend code.
Key idea:
Each module, class, or function should deal with one main concern and hide its internal details from the rest of the system.
A “concern” can be:
- A feature area, such as authentication or payments
- A technical responsibility, such as database access or HTTP handling
- A cross-cutting responsibility, such as logging or validation
You do not need a complex architecture diagram to use SoC. You can start with small code decisions in simple projects.
Concerns in a Typical Backend
To see what “concerns” look like, imagine a simple REST API for a todo app. Even in a small app we already have several different responsibilities:
| Concern | Example responsibility |
|---|---|
| HTTP / Web API | Receive requests, send responses, map URLs to handlers |
| Validation | Check request data, enforce required fields |
| Business logic | Define rules, such as “cannot complete already deleted” |
| Persistence / database | Save, update, delete, and load todos |
| Authentication | Check who the user is |
| Authorization | Check what the user is allowed to do |
| Logging | Record events and errors |
| Error handling | Convert errors to meaningful HTTP responses |
If you mix all of these together inside one function or file, you violate SoC and your code quickly becomes fragile.
A Bad Example: All Concerns Mixed Together
Consider a FastAPI style endpoint that does everything at once:
@app.post("/todos")
def create_todo(request: Request):
# Parse body
body = request.json()
# Validate
if "title" not in body or not body["title"]:
return JSONResponse({"error": "Title is required"}, status_code=400)
# Auth
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
token = auth_header.split(" ", 1)[1]
# Very naive "auth"
user_id = decode_token(token) # Might raise exception
# Business rule
if len(body["title"]) > 200:
return JSONResponse({"error": "Title too long"}, status_code=400)
# Database
conn = sqlite3.connect("db.sqlite")
cursor = conn.cursor()
cursor.execute(
"INSERT INTO todos (user_id, title, completed) VALUES (?, ?, ?)",
(user_id, body["title"], False),
)
conn.commit()
todo_id = cursor.lastrowid
conn.close()
# Response
return JSONResponse(
{"id": todo_id, "title": body["title"], "completed": False},
status_code=201,
)Problems:
- HTTP, validation, auth, business rules, and DB code are all in one function.
- Changing the database means editing the endpoint.
- Reusing business rules elsewhere is difficult.
- Testing business rules requires setting up HTTP and a database.
This is a classic SoC violation.
A Better Example: Splitting Concerns
Now separate responsibilities into focused pieces.
Step 1: Define a Domain Model
A domain model describes your core business concepts. This is part of the “domain concern” and should not know about HTTP or databases.
from dataclasses import dataclass
@dataclass
class Todo:
id: int | None
user_id: int
title: str
completed: bool = FalseThis model only describes data and meaning, not how it is stored or transported.
Step 2: Repository for Database Access
The repository handles the persistence concern. It provides methods to store and load Todo objects.
import sqlite3
from typing import Protocol, Iterable
class TodoRepository(Protocol):
def create(self, todo: Todo) -> Todo: ...
def list_for_user(self, user_id: int) -> Iterable[Todo]: ...
class SqliteTodoRepository:
def __init__(self, db_path: str):
self.db_path = db_path
def _get_connection(self):
return sqlite3.connect(self.db_path)
def create(self, todo: Todo) -> Todo:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO todos (user_id, title, completed) VALUES (?, ?, ?)",
(todo.user_id, todo.title, todo.completed),
)
conn.commit()
todo.id = cursor.lastrowid
conn.close()
return todo
def list_for_user(self, user_id: int):
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT id, user_id, title, completed FROM todos WHERE user_id = ?",
(user_id,),
)
rows = cursor.fetchall()
conn.close()
return [
Todo(id=row[0], user_id=row[1], title=row[2], completed=bool(row[3]))
for row in rows
]Now, if you switch to PostgreSQL or an ORM, you only touch this repository, not your business logic or controllers.
Step 3: Service for Business Logic
The service handles the business rules concern. It uses the repository but does not know anything about HTTP or JSON.
class TodoService:
def __init__(self, repo: TodoRepository):
self.repo = repo
def create_todo(self, user_id: int, title: str) -> Todo:
if not title:
raise ValueError("Title is required")
if len(title) > 200:
raise ValueError("Title too long")
todo = Todo(id=None, user_id=user_id, title=title, completed=False)
return self.repo.create(todo)
def list_todos(self, user_id: int):
return self.repo.list_for_user(user_id)
You can now test TodoService completely in memory with a fake repository.
Step 4: Auth Concern
Create a small utility to handle authentication concerns.
class AuthError(Exception):
pass
def get_user_id_from_token(token: str) -> int:
# In real code, verify signature, expiration, etc.
if token == "invalid":
raise AuthError("Invalid token")
return int(token)This can later be replaced by a more complex auth system without rewriting business logic.
Step 5: HTTP Layer / Controller
Finally, the endpoint focuses on HTTP and request/response mapping, and delegates the rest.
from fastapi import FastAPI, Depends, Header, HTTPException
from pydantic import BaseModel
app = FastAPI()
class CreateTodoRequest(BaseModel):
title: str
class TodoResponse(BaseModel):
id: int
title: str
completed: bool
def get_repo():
return SqliteTodoRepository("db.sqlite")
def get_service(repo: TodoRepository = Depends(get_repo)):
return TodoService(repo)
def get_current_user_id(authorization: str = Header(None)) -> int:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
token = authorization.split(" ", 1)[1]
try:
return get_user_id_from_token(token)
except AuthError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
@app.post("/todos", response_model=TodoResponse, status_code=201)
def create_todo(
body: CreateTodoRequest,
user_id: int = Depends(get_current_user_id),
service: TodoService = Depends(get_service),
):
try:
todo = service.create_todo(user_id=user_id, title=body.title)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return TodoResponse(id=todo.id, title=todo.title, completed=todo.completed)Now each part has one main concern:
- Endpoint: HTTP and wiring
- Auth helper: authentication
- Service: business rules
- Repository: database operations
- Model: domain representation
This is Separation of Concerns in action.
Separation of Concerns at Different Levels
SoC applies at many levels of your backend.
Project / Package Level
You can group code by concerns using packages or directories.
app/
api/ # HTTP layer: routes, controllers, DTOs
auth/ # Authentication & authorization
domain/ # Domain models & business rules
persistence/ # Repositories, database code
services/ # Application services / use cases
config/ # Configuration & settingsEven in small projects, this structure keeps unrelated concerns apart.
Module Level
Within a module, you can keep concerns separate.
Bad example: todo.py mixing everything:
# todo.py
from fastapi import APIRouter
import sqlite3
# routes, database code, models, and services all in here...Better:
# domain/todo.py
@dataclass
class Todo: ...
# services/todo_service.py
class TodoService: ...
# persistence/sqlite_todo_repository.py
class SqliteTodoRepository(TodoRepository): ...
# api/todo_routes.py
router = APIRouter()
@router.post("/todos") ...Class and Function Level
Even inside a class, you should separate concerns.
Bad: one function does 5 things.
def process_order(request):
# parse, validate, authorize, compute price, charge card, send email, save to DB...
...Better: split into focused functions:
def validate_order_data(data): ...
def calculate_price(items): ...
def charge_payment(payment_info, amount): ...
def save_order(order): ...
def send_confirmation_email(order): ...The HTTP handler simply calls these pieces in order.
Separation of Concerns vs DRY and Single Responsibility
SoC is related to other design principles.
DRY (Don’t Repeat Yourself)
DRY says:
Do not duplicate knowledge.
Each piece of knowledge in your system should have one unambiguous representation.
Example:
- Validation rules for todo title should not be copied in several endpoints.
- Place them in one place, such as
TodoServiceor a dedicated validator.
SoC helps you organize where that single representation lives.
Single Responsibility Principle (SRP)
SRP says:
A class or module should have one reason to change.
This is very similar to SoC, but SRP usually applies at the class or function level, while SoC can apply to the whole system.
Example:
- A
UserServiceshould change if your business rules about users change. - It should not have to change when you change from SQLite to PostgreSQL.
That means user persistence should live in a separate repository.
Common Concerns to Separate in Backends
Here are typical backend concerns and what they usually contain:
| Concern | Typical contents |
|---|---|
| HTTP / API | Routes, controllers, request/response models, status codes |
| Domain / Business | Domain models, business rules, use cases |
| Persistence | ORM models, repositories, database sessions |
| Validation | Input validators, schema validation, domain invariants |
| Authentication / Authz | Token parsing, user identity, permission checks |
| Configuration | Settings, environment variables, configuration loading |
| Logging | Logging setup, log formatting, log sinks |
| Error handling | Custom exceptions, global exception handlers |
| Background tasks | Job definitions, task schedulers, worker code |
| Integration | HTTP clients for other services, external APIs |
You will see many of these again in later chapters.
Cross-Cutting Concerns and How to Handle Them
Cross-cutting concerns are concerns that touch many parts of your system, for example:
- Logging
- Tracing and metrics
- Caching
- Security checks
- Transaction management
If you put these directly into your business logic functions, SoC breaks.
Example: Logging as a Cross-Cutting Concern
Bad:
def create_todo(user_id, title):
logger.info("Creating todo", extra={"user_id": user_id, "title": title})
# business logic...Better: use a middleware or decorator in the HTTP layer to log incoming requests, or use infrastructure code that wraps service calls, so services stay focused on business rules.
Example: Transaction Management
Instead of starting and committing database transactions inside every service method, handle them in a higher layer, for example:
def with_transaction(service_method):
def wrapper(*args, **kwargs):
session = Session()
try:
result = service_method(*args, session=session, **kwargs)
session.commit()
return result
except:
session.rollback()
raise
return wrapperNow services focus on domain logic and leave transaction boundaries to infrastructure.
Trade-offs: When to Keep It Simple
Separation of Concerns is powerful, but you can also overdo it, especially in very small projects.
Signs You Went Too Far
- Many tiny layers and abstractions for a 2-endpoint script.
- Difficult to follow the call chain because everything is wrapped 5 times.
- You spend more time wiring dependencies than writing business logic.
A Practical Rule
For beginners and small apps:
Separate concerns until:
- Each class or module has a clear purpose, and
- You can change the database, HTTP framework, or auth mechanism without rewriting everything.
Avoid building a full enterprise architecture if you only have a few endpoints.
Refactoring Toward Separation of Concerns
You rarely design perfect separation from the beginning. Instead you refactor as the project grows.
A simple refactoring process:
- Find mixed concerns
Look for big functions that do HTTP, DB, and business logic together. - Extract the domain logic
Move business rules into pure functions or services that do not know about HTTP or database details. - Extract persistence
Move raw SQL or ORM queries into a repository layer. - Extract shared concerns
Move repeated auth, validation, or error-handling logic into separate modules or middlewares. - Write tests around the separated pieces
Test business logic with in-memory fakes and no framework.
Example: Simple Refactor
Original endpoint:
@app.post("/orders")
def create_order(request: Request):
data = request.json()
# validate
# compute price
# load products from DB
# check stock
# insert order and order items
# send emailRefactor steps:
- Extract validation into
validate_order_request(data). - Extract price calculation into
calculate_order_price(items). - Move DB operations into an
OrderRepository. - Move stock checks and order creation into
OrderService. - Move email sending into a background job or
EmailService.
Now the endpoint becomes a thin layer:
@app.post("/orders")
def create_order(data: CreateOrderRequest, service: OrderService = Depends(...)):
order = service.place_order(user_id=current_user.id, items=data.items)
return OrderResponse.from_domain(order)How Separation of Concerns Helps You Later
Separation of Concerns makes many backend tasks easier:
- Testing
You can test business logic without HTTP, and repositories with in-memory databases. - Switching persistence
Moving from PostgreSQL to another database affects only the repository. - Moving from monolith to microservices
If your domain and application logic are well separated from HTTP and persistence, you can move pieces out more easily. - Scaling teams
Different developers can work on different concerns: one on domain logic, another on API, another on database tuning. - Using patterns
Patterns like Service Layer, Repository Pattern, and Layered Architecture are all about formalizing SoC.
As you continue through this course, try to recognize where Separation of Concerns is being used and how it could help keep your own projects maintainable as they grow.
Views: 7
KAHIBARO