Layered Backend Architecture

LESSON

Backend and API Architecture

005 30 min intermediate

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:

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:

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

Key Takeaways

PREVIOUS API Versioning and Contract Evolution NEXT Dependency Injection and Runtime Composition