Layered Backend Architecture
LESSON
Layered Backend Architecture
The core idea: Layered backend architecture protects change boundaries by keeping transport, application workflow, business rules, and persistence mechanics from leaking into each other.
Core Insight
Imagine the learning platform adds one familiar feature: POST /courses/42/enrollments. The first working version is tempting to write in one route handler. Parse the JSON body, check the authenticated user, query the course, reject closed courses, insert the enrollment row, maybe emit an analytics event, and return an HTTP response.
That version can be acceptable for a tiny prototype. The problem appears when the same enrollment behavior is needed from somewhere else. A background job wants to enroll learners imported from a company account. A CLI tool needs to repair failed enrollments. A partner API needs the same rule with a slightly different response shape. If the real policy lives inside the HTTP handler, every new entry point either calls web code indirectly or copies the rule.
Layered architecture is often drawn as controllers, services, and repositories. The drawing is less important than the dependency rule behind it: code near the outside world should translate external concerns, while core application behavior should speak in product terms and depend on small persistence or integration capabilities. A layer is useful only when it protects a kind of knowledge from spreading.
The common misconception is that layers are folder names. A backend can have folders named controllers, services, and repositories and still be tangled if HTTP objects, ORM models, transaction details, and business decisions move freely between them. The architecture starts to work when each layer has a reason to exist and a clear answer to the question: "what is this code allowed to know?"
The Pressure: One Use Case, Many Entry Points
Start with the naive route handler:
@app.post("/courses/{course_id}/enrollments")
def enroll(course_id: str, request: Request):
user_id = request.user.id
course = db.query("select * from courses where id = ?", course_id)
if not course["is_open"]:
return JSONResponse({"error": "course_closed"}, status_code=409)
existing = db.query(
"select * from enrollments where learner_id = ? and course_id = ?",
user_id,
course_id,
)
if existing:
return JSONResponse({"error": "already_enrolled"}, status_code=409)
enrollment = db.insert("enrollments", {"learner_id": user_id, "course_id": course_id})
return JSONResponse({"enrollmentId": enrollment["id"]}, status_code=201)
The code is easy to follow because everything is in one place. That is also why it becomes fragile. The function now knows about HTTP, authentication shape, SQL, duplicate-enrollment policy, course-open policy, error mapping, and response format.
When a second entry point arrives, the team has three bad options:
- duplicate the enrollment checks in the new place
- call the HTTP handler from non-HTTP code
- extract the rules under pressure after they have already leaked
Layering is the planned extraction. It says that transport-specific code should live at the transport boundary, use-case workflow should live in application code, and storage mechanics should live behind a persistence boundary.
The Mechanism: What Each Layer Should Know
A pragmatic layered backend usually has these responsibilities:
HTTP controller / resolver / message handler
translates the outside world into an application request
Application service / use case
coordinates the workflow in product language
Domain model or domain functions
express business rules when those rules are rich enough to deserve a home
Repository / gateway / adapter
hides persistence or external integration mechanics
Database, queue, payment provider, cache, file store
concrete infrastructure
Not every system needs all of these layers as separate files. A small CRUD endpoint may only need a controller and a repository. A rule-heavy workflow may deserve explicit use-case and domain objects. The useful test is not "did we create every layer?" but "did we keep each kind of change near the code that understands it?"
For the enrollment endpoint, the controller should know how to translate HTTP:
@app.post("/courses/{course_id}/enrollments")
def enroll(course_id: str, request: Request):
result = enrollment_service.enroll(
learner_id=request.user.id,
course_id=course_id,
)
return enrollment_response(result)
The application service should know the workflow:
class EnrollmentService:
def __init__(self, courses, enrollments):
self.courses = courses
self.enrollments = enrollments
def enroll(self, learner_id: str, course_id: str):
course = self.courses.get(course_id)
if not course.is_open:
raise EnrollmentRejected("course_closed")
if self.enrollments.exists(learner_id, course_id):
raise EnrollmentRejected("already_enrolled")
return self.enrollments.create(learner_id, course_id)
The repository should know storage:
class PostgresEnrollments:
def exists(self, learner_id: str, course_id: str) -> bool:
return self.db.fetch_one(
"""
select 1
from enrollments
where learner_id = %s and course_id = %s
""",
learner_id,
course_id,
) is not None
The important move is not that the code became longer. The important move is that each part now changes for a different reason. A new HTTP status mapping changes the controller. A new enrollment policy changes the service or domain rule. A new index or query shape changes the repository.
Worked Example: Moving a Rule to the Right Place
Suppose the product adds a waitlist rule:
If a course is full but waitlisting is enabled, create a waitlist entry instead of rejecting the learner.
If that rule lives in the controller, the HTTP endpoint works, but the import job and partner API can easily miss it. If it lives in a repository, storage code starts deciding product policy. If it lives in the application service, every entry point that asks to enroll a learner can share the same decision.
A use-case-oriented result might look like this:
class EnrollmentService:
def enroll(self, learner_id: str, course_id: str):
course = self.courses.get(course_id)
if course.has_available_seat:
enrollment = self.enrollments.create(learner_id, course_id)
return EnrollmentResult(kind="enrolled", id=enrollment.id)
if course.waitlist_enabled:
waitlist_entry = self.waitlists.create(learner_id, course_id)
return EnrollmentResult(kind="waitlisted", id=waitlist_entry.id)
raise EnrollmentRejected("course_full")
The controller can still decide how to express that result over HTTP:
enrolled -> 201 Created
waitlisted -> 202 Accepted
rejected -> 409 Conflict
That mapping is a transport concern. The rule that a full course may become a waitlist entry is not. This distinction is the heart of layered architecture: the same product decision can be reached from HTTP, a queue worker, a CLI command, or a test without smuggling the web framework into all of them.
Dependency Direction and Data Shape
Layered architecture becomes brittle when dependencies point in every direction. The controller may depend on the service. The service may depend on repository interfaces or small capabilities. The repository may depend on the database driver. But the service should not depend on the web framework, and the domain rule should not depend on the ORM's active record object unless the whole system has deliberately chosen that trade-off.
A useful dependency direction is:
outside details -> application behavior -> small capabilities
In practice, that means the application layer should receive plain values or domain objects, not raw HTTP request objects. It should return application results or raise application errors, not framework response objects. It should ask repositories for meaningful operations, not scatter SQL strings across controllers and jobs.
Data shape matters here. API DTOs, database rows, and domain concepts often look similar, so teams let one object serve all three roles. That can be fine for small systems, but it becomes expensive when the API contract evolves differently from the database schema or the domain language. The previous lesson on API versioning showed why public response shapes need migration discipline. Layered architecture gives those public shapes a place to stay: near the edge, where translation belongs.
Trade-offs and Failure Modes
The main benefit is change isolation. You can adjust HTTP semantics without rewriting the use case. You can test enrollment rules without booting the server. You can replace a query implementation without teaching controllers about indexes or transaction handling.
The cost is indirection. A simple read endpoint can become harder to understand if it passes through five files that only forward the same call. Layers are not automatically good. They should earn their place by hiding a real detail, naming a real policy, or protecting a real boundary.
Common failure modes include:
- pass-through services: a service method exists only to call a repository method with the same name
- fat controllers: route handlers own business policy because they were the fastest place to add one more condition
- leaky repositories: storage abstractions expose ORM sessions, SQL fragments, or database-specific errors to application code
- domain objects as everything: the same structure is used as database row, API response, validation input, and business model
- framework-centered design: the web framework becomes the architecture, so jobs, scripts, and tests must imitate HTTP to reuse behavior
The corrective question is concrete:
If this feature had to run from a different entry point tomorrow,
which code would still be useful?
Code that would remain useful belongs closer to the application core. Code that exists only to translate a protocol, tool, or storage engine belongs near that boundary.
Connections
The previous lesson on API versioning showed that public contracts need stable migration paths. Layered architecture supports that by keeping response translation near the API boundary instead of letting public DTOs become the whole backend's internal language.
The next lesson on dependency injection explains how these layers get connected at runtime. Once a service declares that it needs course and enrollment repositories, something at the application edge must provide the concrete implementations.
The later lesson on repositories and units of work will go deeper into persistence boundaries, especially where transaction ownership belongs and how to avoid pretending that every database concern can be hidden cleanly.
Resources
- [ARTICLE] Service Layer
- Focus: Understand why application services organize workflows above presentation and data source details.
- [ARTICLE] Presentation Domain Data Layering
- Focus: Compare presentation, domain, and data responsibilities as separate change pressures.
- [BOOK] Patterns of Enterprise Application Architecture
- Focus: Study service layers, repositories, data mappers, and related enterprise backend patterns.
- [BOOK] Clean Architecture
- Focus: Compare layered dependency direction with a stricter view of keeping business policy independent from frameworks.
Key Takeaways
- Layers are useful when they protect different kinds of knowledge: protocol translation, application workflow, business policy, and storage mechanics.
- The strongest test for a backend layer is whether the code remains useful from another entry point such as a job, CLI command, or test.
- Layering has a cost, so avoid pass-through ceremony and keep only boundaries that reduce real coupling.
- Public API shapes, database rows, and domain concepts should be allowed to differ when they change for different reasons.
← Back to Backend and API Architecture