KAHIBARO
Discord Login Register

25.4. Layered Architecture

Why Layered Architecture Exists

When an application is small, it is tempting to put everything in a few files. For example, you might:

all in one big function.

This becomes very hard to maintain as the code grows. Layered architecture solves this by separating responsibilities into layers. Each layer focuses on one kind of work and talks only to the layers around it.

A layered backend is easier to:

In this chapter, you will see what the common layers are and how they interact in a backend application.

Important rule
In a layered architecture, higher layers must not "reach around" and talk to lower layers that are not directly below them. Always go through the next lower layer.

Common Layers in Backend Applications

Most backend applications use a variation of these layers:

LayerMain responsibilityTalks to
PresentationHandle HTTP, map requests to operationsService / Application
ServiceBusiness rules, use cases, workflowsRepositories, other services
Data AccessInteract with database or external storageDatabase / external systems
InfrastructureTechnical utilities such as email, queues, cacheExternal services

You might see different names:

The idea is the same. Each layer answers a different question:

A Typical Request Flow Through Layers

Imagine a simple "create task" endpoint in a task management API.

A typical flow looks like this:

  1. Presentation layer (controller / FastAPI route)
    • Accepts the HTTP POST /tasks request.
    • Validates and parses the request body.
    • Calls a service method such as create_task.
  2. Service layer
    • Applies business rules such as "title must be unique per user".
    • Uses repositories to check existing tasks and create a new task.
    • Returns a domain object, such as a Task entity.
  3. Data access layer (repository)
    • Converts the domain object into SQL or ORM operations.
    • Talks to the database.
    • Returns persisted objects or database rows.
  4. Back up to presentation layer
    • The controller turns the returned task into a JSON response.
    • Sets the HTTP status code and headers.
    • Sends the response to the client.

In pseudocode:

python
# Presentation (API endpoint)
@app.post("/tasks")
def create_task_endpoint(body: CreateTaskBody, user=Depends(get_current_user)):
    task = task_service.create_task(
        user_id=user.id,
        title=body.title,
        description=body.description,
        due_date=body.due_date,
    )
    return TaskResponse.from_domain(task)
python
# Service layer
class TaskService:
    def __init__(self, task_repo: TaskRepository):
        self.task_repo = task_repo
    def create_task(self, user_id, title, description, due_date):
        if self.task_repo.exists_with_title(user_id, title):
            raise DuplicateTaskTitleError()
        task = Task(
            id=None,
            user_id=user_id,
            title=title,
            description=description,
            due_date=due_date,
            is_done=False,
        )
        saved_task = self.task_repo.save(task)
        return saved_task
python
# Data access layer (repository)
class TaskRepository:
    def __init__(self, session: Session):
        self.session = session
    def exists_with_title(self, user_id, title) -> bool:
        return (
            self.session.query(TaskModel)
            .filter_by(user_id=user_id, title=title)
            .first()
            is not None
        )
    def save(self, task: Task) -> Task:
        model = TaskModel(
            user_id=task.user_id,
            title=task.title,
            description=task.description,
            due_date=task.due_date,
            is_done=task.is_done,
        )
        self.session.add(model)
        self.session.commit()
        self.session.refresh(model)
        return Task.from_model(model)

Every part has a clear role. Presentation does HTTP, service does business rules, repository does data access.

Responsibilities of Each Layer

Presentation Layer

The presentation layer is the outer shell of your backend. In a web API, this usually means:

It should not:

Example, what belongs in the presentation layer:

python
@app.delete("/tasks/{task_id}")
def delete_task_endpoint(task_id: int, user=Depends(get_current_user)):
    task_service.delete_task(task_id=task_id, user_id=user.id)
    return Response(status_code=204)

This endpoint does not check permissions by itself. It delegates that to the service.

Service Layer

The service layer holds business logic and use cases. It answers questions like:

In the service layer you:

Example of business rules in the service layer:

python
class TaskService:
    def delete_task(self, task_id: int, user_id: int):
        task = self.task_repo.get_by_id(task_id)
        if task is None:
            raise TaskNotFoundError()
        if task.user_id != user_id:
            raise PermissionDeniedError("Cannot delete another user's task")
        if task.is_done is False and task.due_date is not None:
            # Example of a business rule: you must mark as done before deleting
            raise BusinessRuleError("Task must be completed before deletion")
        self.task_repo.delete(task_id)

The service layer calls repositories but does not know about SQL queries or ORM-specific details.

Data Access Layer

The data access layer isolates how data is stored and accessed. It is responsible for:

Typical elements:

The service layer should talk to repositories through interfaces or clear methods, such as:

python
class TaskRepository:
    def get_by_id(self, task_id: int) -> Optional[Task]:
        ...
    def save(self, task: Task) -> Task:
        ...
    def delete(self, task_id: int) -> None:
        ...

The service layer does not need to know:

This separation makes it easier to:

Allowed and Forbidden Layer Interactions

To keep layers clean, you must control which layer can talk to which.

Common rule:

A simple diagram:

CallerCan callShould not call
PresentationServiceRepositories directly, database directly
ServiceRepositories, infrastructureControllers, HTTP request objects
Data accessDatabase drivers, ORM, cacheControllers, HTTP, business rules

Important rule
Do not let the presentation layer call the database or ORM directly. Always go through the service layer and repositories. This keeps business rules centralized and testable.

Example of a bad design:

python
@app.post("/tasks")
def create_task_endpoint(body: CreateTaskBody, user=Depends(get_current_user)):
    # Bad: calling ORM from controller and putting rules here
    existing = (
        db_session.query(TaskModel)
        .filter_by(user_id=user.id, title=body.title)
        .first()
    )
    if existing:
        raise HTTPException(status_code=400, detail="Duplicate title")
    model = TaskModel(...)
    db_session.add(model)
    db_session.commit()
    ...

This endpoint mixes HTTP handling and data access. It is harder to reuse and test.

Benefits of Layered Architecture

Using layers introduces some structure and overhead, but it brings significant benefits.

Easier Testing

Example, unit test for the service with a fake repository:

python
class FakeTaskRepository(TaskRepository):
    def __init__(self):
        self.tasks = []
    def exists_with_title(self, user_id, title):
        return any(t.user_id == user_id and t.title == title for t in self.tasks)
    def save(self, task: Task) -> Task:
        task.id = len(self.tasks) + 1
        self.tasks.append(task)
        return task
def test_create_task_rejects_duplicate_title():
    repo = FakeTaskRepository()
    service = TaskService(task_repo=repo)
    first = service.create_task(1, "Buy milk", "desc", None)
    try:
        service.create_task(1, "Buy milk", "another", None)
        assert False, "Expected DuplicateTaskTitleError"
    except DuplicateTaskTitleError:
        pass

The test focuses on business rules, not on HTTP or the database.

Clearer Responsibilities

When each layer has a single purpose, you avoid "god classes" that do everything.

Examples:

Easier to Evolve

As your application grows, you might:

With layered architecture, you can reuse the service layer across all these entry points.

For example:

Simple Example Application Structure

A small FastAPI application using layers might use this folder structure:

text
app/
  api/
    routes/
      tasks.py        # presentation layer
  services/
    tasks.py          # service layer
  repositories/
    tasks.py          # data access layer
  models/
    domain/
      task.py         # domain entities
    db/
      task_model.py   # ORM models

Example content:

python
# app/models/domain/task.py
@dataclass
class Task:
    id: int | None
    user_id: int
    title: str
    description: str | None
    is_done: bool
python
# app/api/routes/tasks.py
@router.post("/tasks", response_model=TaskResponse)
def create_task_endpoint(body: CreateTaskBody, user=Depends(get_current_user)):
    task = task_service.create_task(
        user_id=user.id,
        title=body.title,
        description=body.description,
    )
    return TaskResponse.from_domain(task)
python
# app/services/tasks.py
class TaskService:
    def __init__(self, task_repo: TaskRepository):
        self.task_repo = task_repo
    ...
python
# app/repositories/tasks.py
class TaskRepository:
    def __init__(self, session: Session):
        self.session = session
    ...

This simple structure already separates HTTP, business rules, and persistence.

Common Pitfalls and How to Avoid Them

Even when you use layers, some mistakes are frequent.

Putting Business Logic in the Wrong Layer

If you see a lot of business rules inside controllers or repositories, that is a warning sign.

Example, bad controller with business rules:

python
@app.post("/orders")
def create_order_endpoint(body: CreateOrderBody):
    # Business rule: cannot order if user has unpaid invoices
    unpaid = db_session.query(InvoiceModel).filter_by(user_id=body.user_id, paid=False)
    if unpaid.count() > 0:
        raise HTTPException(400, "Unpaid invoices")
    ...

Better, move the rule into the service:

python
class OrderService:
    def create_order(self, user_id, items):
        if self.invoice_repo.has_unpaid_invoices(user_id):
            raise UnpaidInvoicesError()
        ...

The controller then only calls order_service.create_order.

Layer Bypassing

Another issue is letting upper layers bypass the service layer and use repositories directly.

For example:

python
# Bad: controller uses both service and repository
@app.post("/tasks/{task_id}/done")
def mark_task_done(task_id: int):
    task = task_repo.get_by_id(task_id)
    task.is_done = True
    task_repo.save(task)

This bypasses any business rules in the service layer.

Correct version:

python
@app.post("/tasks/{task_id}/done")
def mark_task_done(task_id: int):
    task_service.mark_done(task_id)

In TaskService.mark_done, you can enforce all rules consistently.

Overcomplicating Small Projects

For a tiny script or a single endpoint demo, a strict layered structure can feel heavy. Early on, you can start simple, but keep layers in mind.

A useful guideline:

Guideline
As soon as you:

  • Have more than a few endpoints, or
  • Need non-trivial business rules, or
  • Need to talk to more than one data source,
    introduce at least a service layer and a repository layer.

You can refactor gradually:

  1. Move database code into repositories.
  2. Move business logic into services.
  3. Leave controllers thin.

Summary

Layered architecture is about separating concerns into clear layers:

By controlling how layers interact and by keeping each layer focused, you get backends that are easier to test, maintain, and extend.

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!