CodeGym /Courses /Spring REST & MVC /Tags as a lookup endpoint

Tags as a lookup endpoint

Spring REST & MVC
Level 25, Lesson 3
Available

1. A common tag list for the client

If the backend lived in a vacuum, we could say: “Tags are just List<String> inside Task, and that is all.” But an API is a contract for the client, and the client usually wants convenient things: a tag filter, autocomplete, a dropdown list of “which tags actually exist,” so the user does not type bugfix in one place and bug-fix in another. In other words, tags become part of the UX, which means you need a predictable way to get them.

After the comments, one more type of API extension becomes visible. Not everything has to become either a field on Task or a new collection under it. Sometimes the client needs a read-only list of values that helps filter, suggest options, and avoid building a reference list from tasks on its own side.

Imagine the frontend: it has a “Task list” screen with filters. One of the filters is “Tag”. The frontend can, of course, do a clever trick: request GET /api/v1/tasks for a thousand items, then collect all tags, then do “distinct + sort” on its own. But that immediately creates three problems.

The first problem is extra traffic and extra logic on the client. We ask the server for tasks even though we only need tags. The second problem is unpredictability. Pagination, filters, access rights (we do not have them in our course, but we will in real life) — and suddenly the client has collected “not all tags,” only those that landed on the first page. The third problem is that responsibility drifts apart: part of the “contract logic” suddenly lives on the client, and now you have two different “sources of truth” about how tags are collected and sorted.

So the adult and simple logic is this: tags remain part of tasks, but we add a lookup endpoint that returns a reference list of already existing tags. It satisfies the UX need without inflating the domain.

2. Tags as values: no separate CRUD

When a developer sees “repeated values,” the hand sometimes reaches for creating an entity: Tag with id, a repository, a CRUD controller, and a separate universe of errors. This is especially typical after the first JPA projects, where everything seems like a set of tables. But here it is important to stop in time and ask: does a tag have its own life cycle in our project? Can a tag exist independently of tasks? Do we need to edit it, delete it, “administer” it separately?

In our Task Tracker API the honest answer is this: tags are values, “stickers on a task,” not independent business objects. They appear and disappear as a result of creating/updating tasks. If you build a full CRUD for tags, you immediately get extra rules: what happens if you delete a tag that is already attached to tasks? Do we delete it from tasks too? Do we forbid deletion? And if you rename a tag, do you rename it across all tasks? That is not impossible, but it is a different kind of complexity, and it does not help our goal for today: keep the API resource-oriented and compact.

It is very useful to compare the two approaches by cost and meaning:

Approach What the client gets What the backend pays When it is justified
Full Tag CRUD (POST/PUT/DELETE /tags) Management of the tag catalog as a separate subsystem New consistency rules, new errors, new conflict scenarios, more documentation and tests When tags are truly managed separately (there is an admin UI, moderation, descriptions, icons, etc.)
Lookup endpoint GET /tags A list of existing tags for the UI and filters Minimal logic: collect, deduplicate, sort When tags are just strings inside another resource

So a lookup is not a “stripped-down CRUD.” It is a different kind of endpoint: a small supporting resource that helps the client navigate values without creating a separate entity where it is not needed.

3. The GET /api/v1/tags contract

Now we will fix the contract so that it is not “roughly whatever works,” but clearly understandable and repeatable. We need an endpoint GET /api/v1/tags that returns a list of tags found in tasks. It is important that this is a read-only endpoint; it does not create or change data — a classic safe GET. This is good both for perception and for the ability to cache the result (we do not do caching in our course, but the idea is correct).

The key contract decisions here are as follows. First, the success status is 200 OK, even if there are no tags yet. An empty list is a normal result, not an error: the absence of tags does not mean the endpoint is “not found.” Second, the list must be deterministic: the same data set must produce the same order of values. For that reason, we sort tags. Third, the list must be unique: no duplicates, otherwise the UI turns into a comedy (“bug”, “bug”, “bug”…).

With the comments, we could allow ourselves a raw array: this is a small collection inside a specific task, and there is usually no extra metadata there. For /tags the role is different — it is a global supporting lookup for the UI and filters, so an envelope looks more reliable here.

There are two options for the response shape: return a “bare” JSON array ["bug","java"] or return a small envelope, for example { "items": [...] }. In the module on a stable JSON contract, we already discussed why an envelope often wins: if you want to add metadata later (for example, total), you can do it without breaking clients. So below I will show the envelope version. It is 3 characters longer, but nicer as a contract.

Response example:

{
  "items": ["backend", "bug", "java", "urgent"]
}

And one more important rule: the source of truth for tags is tasks, not a “catalog for the sake of a catalog.” That means the GET /tags endpoint will collect tags from TaskRepository without creating a separate TagRepository.

4. Implementation: TagController → TagService → TaskRepository

Now let’s move to the code so that it fits naturally into our architecture: the controller is thin, business logic (even if simple) lives in the service, and data is taken through the repository. Even if it seems that “it is just one stream line,” it is still better to keep the layer discipline. Otherwise, in a week you will have a 200-line controller with flatMap, sorting, and “well, it was a tiny thing.”

The call flow looks like this:

flowchart TD
  A["HTTP GET /api/v1/tags"] --> B["TagController"]
  B --> C["TagService"]
  C --> D["TaskRepository"]
  D --> C
  C --> B
  B --> E["200 OK + TagLookupResponse"]

Response DTO: TagLookupResponse

Let’s start with the response model. It makes sense to place it in com.example.tasktracker.api.dto.response.

import java.util.List;

/**
 * DTO for the tag lookup response.
 * Important: this is a read-only view for the client, not a domain entity.
 */
public record TagLookupResponse(List<String> items) {
}

This is intentionally a minimal DTO: a list of strings. If needed, metadata such as total can be added easily, but we do not need it right now.

Controller: TagController

The controller is as thin as possible: receives a request, calls the service, returns a DTO. No streams and no “distinct() right in the controller.”

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/tags") // global supporting resource
public class TagController {

    private final TagService tagService;

    public TagController(TagService tagService) {
        // Inject the service through the constructor: the controller should not "know" how tags are collected
        this.tagService = tagService;
    }

    @GetMapping
    public TagLookupResponse getAll() {
        // Return a read-only lookup: a list of existing tags from tasks
        return tagService.getAllTags();
    }
}

Notice a few things. The path starts immediately with /api/v1/tags, so the client does not have to remember “is this under /tasks or separate?” This is indeed a global supporting resource: it helps with task filters, but it is not a subresource of a specific task.

Service: TagService

In TagService we take all tasks and collect tags from them. Yes, in the in-memory world “take all tasks” sounds fine. In a real project with a database, you would not do “everything into memory,” but now our goal is the API contract and readable logic, not optimization for millions of records.

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class TagService {

    private final TaskRepository taskRepository;

    public TagService(TaskRepository taskRepository) {
        // The service depends on the repository: the service should not store the task collection itself
        this.taskRepository = taskRepository;
    }

    public TagLookupResponse getAllTags() {
        // The repository "fetches the data" (tags), the service "formats the response" for the API contract
        List<String> tags = taskRepository.findAllTags();
        return new TagLookupResponse(tags);
    }
}

Here I deliberately made an interesting move: TagService calls taskRepository.findAllTags(). This is not the only option. We could have called findAll() and collected tags in the service. But findAllTags() makes our code clearer: the repository is responsible for “how to fetch data,” and the service is responsible for “how to present the result for the API.” And collecting tags from storage is still closer to “fetching data” than to “business logic.”

If your repository does not have such a method yet, that is normal. We can add it, and the in-memory implementation will support it easily.

Repository: the findAllTags() method (in-memory)

I will show the idea at the interface level:

import java.util.List;

public interface TaskRepository {

    List<Task> findAll();

    /**
     * Returns all tags found in tasks.
     * Important: the method contract must match the API contract (uniqueness/order are handled below).
     */
    List<String> findAllTags();
}

And now an example implementation for an in-memory repository (super simplified so the mechanics are visible). Suppose we have a tasks collection.

import java.util.List;

public class InMemoryTaskRepository implements TaskRepository {

    private final List<Task> tasks;

    public InMemoryTaskRepository(List<Task> tasks) {
        // Store tasks in memory (for the educational variant)
        this.tasks = tasks;
    }

    @Override
    public List<String> findAllTags() {
        // "Raw" version: just flatten the list of tags from all tasks
        // Uniqueness and sorting will be added in the next step
        return tasks.stream()
                .flatMap(t -> t.getTags().stream())
                .toList();
    }
}

This version is still “raw”: it does not do distinct() and does not sort. We intentionally leave that for the next section, because that is exactly where the typical mistakes live, the ones that make a lookup endpoint suddenly start behaving like a random-number generator.

5. Tag uniqueness and sorting

On paper, “return a list of tags” looks like one line. In practice, this is exactly the kind of place where an API slowly starts accumulating small annoyances. Sometimes the response order jumps around, sometimes ["Java","java"] appears, and sometimes tags suddenly contain spaces, and the UI produces a masterpiece: " urgent" and "urgent" as if they were different values. So now we will build the part that turns a raw selection into a proper contract.

Normalization: trim() and toLowerCase()

Let’s start with the fact that a tag is user input. And user input, as we know, likes unexpected spaces. That is why it is almost always useful to normalize strings. The simplest minimum is trim() and toLowerCase().

private String normalize(String tag) {
    // Remove surrounding spaces and convert to lower case for case-insensitive semantics
    return tag.trim().toLowerCase();
}

Why toLowerCase()? Because the project requirements say tags must be unique within a task regardless of case. Since we already live in that semantic model, it makes sense to make lookup work the same way. Otherwise, a person will see two variants of the same tag and ask a philosophical question: “which one is the real one?”

Building it: flatMap() → normalize → distinct() → sorted()

Now let’s put everything into one clear pipeline. I will show the implementation in InMemoryTaskRepository#findAllTags() (or, if you prefer, this can live in the service — the logic will be the same).

import java.util.List;

@Override
public List<String> findAllTags() {
    return tasks.stream()
            .flatMap(t -> t.getTags().stream()) // 1) extract tags from all tasks into one stream
            .map(this::normalize)               // 2) normalize user input
            .distinct()                         // 3) remove duplicates after normalization
            .sorted()                           // 4) make the order deterministic for the client
            .toList();
}

It is important to understand the order of operations. First we extract all tags (flatMap()), then normalize (map()), then apply uniqueness (distinct()), then sort (sorted()). If you mix up the order, things get weird. For example, if you do distinct() before normalization, then "Java" and "java" will not be considered the same, and you will still get duplicates.

Sorting as part of the contract

Sorting makes the response deterministic. Without sorting, the tag order depends on the internal order of tasks, on seed data, on how you added items to the list, and sometimes even on which collection you used (for example, HashSet can change order between runs). For the client, this is painful: the UI will “flicker” — the tags in the dropdown are in one order today and another tomorrow.

With sorting, the contract becomes predictable: “alphabetical order, always the same.” And that immediately improves the UX: the user finds the needed tag faster, and frontend auto-tests stop failing because of random ordering.

If you want a slightly friendlier sort (without surprises in case), you can sort case-insensitively:

import java.util.Comparator;

// Explicitly show sorting with a comparator (in our case, after lower-case this is not even required)
.sorted(Comparator.naturalOrder())

But in our case we have already normalized to lower case, so the usual sorted() works perfectly.

An empty result is still success

Let us reinforce the semantics once more: if there are no tasks or none of the tasks has tags, GET /api/v1/tags should return:

{
  "items": []
}

and status 200 OK.

This is important psychologically. 404 says: “the resource does not exist.” But our resource exists; it is just empty right now. That is normal.

6. Checking the contract through .http

After any new endpoint, it is very useful to do a quick manual smoke test. Not because we do not like tests (we will like them later), but because a person needs to see that the contract really looks the way it was intended. Usually this takes 30 seconds and saves 30 minutes of “why is the frontend complaining.”

Example request in a .http file:

### Tags lookup
# Check the supporting endpoint that returns a reference list of tags
GET http://localhost:8080/api/v1/tags
Accept: application/json

Expected response (example):

{
  "items": ["backend", "bug", "java", "urgent"]
}

And now the important practical link. The client received the list of tags, showed the user a dropdown, and the user selected, say, bug. Next, the client goes to our task list endpoint, which already supports filtering by tag:

### Tasks filtered by tag
# Use the selected tag as a filter parameter in the task list
GET http://localhost:8080/api/v1/tasks?tag=bug&page=0&size=20&sort=updatedAt,desc
Accept: application/json

And this pair of endpoints looks like a normal ecosystem to the client: there is a value catalog and there is filtering by those values. At the same time, the project’s domain model did not bloat, because Tag did not become a separate entity.

7. Common mistakes when building a tag lookup

When you make a lookup endpoint, it is easy to fall into the trap of “well, it is just a small thing.” But it is exactly small things that create the feeling that the API is “sometimes fine, sometimes weird.” The mistakes below are the most common, and almost all of them are solved by one thing: being conscious about the contract and keeping a bit of discipline in the code.

Mistake #1: turning tags into a full CRUD just because “that is what we are used to.”
It is very tempting to build POST /tags, DELETE /tags/{id}, PUT /tags/{id} — it feels like you have “finished the domain.” In reality, you added a new entity with an independent life cycle and a bunch of conflict scenarios nobody asked for. If tags are just values on a task, then a lookup endpoint covers the client’s need much more cheaply.

Mistake #2: returning a non-deterministic order (sometimes this way, sometimes that way).
If you collect tags into a HashSet and then call toList(), the order may differ between runs. If you collect them “as it happens” from tasks, the order will depend on the seed data and on how you added tasks. This is unpleasant for the client and even more unpleasant for the developer trying to debug “flaky” behavior. Stable sorting makes the contract predictable.

Mistake #3: doing distinct() before normalization and getting duplicates like "Java" and "java".
At the Java level these are two different Strings, and distinct() will not merge them. If the project semantics for tags are case-insensitive (and ours are), then normalization must happen before uniqueness. Good order: normalize → distinct → sort.

Mistake #4: returning 404 Not Found when there are no tags.
404 means “the resource is missing.” But /api/v1/tags as a resource always exists: sometimes it just returns an empty list. An empty list is a successful and expected result, especially at the beginning of an application’s life or in a “clean” environment.

Mistake #5: hardcoding the tag list in the controller.
Sometimes you want to “do it quickly”: return List.of("bug", "urgent"). That breaks the source-of-truth idea and turns tags into a separate catalog that lives on its own and drifts away from real task data. A lookup endpoint is valuable precisely because it reflects existing tasks, not the controller’s fantasies about which tags “should be there.”

1
Task
Spring REST & MVC, level 25, lesson 3
Locked
Lookup endpoint with a tag list
Lookup endpoint with a tag list
1
Task
Spring REST & MVC, level 25, lesson 3
Locked
Tag normalization before the lookup response
Tag normalization before the lookup response
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION