JavaRush /Courses /Go SELF /Done endpoint — PATCH/POST semantics and 200/204 response...

Done endpoint — PATCH/POST semantics and 200/204 responses

Go SELF
Level 61, Lesson 2
Available

1. Why you need a done endpoint at all

When you implement CRUD for tasks, you eventually hit the moment of truth: “Okay, we can create and read tasks, but how do we change state?” In learning projects you just want to write task.Done = true and move on. But in an HTTP API this turns into a separate contract: which method, which path, what do we return, is the action idempotent, what to do on repeated calls. And it is exactly in these “simple” places that people most often end up with an API that is later painful to maintain.

Let’s imagine a task is a resource, and the resource has a done state (done/not done). A client (CLI, frontend, another service) should be able to tell the server: “mark this task as done.” Technically, that’s an update. But updates in HTTP come in different forms, and next we’ll break down how to choose the right one and not confuse either the client or yourself a week later.

PATCH vs POST: what we choose and why

If you’re just getting started, a very natural thought is: “We have an action ‘mark done’, so we should POST: /tasks/{id}/done.” This is the logic “POST = perform an action.” It’s not completely wrong, but it has side effects: you mix “resource” and “command”, and you also risk losing idempotency (and later wondering why client retries are ruining your life).

For “change part of a resource”, the HTTP method that most often fits is PATCH. Mentally it sounds like: “I’m not recreating the entire task (not PUT), I’m not creating a sub-resource (not POST), I’m partially changing an existing resource.” And marking it as “done” is exactly a partial change.

At the same time, there are two popular designs:

  • PATCH /api/v1/tasks/{id} with body {"done": true}
  • PATCH /api/v1/tasks/{id}/done with no body (or with a body, but usually without)

In this module we’ll stick with the more straightforward, beginner-friendly option: PATCH /api/v1/tasks/{id}/done, because it’s easier to read and easier to validate (we don’t need to parse JSON just to get true). Also, it fits well with the idea of an “endpoint for changing state.”

To avoid turning this into a religious war of “PATCH vs POST”, let’s lock in a useful table. It’s not absolute truth, but it disciplines your thinking very well:

Option Example Pros Cons
PATCH
PATCH /tasks/10/done
closer to “changing part of a resource”, easier to make idempotent some argue that “done” looks like a command
POST
POST /tasks/10/done
reads well as an action more often associated with “create/start”, easy to accidentally make non-idempotent

My practical advice: if the action is, by meaning, a resource state change, and you want repeated calls not to cause chaos, use PATCH.

2. Idempotency: why a repeated request must not make “even more done”

The word “idempotency” sounds like a spell from a math textbook (and yes, it can be intimidating). But in HTTP it’s a very down-to-earth idea: if the client retries a request due to a network failure or timeout, the result should remain predictable.

For “mark done” we want:

  • the first call to make the task done;
  • the second/third/fiftieth call to not corrupt data and not turn done into “done done done”.

That is, the operation is idempotent by meaning: after completion, the state is the same.

From this follows an important domain-layer contract: the method MarkDone(id) must behave calmly even if the task is already done=true. There are different possible behaviors here, but for our learning API the following is convenient:

  • if the task exists, we guarantee done=true and return success;
  • if the task does not exist, return ErrNotFound (possibly wrapped);
  • if id is invalid, it’s a validation error before the domain layer.

4. 200 vs 204: what to return to the client

Now the fun part: what do we return in response to a successful “mark done”.

There are two main options:

  • 204 No Content — success with no response body
  • 200 OK — success with a body, e.g., return the updated task

Both are valid, but they imply different client workflows.

Option A: 204 No Content

This option says: “I did it. I won’t say anything else.” It’s good because:

  • implementation is simpler (no DTO needed, no json.NewEncoder(w).Encode(...));
  • less traffic;
  • less chance to accidentally “drift” the response format.

But there is a strict nuance: with 204 you must not write a body. At all. Not even "ok". Not even a single space. Not even “I swear it’s only 2 bytes”. Some proxies and clients will behave oddly.

Option B: 200 OK + JSON

This option says: “I did it and here is the final state.” It’s good because the client doesn’t need an extra GET to see updated fields.

But you pay with complexity: you must decide what exactly to return. The full task? Only {"id":10,"done":true}? You also need to keep the DTO stable.

In this module we choose 204 No Content as the default, “clean”, and easy-to-maintain contract. If you really want to return JSON, that’s also possible, but then agree on it explicitly and keep the same style across the entire API.

A small mini-diagram to reinforce how these options differ in “feel”:

sequenceDiagram
    participant C as Client
    participant S as Server

    C->>S: PATCH /tasks/10/done
    alt 204 No Content
        S-->>C: 204 (no body)
        Note over C: Client can do GET if needed
    else 200 OK + body
        S-->>C: 200 + {"id":10,"done":true,...}
        Note over C: Client sees the final state immediately
    end

5. Endpoint contract: method, path, input, output

Now we’ll formalize the contract so it can be read like an agreement, not like “well, it kind of works like this.”

We implement:

  • Method: PATCH
  • Path: /api/v1/tasks/{id}/done
  • Path params: {id} — required, integer > 0
  • Request body: none
  • Success: 204 No Content
  • Errors:
    • 400 (validation) if id is empty/not a number/<=0
    • 404 (not_found) if the task does not exist
    • 500 (internal) for other unexpected problems

Notice the important pedantry: “id is not a number” is not 404. It’s bad input, which means 400. We keep 404 for the situation “the number is fine, but there is no such resource.”

6. Routing in Go 1.22+: register the handler

Before writing the handler, you need to give it an address in ServeMux. Since we’re on Go 1.22+ and we use patterns with the method included, it reads very clearly: the route string itself documents the contract.

package main

import (
	"net/http"
)

func routes(s *Server) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("PATCH /api/v1/tasks/{id}/done", s.handleMarkDone)
	return mux
}

Pay attention to an important detail: we don’t “glue strings together” and we don’t try to manually parse the URL. The path parameter {id} is then retrieved via r.PathValue("id"). This removes a lot of routine and reduces the chance of making a mistake while parsing the path.

7. Unified {id} parsing: parseID and ValidationError

The point of parseID in our application is not only to convert a string to a number. The main goal is that all endpoints interpret a bad id the same way and return the same validation-error shape.

Example (reminder: this may already have been written earlier; here we only lock in the idea of which error we want to return):

package main

import (
	"strconv"
)

func parseID(raw string) (int, error) {
	id, err := strconv.Atoi(raw)
	if err != nil || id <= 0 {
		return 0, &ValidationError{
			Fields: map[string]string{"id": "must be positive integer"},
		}
	}
	return id, nil
}

Key point: parseID returns a ValidationError specifically so writeError can reliably turn it into a 400 with fields. That is, the handler does not decide “what is an id error” — it just calls a shared helper.

8. Handler: parse → app → status

A handler for done is a great example of “short and honest code.” It should look as linear as possible: parse id, call the domain operation, return 204. No JSON encoders, no “let’s also print a log into the response”.

package main

import (
	"net/http"
)

func (s *Server) handleMarkDone(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r.PathValue("id"))
	if err != nil {
		writeError(w, err)
		return
	}

	if err := s.app.MarkDone(r.Context(), id); err != nil {
		writeError(w, err)
		return
	}

	w.WriteHeader(http.StatusNoContent) // 204
}

If you catch yourself thinking “maybe we should write fmt.Fprintln(w, "ok")?”, stop. With 204 this violates the contract. In API-land, “I wrote a tiny body” is like “I slightly broke the protocol”: it seems minor, but later someone will be debugging it at night.

9. Domain layer: how to return not_found

The HTTP layer should not “understand the database” or storage details. It knows how to map domain error classes to HTTP status codes. Therefore, the domain layer must return errors in a way that makes classification stable.

We agreed that not found is the sentinel ErrNotFound, recognized via errors.Is. And it’s very important: if you add context via fmt.Errorf, use %w so the error remains recognizable in the chain. In Go, this is the standard practice for working with sentinel errors.

Here is a minimal example of a domain method:

package main

import (
	"context"
	"fmt"
)

func (a *App) MarkDone(ctx context.Context, id int) error {
	ok := a.store.SetDone(id, true)
	if !ok {
		return fmt.Errorf("mark done %d: %w", id, ErrNotFound)
	}
	return nil
}

Why do we do it this way? Because errors.Is(err, ErrNotFound) will work even with wrapping (wrapping), and that is much more reliable than err == ErrNotFound.

10. If the task is already done: error or success?

This is where many beginners (and not only beginners) start arguing with reality. It seems logical: “If it’s already done, then it’s an error.” But in an API it often turns into inconvenience: the client has to do a GET first to learn the state, then decide whether to call PATCH. And what if two clients do it simultaneously? What if it’s a mobile UI and the network is unstable?

For our learning contract, a convenient and practical rule is: if the task exists, MarkDone is always a success, even if it is already done=true. This makes the operation naturally idempotent. The client can retry safely, and the state won’t break.

If you really want to tell the client “it was already done”, you can only do that if you return 200 with a body and, for example, a field updated: false. But that complicates the contract, and for a basic course we won’t take it: 204 is simpler, cleaner, and has fewer bug surfaces.

11. Alternatives: POST and a 200 response

If you still use POST

Sometimes you’ll see APIs that do POST /tasks/{id}/done. This is especially common in systems built around “commands” (command-style API) or events (event-style). There POST means “create an event/perform an action”, and that’s fine within that architecture.

But if you choose POST, you need to be twice as careful about idempotency. If the client repeats POST due to a retry, and your server naively writes a “done event” into a log, queue, or history, you might get two events instead of one. Sometimes that’s acceptable, sometimes it breaks analytics, sometimes it breaks billing (and then you get a new friend: accounting).

Therefore, within our project, PATCH remains the safer choice, because the “partial update” semantics are inherently closer to “set done=true”.

If you return 200 OK + JSON

So you don’t think 204 is the only religion, here’s what “200 OK + return the task” might look like. It’s useful at least to understand what price you pay for client convenience.

Suppose MarkDone returns an updated DTO:

package main

import (
	"context"
)

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

type TaskApp interface {
	MarkDone(ctx context.Context, id int) (TaskDTO, error)
}

Then the handler is no longer that minimal:

package main

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

func (s *Server) handleMarkDone200(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r.PathValue("id"))
	if err != nil {
		writeError(w, err)
		return
	}

	task, err := s.app.MarkDone(r.Context(), id)
	if err != nil {
		writeError(w, err)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(task) // 200 by default
}

Notice how more decisions appear immediately: which DTO, which fields, what to do with Title (what if we don’t want to expose it?), how to keep compatibility. That’s why in the current module we prefer 204: it teaches careful minimalism and stable contracts.

12. Common mistakes

Mistake #1: returning 404 when id is not a number.
This is one of the most common logic mistakes: “well, if we didn’t find it, it’s 404.” But id=abc is not “resource not found”, it’s “the client sent garbage.” So validation always comes first and you return 400 validation, and 404 not_found only when id is valid but the entity doesn’t exist.

Mistake #2: writing a body with 204 No Content.
Often it looks innocent: w.WriteHeader(204) and then fmt.Fprintln(w, "ok"). In local tests it “seems to work”, and then the client or proxy starts acting up. If the contract is 204, then after WriteHeader you simply return from the handler and don’t try to “be helpful with some text”.

Mistake #3: doing err == ErrNotFound and losing not found when wrapping.
When the domain layer adds context via fmt.Errorf("...: %w", ErrNotFound), the comparison err == ErrNotFound stops working. The correct approach for a sentinel is errors.Is(err, ErrNotFound), because it walks the wrapper chain.

Mistake #4: implementing “done” as a non-idempotent action.
Sometimes internally people implement a “toggle” (done = !done) or “if already done, return an error”. This turns a repeated request (which happens in real life due to retries) into a source of random bugs. For a basic API, the contract “after the call done=true” is simpler and more reliable.

Mistake #5: having different {id} parsing in different handlers.
One handler treats id=0 as valid, another treats it as an error; one returns {"id":"bad"}, another returns {"path":"bad"}. From the outside it looks like you have two different APIs in one. The fix is simple: a single parseID and a single ValidationError, and then one writeError for everyone.

1
Task
Go SELF, level 61, lesson 2
Locked
ID validation
ID validation
1
Task
Go SELF, level 61, lesson 2
Locked
PATCH without a body
PATCH without a body
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION