JavaRush /Courses /Go SELF /GET list: filters, sorting, and stable output

GET list: filters, sorting, and stable output

Go SELF
Level 61, Lesson 1
Available

1. Contract and response format

When you write a list endpoint, it can feel like it is “just return an array”. But list responses are exactly what most often start living a life of their own: scripts parse them, autotests are built around them, they are compared as JSON “for equality”, and users complain if items “jump” around. So the list contract is not only the fields id/title/done, but also the ordering rules and the behavior of filters.

We fix the following contract for GET /api/v1/tasks:

  • Method and path: GET /api/v1/tasks
  • Query parameters:
    • done=true|false — optional status filter
    • sort=id|title — optional sorting
  • Response: 200 OK and a JSON object of the form { "tasks": [...] }, not a “bare array”, so the format can be extended without changing the response type (for example, by adding "total").

Why do we talk separately about “stability”? Because even seemingly “simple” things like sorting can produce different ordering for equal elements when the internal sorting implementation changes. This is one reason tests that depend on ordering suddenly break.

To avoid turning clients into archaeologists, we make the output deterministic up front.

DTO and the response wrapper

Before filtering and sorting, it is important to agree on what shape the data has when it goes out. At the HTTP layer we usually work with DTOs (data transfer objects)—structures that reflect the API contract and do not have to match the domain model 100%. This is normal: the domain is about rules, HTTP is about packaging.

Minimal DTO and response wrapper:

type TaskDTO struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type ListTasksResponse struct {
	Tasks []TaskDTO `json:"tasks"`
}

Why { "tasks": [...] }, and not just []TaskDTO? Because APIs rarely remain “just a list”. When you want to add total, next_page, generated_at—you will not have a painful format change where clients must rewrite the parser from “array → object”.

And yes, this is the case where an extra 10 bytes of JSON is a justified investment into a calmer life.

2. Query params: reading and handling

Query parameters look harmless because they are “just strings”. But in reality they are user input just like a JSON body or {id} in the path: you can get empty values, duplicates, unexpected variants—and all of it must be handled consistently. If we “accept everything as-is”, bugs will not show up immediately; they will appear when a client accidentally sends ?done=tru and gets strange behavior.

In practice r.URL.Query() returns url.Values, i.e. map[string][]string.

import "net/http"

func readQuery(r *http.Request) map[string][]string {
	q := r.URL.Query()
	return q
}

And this is where the first subtlety appears: one key can be passed multiple times, for example ?sort=id&sort=title. For learning, we will choose a strict policy: if a parameter is provided more than once, it is a validation error (400), because otherwise it is hard for the client to predict the result.

Exactly one value per parameter

Let’s make a small helper: “take exactly one value”:

import (
	"fmt"
	"net/url"
)

func getSingleQuery(q url.Values, key string) (string, bool, error) {
	vals, ok := q[key]
	if !ok {
		return "", false, nil
	}
	if len(vals) != 1 {
		return "", true, fmt.Errorf("query %q must be single", key)
	}
	return vals[0], true, nil
}

Here present (the second result) is more important than it seems. It helps distinguish “the parameter is absent” from “the parameter exists but is empty/broken”. For done, this is critical.

done filter: three states instead of one bool

There is a typical beginner trap in filters: you want to read done as a bool and call it a day. But done in query actually has three states, and they are semantically different:

  1. parameter absent → do not apply the filter
  2. parameter present and valid (true/false) → apply the filter
  3. parameter present but invalid → return 400 (validation)

If you do not distinguish (1) and (2), you will get a bug: “parameter absent” will behave the same as done=false, and the client will suddenly see only incomplete tasks.

Let’s make a parser for an optional bool:

import (
	"net/url"
	"strconv"
)

func parseOptionalBool(q url.Values, key string) (value bool, present bool, err error) {
	raw, present, err := getSingleQuery(q, key)
	if err != nil || !present {
		return false, present, err
	}
	if raw == "" {
		return false, true, strconv.ErrSyntax
	}
	v, err := strconv.ParseBool(raw)
	if err != nil {
		return false, true, err
	}
	return v, true, nil
}

Now filtering. We want to do it as simply as possible and without extra allocations. The “in-place filtering” pattern looks like this: take dst := tasks[:0] and append matching elements. This sounds like magic, but it is really just careful reuse of the array behind the slice.

func filterByDone(tasks []TaskDTO, done bool) []TaskDTO {
	dst := tasks[:0]
	for _, t := range tasks {
		if t.Done == done {
			dst = append(dst, t)
		}
	}
	return dst
}

If you are thinking “this looks like an ancient gopher ritual”—that is fine. You will get used to it: in Go, things like this are considered readable because they are short and predictable.

Query validation and mapping errors to ValidationError

Inside a handler, we do not want to smear logic like “if it failed here, write 400 this way, and there do it differently”. We already agreed that we have a single error path through writeError, so the handler should return domain-meaningful errors: ValidationError for 400 and ErrNotFound for 404 (less common for list).

In this lecture, we will assume the ValidationError type and writeError/mapError already exist (you wrote them in the lecture about a unified error contract). Then query validation becomes a simple rule: everything invalid becomes &ValidationError{Fields: ...}.

For example, if done is broken:

func invalidDoneQuery() error {
	return &ValidationError{
		Fields: map[string]string{
			"done": "must be true or false",
		},
	}
}

And if sort is unknown:

func invalidSortQuery() error {
	return &ValidationError{
		Fields: map[string]string{
			"sort": "supported: id, title",
		},
	}
}

This is convenient for the client: it always gets one format and can highlight the error next to the specific parameter. And it is convenient for you because the handler stays linear and readable.

3. Sorting and output stability

Sorting in a list endpoint is needed not only “for aesthetics”. It is needed for determinism: it is easier for the client (and your tests) when the same request always returns elements in the same order.

The key nuance: if you sort by a field that can be equal (for example, title), and you do not set a secondary rule (tie-break), then the order of elements with the same title can drift. Even with identical code, the ordering of equal elements can vary between environments and tool versions.

We introduce the rules:

  • sort absent or sort=id → sort by ID ascending
  • sort=title → sort by Title, and if Title are equal, sort by ID

Code:

import (
	"fmt"
	"sort"
)

func sortTasks(tasks []TaskDTO, sortBy string) error {
	switch sortBy {
	case "", "id":
		sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
		return nil
	case "title":
		sort.Slice(tasks, func(i, j int) bool {
			if tasks[i].Title == tasks[j].Title {
				return tasks[i].ID < tasks[j].ID
			}
			return tasks[i].Title < tasks[j].Title
		})
		return nil
	default:
		return fmt.Errorf("unsupported sort: %s", sortBy)
	}
}

The tie-break by ID makes the result deterministic even when titles match. This is especially important if you later add tests that compare JSON “as a string”, or golden files.

4. Handler: pipeline parse → list → filter → sort → encode

A list handler is great because it perfectly trains the “server sequence muscle”. We want the code to read top-to-bottom without jumping around the file.

Handler scheme:

flowchart TD
    A[GET /api/v1/tasks] --> B[parse query: done/sort]
    B --> C["app.List -> []TaskDTO"]
    C --> D[optional filter done]
    D --> E[sort + tie-break]
    E --> F["encode JSON {tasks:...}"]

Now the handler skeleton. For now, we will keep the service layer minimal:

import "context"

type TaskLister interface {
	List(ctx context.Context) ([]TaskDTO, error)
}

And the handler itself:

import "net/http"

func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	done, hasDone, err := parseOptionalBool(q, "done")
	if err != nil {
		writeError(w, &ValidationError{Fields: map[string]string{"done": "must be true or false"}})
		return
	}

	sortBy, _, err := getSingleQuery(q, "sort")
	if err != nil {
		writeError(w, &ValidationError{Fields: map[string]string{"sort": "must be single"}})
		return
	}

	tasks, err := s.app.List(r.Context())
	if err != nil {
		writeError(w, err)
		return
	}

	if hasDone {
		tasks = filterByDone(tasks, done)
	}
	if err := sortTasks(tasks, sortBy); err != nil {
		writeError(w, &ValidationError{Fields: map[string]string{"sort": "supported: id, title"}})
		return
	}

	writeJSON(w, http.StatusOK, ListTasksResponse{Tasks: tasks})
}

Notice a few “small things that are not small”:

  • We always sort (even if sort is empty): that is default stability.
  • We filter after fetching the data: this keeps parse/query logic out of the app layer. This is not the only possible design, but it works well for learning: the HTTP layer owns the “input/output contract”.
  • Query errors become ValidationError, and everything else is whatever the app layer returns (and then writeError decides 400/404/500).

If you already have a writeJSON helper, it typically looks like this:

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

Request examples

Server code is half the story. The other half is how people actually use it. When you look at an API through the client’s eyes, it becomes obvious where the contract is “leaky” versus predictable.

Example 1: get all tasks, default sorting (by id)

curl -i "http://localhost:8080/api/v1/tasks"

Expected (example):

{
  "tasks": [
    { "id": 1, "title": "Buy milk", "done": false },
    { "id": 2, "title": "Write Go code", "done": true }
  ]
}

Example 2: only completed, sorting by title

curl -i "http://localhost:8080/api/v1/tasks?done=true&sort=title"

Example 3: query error

curl -i "http://localhost:8080/api/v1/tasks?done=tru"

And this is where it is especially important that the client receives not random text, but your unified error envelope, for example:

{
  "error": {
    "code": "validation",
    "message": "invalid request",
    "fields": {
      "done": "must be true or false"
    }
  }
}

Even if the client is written in another language, this format is trivial to process: code defines the class, fields lets you highlight the specific place, and message is what you show to a human.

5. Common mistakes

Error #1: assuming that “if you do not sort, it will be in order anyway”.
As long as you store data in a slice and only append tasks to the end, it feels like the order is “stable by itself”. But once you introduce a map, reading from a file, merging from multiple sources, or simply change the implementation, the order becomes unpredictable. The right habit: the list endpoint is responsible for ordering and always sorts before returning data.

Error #2: forgetting that done has three states, and accidentally filtering by default.
If you do done, _ := strconv.ParseBool(q.Get("done")), then the absence of the parameter turns into false, and you suddenly return only incomplete tasks. The client will think “the server is losing data”. There is only one fix: return (value, present, err) and apply the filter only when present is true.

Error #3: sorting by title without a tie-break and being surprised by “jumping” order.
When two title values are equal, sorting without a secondary criterion does not promise that elements will stay “as before”. Between tool versions or with a different dataset, the ordering of equal elements can change. To avoid phantom test failures, add a tie-break (for example, id).

Error #4: accepting ?sort=id&sort=title and “silently taking the first value”.
Such an API becomes hard to debug: the client made a mistake, but the server silently did “something”. In stable contracts, it is better either to clearly document the rule “we take the first”, or (simpler for learning and more reliable) return 400, requiring exactly one parameter.

Error #5: returning a bare array instead of an object and then “having nowhere to extend”.
Today it seems like a task list is just []TaskDTO. Tomorrow you will need total, the day after tomorrow—a timestamp or a version. If the response is already an object {"tasks": [...]}, you can add a field painlessly. If the response is an array, you will have to break clients’ parsers. This is that rare case where a small JSON “redundancy” really saves weeks of life.

1
Task
Go SELF, level 61, lesson 1
Locked
One parameter
One parameter
1
Task
Go SELF, level 61, lesson 1
Locked
Three-state bool
Three-state bool
1
Task
Go SELF, level 61, lesson 1
Locked
Filter and sort
Filter and sort
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION