KAHIBARO
Discord Login Register

25.6 Separation of Concerns

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:

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:

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:

ConcernExample responsibility
HTTP / Web APIReceive requests, send responses, map URLs to handlers
ValidationCheck request data, enforce required fields
Business logicDefine rules, such as “cannot complete already deleted”
Persistence / databaseSave, update, delete, and load todos
AuthenticationCheck who the user is
AuthorizationCheck what the user is allowed to do
LoggingRecord events and errors
Error handlingConvert 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:

python
@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:

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.

python
from dataclasses import dataclass
@dataclass
class Todo:
    id: int | None
    user_id: int
    title: str
    completed: bool = False

This 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.

python
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.

python
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.

python
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.

python
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:

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.

text
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 & settings

Even 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:

python
# todo.py
from fastapi import APIRouter
import sqlite3
# routes, database code, models, and services all in here...

Better:

python
# 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.

python
def process_order(request):
    # parse, validate, authorize, compute price, charge card, send email, save to DB...
    ...

Better: split into focused functions:

python
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:

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:

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:

ConcernTypical contents
HTTP / APIRoutes, controllers, request/response models, status codes
Domain / BusinessDomain models, business rules, use cases
PersistenceORM models, repositories, database sessions
ValidationInput validators, schema validation, domain invariants
Authentication / AuthzToken parsing, user identity, permission checks
ConfigurationSettings, environment variables, configuration loading
LoggingLogging setup, log formatting, log sinks
Error handlingCustom exceptions, global exception handlers
Background tasksJob definitions, task schedulers, worker code
IntegrationHTTP 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:

If you put these directly into your business logic functions, SoC breaks.

Example: Logging as a Cross-Cutting Concern

Bad:

python
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:

python
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 wrapper

Now 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

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:

  1. Find mixed concerns
    Look for big functions that do HTTP, DB, and business logic together.
  2. Extract the domain logic
    Move business rules into pure functions or services that do not know about HTTP or database details.
  3. Extract persistence
    Move raw SQL or ORM queries into a repository layer.
  4. Extract shared concerns
    Move repeated auth, validation, or error-handling logic into separate modules or middlewares.
  5. Write tests around the separated pieces
    Test business logic with in-memory fakes and no framework.

Example: Simple Refactor

Original endpoint:

python
@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 email

Refactor steps:

  1. Extract validation into validate_order_request(data).
  2. Extract price calculation into calculate_order_price(items).
  3. Move DB operations into an OrderRepository.
  4. Move stock checks and order creation into OrderService.
  5. Move email sending into a background job or EmailService.

Now the endpoint becomes a thin layer:

python
@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:

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

Comments

Please login to add a comment.

Don't have an account? Register now!