JavaRush /Courses /Go SELF /Resource design: /api/v1/tasks, /api/v1/tasks/{id}, and /...

Resource design: /api/v1/tasks, /api/v1/tasks/{id}, and /done

Go SELF
Level 56, Lesson 2
Available

1. Why think in resources, not commands

When you start building an HTTP API, it’s very easy to slide into a style of “convenient commands”: /createTask, /getTask, /markTaskDone. It looks like a control panel: a “Create” button, a “Get” button, a “Make it nice” button. The problem is that HTTP already invented the buttons for you — those are the methods. And the path should describe what entity lives at that address.

A resource-oriented approach is when you present your API as a “showcase of entities”. There is a collection of tasks, there is a specific task, there is a representation of the “done” state. Then it’s easier for the client to predict the API “by shape”, instead of memorizing 30 unique URL commands.

Good news: you don’t need to know “all of REST philosophy”. It’s enough to keep one simple sentence in mind:

A URL answers “what is this?”, and a method answers “what to do?”

Mini comparison: command style vs resource style

What we want Command URL (bad) Resource URL (good)
get the task list
GET /getTasks
GET /api/v1/tasks
create a task
POST /createTask
POST /api/v1/tasks
get task 7
GET /getTask?id=7
GET /api/v1/tasks/7
mark task 7 as done
POST /markDone?id=7
POST /api/v1/tasks/7/done

The command style looks “clear” exactly until the API starts to grow. Then drift begins: createTask, addTask, newTask, makeTask — and suddenly your API turns into a linguistics course on “synonyms of English verbs”.

2. Version in the path: why /api/v1 matters

Path-based versioning often feels like bureaucracy: “we just started — what versions?”. But the problem is that it’s almost impossible not to change an API. You’ll want to add a field, rename a field, change a format, “slightly” change error behavior. And that’s where things get interesting: old clients can keep living for months.

There’s a hard rule: once a client has integrated, your “small tweak” can become a Friday-evening production outage for them. And Friday evening is sacred time — production is best left untouched.

In the Go world, the importance of compatibility is felt very strongly: if you change a public API (functions, types), you risk breaking someone else’s code that depends on it. This is explicitly described as a compatibility problem: “you can’t take an API away, or programs will break”. HTTP contracts are the same story, just the “someone else’s program” is your API client.

So /api/v1 is a way to say honestly: “these are the rules of version 1”. If one day you need incompatible changes, you have the option /api/v2, and old clients don’t have to suffer.

A small Go example: pin the version prefix

package main

const apiV1 = "/api/v1"

func tasksPath() string {
	return apiV1 + "/tasks"
}

No magic here — just a habit: build all paths from a single prefix. This reduces the risk of typos and helps keep the contract steady.

3. The task collection: /api/v1/tasks

The path /api/v1/tasks is a collection of tasks. Imagine it as a shelf in a store: all items (tasks) sit on it. With a shelf, you usually do two things: “look at what’s on the shelf” and “put a new item on the shelf”.

Exactly the same in an API: listing tasks is a request to the collection, creating a task is adding to the collection. The method distinguishes those actions, while the path stays the same.

There’s an important psychological aspect here: when the path is one and the method changes, the API feels like a “form”, not a “zoo of links”. It’s easier for the client to remember /tasks and then infer logically: GET — read, POST — create.

Example: describing an endpoint as a contract object

package main

type Endpoint struct {
	Method string
	Path   string
}

var listTasks = Endpoint{Method: "GET", Path: "/api/v1/tasks"}
var createTask = Endpoint{Method: "POST", Path: "/api/v1/tasks"}

If you’re thinking, “why do I need this in Go code, we aren’t even writing the server yet?”, the answer is simple: this trains the “contract first” muscle. The same list can later be used in documentation, in tests, and even in logs.

Why tasks is plural

/tasks, not /task, because a collection implies many. Yes, it’s a “good manners” rule, but it genuinely helps your brain. If you see plural, you expect a list. If you see /tasks/7, you expect a single item.

4. One task: /api/v1/tasks/{id}

If /tasks is a shelf, then /tasks/{id} is a card for a specific task. Here {id} is a template in documentation, not literal curly braces in the URL.

That is, in the contract you write:

  • /api/v1/tasks/{id}

and in a real request the client substitutes a number:

  • /api/v1/tasks/7

What {id} means conceptually

id is an identifier. In our educational domain, the easiest agreement is that id is an integer (for example, int). The important part is to “agree and fix it”: the client must understand what exactly it must put into the path.

If you don’t fix the format, you get chaos: one client sends a UUID, another sends numbers, a third sends 0007, and a fourth sends "seven" (because it’s optimistic).

Example: a function that materializes a path from {id}

package main

import "fmt"

func taskPath(id int) string {
	return fmt.Sprintf("/api/v1/tasks/%d", id)
}

These functions feel like a small detail, but they enforce discipline. When you write taskPath(7), you won’t forget /api/v1, you won’t mix up tasks with task, and you won’t add an extra slash. And these functions are very pleasant to use in tests later: you compare a string instead of assembling it by hand everywhere.

5. The done state: a field and the /done path

With done, the first grown-up API design discussion usually happens. Why? Because done is seemingly just a boolean field in a task (done: true/false), but the user wants a separate “Mark as done” button, and the developer wants a separate endpoint where everything is simple and clear.

In our domain, we want to support the straightforward scenario: “mark a task as done”. And for the course we chose an example of a state path:

  • /api/v1/tasks/{id}/done

This reads almost like a phrase: “done for task {id}”. Here /done acts as a subresource or a “state representation”. We are not pretending that done is a separate entity at the level of /done/{id}. We keep it inside the task.

Why /done is fine

Earlier we criticized “verbs in URLs” (/createTask). But /done is a different case: it’s not a “command to the server”, it’s part of the resource model — a state. It’s as if we say: “a task has a done flag, and it has a dedicated place in the path”.

You can think of it like this: /api/v1/tasks/7/done is the “handle” of the completion state for task 7.

Variants people usually compare

There are two popular approaches:

  1. Apply the change by updating the task (for example, partially) and send {"done": true} in the body.
  2. Make a separate state endpoint: /tasks/{id}/done.

In this lecture we pin down specifically the path design /done. As for the exact method semantics (POST/PATCH) and status code details, it’s better to keep them as part of the overall contract, without diving deep into implementation.

Example: the done path function for a specific task

package main

import "fmt"

func taskDonePath(id int) string {
	return fmt.Sprintf("/api/v1/tasks/%d/done", id)
}

Now you have three stable “building blocks”:

  • tasksPath() — collection
  • taskPath(id) — a single resource
  • taskDonePath(id) — the done state

This is the “skeleton” of the URL space for our API.

A small diagram: how it’s all connected

When this all gets mixed up in your head, a simple visualization helps. There’s no REST magic here, just a tree.

flowchart TD
    A["/api/v1/tasks
Task collection"] --> B["/api/v1/tasks/{id}
One task"] B --> C["/api/v1/tasks/{id}/done
Done state"]

The tree shows an important thing: /done lives under the task, not next to it. This reduces the risk of design mistakes where done suddenly starts living as a separate meaningless entity.

6. Vocabulary consistency: tasks, done, {id}

Very often, APIs are ruined not by complex bugs, but by small inconsistencies. One endpoint uses /tasks, another uses /todo, a third uses /items. In your head it’s the same thing, but the client isn’t telepathic. It reads documentation literally.

So in this educational domain we keep one vocabulary:

  • entity: tasks
  • identifier: id
  • state: done
  • version: /api/v1

If you feel like adding “style” and naming tasks todos, stop and ask yourself: “what does the client gain?”. The answer is usually: “nothing, except more room for mistakes”.

An analogy with variable names in Go is useful here: if in one place you write taskID, and in another id, and in a third task_id, then the code becomes harder to read. URL design is the same thing, except the “variables” are read by other programs.

7. An API map in Go code

Even before you have a real HTTP server, it’s useful to keep the contract as a data structure. This helps you avoid arguing with words (“well, it’s somewhere like /done”), and instead see concrete strings.

It also reduces the chance of typos. A typo in a URL is one of the most annoying bugs: everything is “almost correct”, but you get 404, and you spend an hour looking for the extra letter.

Step 1: define operations and a route list

package main

type Route struct {
	Method  string
	Pattern string
	Op      string
}

func apiRoutes() []Route {
	return []Route{
		{Method: "GET", Pattern: "/api/v1/tasks", Op: "list_tasks"},
		{Method: "POST", Pattern: "/api/v1/tasks", Op: "create_task"},
		{Method: "GET", Pattern: "/api/v1/tasks/{id}", Op: "get_task"},
		{Method: "POST", Pattern: "/api/v1/tasks/{id}/done", Op: "mark_done"},
	}
}

Note: we use {id} specifically as a documentation template. It’s convenient for reading and discussion.

Step 2: print the “map” as mini-documentation

package main

import "fmt"

func printRoutes(routes []Route) {
	for _, r := range routes {
		fmt.Printf("%s %-25s  # %s\n", r.Method, r.Pattern, r.Op)
	}
}

Step 3: wire everything in main

package main

func main() {
	printRoutes(apiRoutes())
}

If you run it, the output will be roughly like this:

GET  /api/v1/tasks             # list_tasks
POST /api/v1/tasks             # create_task
GET  /api/v1/tasks/{id}        # get_task
POST /api/v1/tasks/{id}/done   # mark_done

It looks almost like a README draft. And that’s the beauty of it: the contract should be simple enough to fit “on one screen”.

Why changing paths is almost always painful

Paths are part of the contract, and they’re hard to change without consequences. Even if you “just renamed” /tasks to /todo, clients will stop working.

In the Go world, compatibility is not an abstraction. If a public API changes, other people’s code breaks, and that’s a very practical problem: you can’t “take an API back” without breakage. The HTTP contract behaves the same way: you can’t quietly “pull out” an endpoint if someone depends on it.

And there’s another good example of how compatibility influences design: when the Go standard library added context.Context, it couldn’t just change the signature of http.Client.Do, because that would have broken tons of code. So context support was built into http.Request in a way that didn’t break old calls.

That’s a great lesson: interface design (and API design) is often determined not by “ideal architecture”, but by responsibility to users.

8. Common mistakes when designing task paths

Mistake #1: command URLs (/createTask, /markDone) instead of resources.
This usually comes from wanting to do things “like RPC”: a separate command for each action. The result is a zoo of paths, and the client can’t predict the API by shape. The fix is a simple rule: the path is a noun (resource), the action is the HTTP method, and exceptions like /done must be part of the state model, not a random command.

Mistake #2: no single vocabulary (/tasks, /todo, /items) and naming drift.
At first it seems “minor”. Then suddenly it turns out that different dev teams named the same thing differently, and the client writes hacks like “if /tasks doesn’t work, try /todo”. A good approach is to pick one domain term (tasks) and keep it everywhere: in paths, in DTOs, in documentation, and in examples.

Mistake #3: {id} is not defined as a format, and each client interprets it differently.
If you don’t say that id is an integer (or a UUID), clients will start guessing. It’s especially “fun” when one client sends "7", another sends "007", and a third sends "seven". In the contract you must explicitly fix it: {id} is, for example, a positive integer. Even if you haven’t implemented validation yet, the agreement should already exist.

Mistake #4: confusing state and resource for done.
Sometimes done suddenly gets turned into a separate top-level resource (/api/v1/done/{id}), and the API becomes strange: “where is the task and where is the flag?”. In our domain, done is a task state, so it lives under /tasks/{id}: /tasks/{id}/done. That makes it easier for the client to understand that it’s the state of a specific task that changes.

Mistake #5: no version (/api/tasks instead of /api/v1/tasks) and hope that “we’ll always be careful”.
The hope is noble, but reality wins: APIs change. Without a version, you’ll either have to break old clients, or carry a lot of backward compatibility without an explicit separation of rules. A version in the path is a simple and honest way to say: “this is the contract we are responsible for”.

1
Task
Go SELF, level 56, lesson 2
Locked
Collection path
Collection path
1
Task
Go SELF, level 56, lesson 2
Locked
Task paths
Task paths
1
Task
Go SELF, level 56, lesson 2
Locked
Route map
Route map
1
Task
Go SELF, level 56, lesson 2
Locked
URL Validator
URL Validator
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION