CodeGym /Courses /Spring Boot /Boundaries of controller/ service/ repository

Boundaries of controller/ service/ repository

Spring Boot
Level 7, Lesson 2
Available

1. Roles of controller, service, repository

If you have ever written a small project “on the back of an envelope,” there is a chance that you had one class that did almost everything: stored a list, filtered it, printed the result, and sometimes even drew the UI too (in the console, but still a UI). This works only until the project survives a second evening of development. On the third evening it starts getting revenge.

The problem is that “one class for everything” very quickly turns into a class without a role. You open the file and do not understand: is it about business rules? About data storage? About output formatting? About application startup? In a Boot project, we deliberately fight this mess, because Spring gives us a great opportunity: to build an application from small components, but only if we agree on boundaries ourselves.

To keep the boundaries from being abstract philosophy, let us establish a simple working model. It is not “the one true way,” but for a junior developer it is the sweet spot.

Role Main question What it must NOT do
repository “Where does the data come from?” Must not decide business rules or “the right way” to filter
service “What do we want to do with the data?” Must not know storage details and must not be an HTTP layer
controller “How are we called from the outside?” Must not store data and must not contain business logic

And one more important nuance: our project is read-only. That means we are intentionally not building a “CRUD world” and not pretending there is a database where there is none. We have no write operations, no transactions, no JPA repositories — and this is not “simplification for the sake of simplification,” but a way to protect the course focus.

2. repository: data source and contract

The word “repository” automatically makes many beginners picture “a table in a database.” But a repository is broader than that: it is an abstraction of data access. Today the data lives in memory, tomorrow in a file, the day after tomorrow in configuration, or even behind HTTP. The repository exists so the rest of the code does not worry about such changes.

Since the project is read-only, our repository will be as honest as possible: it simply returns courses. It does not save, does not update, does not pretend to be an ORM. First we define the contract (the interface), and only then the implementation. This is an important habit: first you formulate “what the application needs,” not “how I will quickly do it right now.”

Let us start with the domain model. For a training project, a very small course card is enough: slug, title, the featured and published flags. To keep the code short and immutable by default, it is convenient to use a record.

package com.example.catalogservice.catalog.domain;

// Domain model: a simple course card without Spring annotations or storage logic.
public record CourseCard(String slug, String title, boolean featured, boolean published) {

    // Explicit "readable" methods so stream filters are easier to read.
    public boolean isFeatured() { return featured; }

    // We separately emphasise the meaning of "published" (not just access to a field).
    public boolean isPublished() { return published; }
}

Now the repository contract. Minimal and clear: “give me all courses.”

package com.example.catalogservice.catalog.repository;

import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;

// Contract for accessing catalog data (read-only).
public interface CourseCatalogRepository {
    // Returns all courses available to the repository (without business filters).
    List<CourseCard> findAll();
}

Pay attention to the style: the method is called findAll, not getAllData and not doCatalog. It is a small thing, but it is exactly these little details that make code readable.

Now the simplest in-memory implementation. Yes, for now it returns an empty list — that is fine: we are building the skeleton and the roles. We will add data a little later.

package com.example.catalogservice.catalog.repository;

import java.util.List;
import org.springframework.stereotype.Repository;
import com.example.catalogservice.catalog.domain.CourseCard;

@Repository
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
    @Override
    public List<CourseCard> findAll() {
        // At this stage the repository honestly responds: "there is no data yet."
        // Later we will replace it with a test in-memory set.
        return List.of();
    }
}

And this is where it is useful to state the responsibility boundary. The repository answers the question “where is the data,” but it is not required to “be smart.” If you start stuffing rules like “featured means only published and only the first 3” into the repository, you will gradually turn it into a mix of “storage + business logic,” and you will lose the place where application decisions are supposed to live.

To keep the examples from being in a vacuum, let us add test data. This is still a training in-memory set, with no claim to adult architecture.

import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;

// Training data set (usually this is a field inside the InMemory repository).
// List.of(...) gives an immutable list: we will not accidentally "damage" the data along the way.
private final List<CourseCard> courses = List.of(
        new CourseCard("spring-boot", "Spring Boot", true, true),
        new CourseCard("spring-core", "Spring Core", false, true),
        new CourseCard("docker", "Docker for Spring", true, false)
);

And now the repository can return courses instead of an empty list. Important: List.of(...) returns an immutable list, so we will not accidentally “damage” the data somewhere along the way.

3. service: application decisions

A very common beginner thought is: “Why do we need a service if it just calls the repository?” And the thought is honest: in a small project, the service really does look like a pass-through at first. But the service is not a “pass-through for the sake of pass-through,” it is the place where application decisions live. Even if there are few decisions right now, you should have a place where those decisions naturally belong.

Imagine that tomorrow you need to “return only published courses,” “show featured courses in a limited amount,” “hide unpublished ones in one mode and show them in another.” If you do not have a service layer, all such rules start spreading either into the controller or into the repository. And that is exactly what we are moving away from.

Let us create CourseCatalogService. It depends on CourseCatalogRepository and receives it through the constructor.

package com.example.catalogservice.catalog.service;

import java.util.List;
import org.springframework.stereotype.Service;
import com.example.catalogservice.catalog.domain.CourseCard;
import com.example.catalogservice.catalog.repository.CourseCatalogRepository;

@Service
public class CourseCatalogService {

    // The service knows only the repository contract, not its implementation.
    private final CourseCatalogRepository repository;

    public CourseCatalogService(CourseCatalogRepository repository) {
        // In a real project, you could add a null check here,
        // but in Spring the contract is usually "the repository will always be there."
        this.repository = repository;
    }

    public List<CourseCard> findAllCourses() {
        // For now this is just delegation — that is normal at the skeleton stage.
        return repository.findAll();
    }
}

For now the service really does “delegate.” And that is fine. Now let us add a small application rule: featured courses are those with featured = true, and we want to see only published ones. That already looks like an “application decision,” not a “technical storage detail.”

import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;

public List<CourseCard> findFeaturedCourses() {
    return repository.findAll().stream()
            // First we drop unpublished courses (a catalog decision, not a storage method).
            .filter(CourseCard::isPublished)
            // Then we keep only featured ones.
            .filter(CourseCard::isFeatured)
            // Gather the result back into a list.
            .toList();
}

What matters here is not only that we wrote the filtering, but where we wrote it. Filtering is the catalog behavior logic, so it belongs in the service. The repository still answers the question “give me the data,” while the service answers “how do we interpret this data.”

There is another reason to like the service layer: testability. Even without special Spring tests, you can create the service manually by passing a fake repository. When logic lives in the controller, it becomes harder to test, because the controller is already a “boundary” (later HTTP), and boundaries are more expensive and harder to test.

4. controller: entry point into the application

The word controller in Spring is usually associated with HTTP and annotations like @GetMapping. But here we care about something else: a controller is a thin input adapter. Its job is to accept an external call and pass control to the service layer, not to decide where the data comes from or how the catalog works.

If we translate this into a everyday analogy, the controller is the “front desk administrator.” It does not cook the food (that is the service), it does not store products in the warehouse (that is the repository), it just takes the order and passes it to the kitchen. A bad administrator is the one who starts cutting salad right at the desk because “it is faster that way.”

Let us create the controller skeleton. It depends only on the service. This is crucial: the controller must not know where the data comes from.

package com.example.catalogservice.catalog.web;

import org.springframework.stereotype.Controller;
import com.example.catalogservice.catalog.service.CourseCatalogService;

@Controller
public class CourseCatalogController {

    // The controller depends only on the service layer (not on the repository).
    private final CourseCatalogService service;

    public CourseCatalogController(CourseCatalogService service) {
        // The controller is a thin layer: it gets the dependency and delegates further.
        this.service = service;
    }
}

Why is this @Controller and not @RestController? Because right now we care about the role of the component, not HTTP mechanics. It is enough to see it as a thin input adapter: the class sits on the upper boundary of the application layer and delegates work to the service.

5. The controller → service → repository chain

When you are just starting out, DI and beans can feel like “annotation magic.” To make the magic disappear, it helps to mentally draw the wiring: who depends on whom and where the call flows. And this is exactly where the controller/service/repository layers give you a very clear picture, like an electrical circuit: there is an input, there is logic, there is a data source.

The diagram for our catalog-service should be simple, almost childish. And that is a compliment.

flowchart LR
    C[CourseCatalogController] --> S[CourseCatalogService]
    S --> R[CourseCatalogRepository]
    R --> D[(In-memory courses)]

The direction of dependencies matters here: the controller knows the service, the service knows the repository, the repository knows the domain classes (CourseCard) and its own internal storage. Dependencies do not go in the reverse direction.

To feel this at the code level, let us add a method to the controller that simply delegates to the service. It is not an “HTTP method” yet, just a regular Java method. We want to see the direction of the call and the fact that the controller does not go directly to the repository.

import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;

public List<CourseCard> featuredCourses() {
    // The controller does not filter by itself — it delegates the use case to the service.
    return service.findFeaturedCourses();
}

Now compare this with the “bad” version, which is very tempting to write when you want to “just do it faster”: the controller gets the repository and filters right inside itself. It works... but the controller's role becomes blurred.

import java.util.List;

public List<CourseCard> featuredCourses() {
    // Bad sign: the controller starts to know about data and filtering rules.
    return repository.findAll().stream()
            .filter(CourseCard::isFeatured)
            .toList();
}

The difference between the two versions is not the number of lines, but where the logic will live when it gets bigger. In the good version, the logic naturally grows in the service. In the bad one, the controller turns into an all-in-one component.

6. Boundaries between repository and service

In real projects, the boundary between repository and service is sometimes thin, and that is normal. But juniors often have a different problem: there is no boundary at all, because “it works anyway.” That is why it is useful to keep a simple rule in mind: the repository is data access, the service is meaning.

If you are writing code and catch yourself thinking, “Here I am deciding which courses are right or wrong, which to show, which to hide, how to limit the list,” that is almost certainly the service layer. If you catch yourself thinking, “I need to fetch data,” that is the repository. Even in an in-memory version, this discipline matters: you are training architectural thinking on a safe training domain.

At the same time, the repository does not have to be a “dumb pipe.” It can contain technically justified search methods, but only if they remain in the data-access logic. For example, “find course by slug” is not a business rule, it is a way to access a data item.

import java.util.Optional;

public Optional<CourseCard> findBySlug(String slug) {
    // This is a technical data-access method: "find an item by key."
    // There is no "catalog decision" here, only a search through the collection.
    return findAll().stream()
            .filter(c -> c.slug().equals(slug))
            .findFirst();
}

But “show featured, but no more than 3, and only published” is already service territory, because that is a decision about catalog behavior, not an access method.

public List<CourseCard> findFeaturedCourses(int limit) {
    return repository.findAll().stream()
            // Catalog business decision: show only published courses.
            .filter(CourseCard::isPublished)
            // Catalog business decision: show only featured courses.
            .filter(CourseCard::isFeatured)
            // Another decision: limit the number.
            .limit(limit)
            .toList();
}

Another typical trap is to make the service a passive pass-through forever. In a training project, this can happen if you are afraid to add even one rule there. But the service exists precisely so that rules live in one place. Let it be even a small rule — filtering, sorting, limiting. The main thing is that there is a clear “point of meaning.”

7. Naming methods

When architecture is just emerging, method names feel like cosmetics. But in a Spring project, where half of the understanding comes from reading code and wiring, names are navigation signs. A good name answers the question “what does the method do,” not “it kinda works somehow.”

For a repository, names in the “search/obtain data” style are good: findAll, findBySlug, existsBySlug. They sound boring — and that is great. A repository should not be creative. The duller its API, the calmer your brain.

For a service, names that reflect a use case are good: findFeaturedCourses, getPublishedCourses, buildCatalogSummary. Here you can be more human, because the service is the language of the application. But even here, avoid words like process, handle, doWork: they usually mean “I could not think of a proper name.”

For a controller, method names most often reflect an “external scenario”: “get a list,” “get a card.” But since we are not writing a web API yet, it is better not to rush into HTTP terminology. It is enough that the controller calls the service, not the repository.

And an important self-check technique: open the class and try to understand its role without reading the method bodies. If the method names and constructor dependencies let you roughly understand what kind of class it is, you are on the right track.

8. Typical mistakes when separating layers

In this topic, mistakes are usually not “red” ones (the application still starts), but “architectural” ones: they show up later, when the code starts growing. That is why it is useful to catch them at the very beginning, while the project is still small and obedient.

Mistake #1: the controller depends on the repository directly.
At first it seems to save one class: “why do I need a service if I can already fetch data?” But then the controller starts growing filters, limits, rules, and suddenly it is no longer a controller, but a combine harvester. The habit of keeping the controller → service dependency protects the project from this spread.

Mistake #2: business rules are hidden in the repository “because it is more convenient to filter there.”
In an in-memory implementation, it really is convenient to make findFeatured() directly in the repository, because “well, this is also a search.” But as soon as a rule becomes not just a “search” but a “decision,” the repository stops being a data layer. The boundary is simple: the repository answers “how to fetch,” the service answers “what counts as the correct result.”

Mistake #3: the service remains an empty pass-through and has no role of its own.
Sometimes a service is created “because we were told to,” but people are afraid to put even one rule there. As a result, logic still leaks into the controller or the repository. If the service exists, give it at least minimal responsibility: filtering published/featured, limiting a list, selecting the “showcase.” This strengthens the layer's role and keeps the code readable.

Mistake #4: the repository interface turns into a junk drawer of methods.
A beginner may start adding methods “just in case”: getAll, getAllCourses, getCourses, loadCourses, readCourses. As a result, the interface bloats but does not become clearer. It is better to keep the contract small and expand it only for a real scenario, not because of the anxiety of “what if we need it.”

Mistake #5: layers know too much about each other.
The classic symptom is when domain classes start importing Spring annotations, and the repository suddenly “understands” how the controller will work. In a training project, it is easy to keep things clean: the domain (CourseCard) is just data, the repository is access to it, the service is logic, the controller is the entry point. The fewer “cross-knowledge” dependencies there are, the more calmly the project can evolve.

1
Task
Spring Boot, level 7, lesson 2
Locked
Thin controller for a list of featured courses
Thin controller for a list of featured courses
1
Task
Spring Boot, level 7, lesson 2
Locked
Counting Published Videos via the service layer
Counting Published Videos via the service layer
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION