25.4. Layered Architecture
Table of Contents
Why Layered Architecture Exists
When an application is small, it is tempting to put everything in a few files. For example, you might:
- Read HTTP requests.
- Query the database.
- Apply business rules.
- Format responses.
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:
- Understand, because similar code lives together.
- Test, because you can test each layer in isolation.
- Change, because you can replace or rewrite one layer without rewriting everything.
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:
| Layer | Main responsibility | Talks to |
|---|---|---|
| Presentation | Handle HTTP, map requests to operations | Service / Application |
| Service | Business rules, use cases, workflows | Repositories, other services |
| Data Access | Interact with database or external storage | Database / external systems |
| Infrastructure | Technical utilities such as email, queues, cache | External services |
You might see different names:
- Presentation layer might be called API layer, web layer, or controller layer.
- Service layer might be called business layer, domain layer, or application layer.
- Data access layer might be called repository layer, persistence layer, or ORM layer.
The idea is the same. Each layer answers a different question:
- Presentation: "How do we talk to the client?"
- Service: "What should happen for this request?"
- Data access: "How do we fetch or save data?"
A Typical Request Flow Through Layers
Imagine a simple "create task" endpoint in a task management API.
A typical flow looks like this:
- Presentation layer (controller / FastAPI route)
- Accepts the HTTP POST
/tasksrequest. - Validates and parses the request body.
- Calls a service method such as
create_task. - 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
Taskentity. - Data access layer (repository)
- Converts the domain object into SQL or ORM operations.
- Talks to the database.
- Returns persisted objects or database rows.
- 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:
# 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)# 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# 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:
- HTTP routes, controllers, or path operations.
- Request parsing and validation.
- Mapping exceptions to HTTP status codes.
- Choosing response bodies and status codes.
It should not:
- Contain business rules.
For example, "a user cannot delete another user's task" is a business rule and should live in the service layer. - Know how the database works.
- Build SQL queries.
Example, what belongs in the presentation layer:
@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:
- Can this user perform this action?
- What happens when a task is completed?
- How do we enforce business rules such as limits, quotas, statuses?
In the service layer you:
- Combine data from multiple repositories.
- Apply validation that is based on business rules, not on basic data types.
- Enforce invariants, such as "an order must have at least one item".
Example of business rules in the service layer:
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:
- Executing SQL or ORM queries.
- Mapping between domain objects and database rows.
- Handling low-level persistence details.
Typical elements:
- Repository classes, such as
UserRepository,TaskRepository. - ORM models and queries.
The service layer should talk to repositories through interfaces or clear methods, such as:
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:
- Which database is used.
- Which columns exist.
- How joins are written.
This separation makes it easier to:
- Switch from SQLite to PostgreSQL.
- Use a different ORM.
- Use in-memory repositories in tests.
Allowed and Forbidden Layer Interactions
To keep layers clean, you must control which layer can talk to which.
Common rule:
- Presentation can call: service layer.
- Service can call: repositories and infrastructure.
- Data access can call: database or external storage.
- No one below calls upward.
A simple diagram:
| Caller | Can call | Should not call |
|---|---|---|
| Presentation | Service | Repositories directly, database directly |
| Service | Repositories, infrastructure | Controllers, HTTP request objects |
| Data access | Database drivers, ORM, cache | Controllers, 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:
@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
- You can test the service layer using fake repositories in memory.
- You can test the data access layer by itself, focusing on SQL queries.
- You can test the presentation layer using a test client, mocking services.
Example, unit test for the service with a fake repository:
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:
passThe 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:
- Need to change a business rule? Look at the service layer.
- Need to optimize a query? Look at the data access layer.
- Need to change an endpoint URL? Look at the presentation layer.
Easier to Evolve
As your application grows, you might:
- Add a new API format, such as gRPC.
- Add a CLI tool.
- Add background workers that reuse the same business rules.
With layered architecture, you can reuse the service layer across all these entry points.
For example:
- HTTP endpoint calls
UserService.register_user. - CLI command calls the same
UserService.register_user. - Background job might also use
UserServiceto apply rules.
Simple Example Application Structure
A small FastAPI application using layers might use this folder structure:
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 modelsExample content:
# app/models/domain/task.py
@dataclass
class Task:
id: int | None
user_id: int
title: str
description: str | None
is_done: bool# 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)# app/services/tasks.py
class TaskService:
def __init__(self, task_repo: TaskRepository):
self.task_repo = task_repo
...# 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:
@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:
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:
# 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:
@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:
- Move database code into repositories.
- Move business logic into services.
- Leave controllers thin.
Summary
Layered architecture is about separating concerns into clear layers:
- Presentation layer handles HTTP and delegates work.
- Service layer holds business logic and use cases.
- Data access layer handles database interactions.
By controlling how layers interact and by keeping each layer focused, you get backends that are easier to test, maintain, and extend.
Views: 8
KAHIBARO