Hello! In our last lesson, you did a great job setting up the skeleton of our REST API. You now have a running chi server, a clean Server struct to manage dependencies, and a set of well-organized, grouped routes with stub handlers.
This lesson bridges the gap between those empty handlers and a functional API endpoint.
Your Goal for Today
Learning Outcome: Implement structured JSON request parsing and response writing with proper error handling.
Today, we're going to flesh out our first real endpoint: handleCreateAccount. This involves three key activities:
- Decoding an incoming JSON request body into a Go struct.
- Encoding a Go struct into a JSON response body.
- Handling errors gracefully and communicating them back to the client in a structured JSON format.
By the end of this lesson, you'll have a POST /accounts endpoint that can accept JSON data, create an account using our storage layer, and return a proper JSON response or a detailed JSON error. This is a core skill for building any professional API in Go.
1. The Anatomy of a JSON API Handler
Before we write code, let's break down the logical flow of a typical handler that creates a resource:
- Parse & Decode: Read the HTTP request body and decode the JSON payload into a Go struct.
- Validate: Check if the data from the request is valid (e.g., required fields are present).
- Business Logic: Perform the core action, in this case, calling our
storage.CreateAccountmethod. - Respond: Encode the result (or an error) back into a JSON response and send it to the client with the correct HTTP status code.
We'll tackle these steps systematically, building reusable helper functions along the way to keep our handlers clean and maintainable—a principle you'll appreciate from your system design experience.
First, let's define the data structures for our createAccount endpoint. In your internal/storage package (e.g., internal/storage/storage.go), you likely have an Account struct. We'll use this for our response. For the request, we'll create a new struct inside internal/server/handlers.go for now.
// internal/server/handlers.go
// ... imports and existing code ...
type CreateAccountRequest struct {
Owner string `json:"owner"`
Currency string `json:"currency"`
}
// ... existing handler stubs ...
Notice the json:"owner" tags. These tell the encoding/json package how to map the fields between the JSON payload and the Go struct. It's conventional to use camelCase in JSON and PascalCase for exported struct fields in Go.
2. Decoding Requests and Encoding Responses
Go's standard library provides the encoding/json package, which is all you need for handling JSON. While there are a couple of ways to decode JSON, using json.NewDecoder is generally the most efficient and robust method for reading from a stream like a request body.
This video provides an excellent overview of the fundamentals of both decoding (unmarshaling) and encoding (marshaling) JSON in Go.
Go Programming - JSON Encoding & Decoding in Golang
To get a solid grasp of the core mechanics, please watch this video from the BugBytes channel. It clearly explains how to map JSON to structs, the difference between json.Unmarshal and json.NewDecoder, and how to use struct tags.
Please watch the following two segments: Decoding JSON (05:10 - 11:22): Focus on how json.NewDecoder is used with the request body and how struct tags (json:"...") map JSON keys to struct fields. Encoding Struct to JSON (16:40 - 20:21): Pay attention to json.MarshalIndent for creating human-readable JSON responses.
A crucial feature for robust APIs, mentioned in the video and highly recommended, is DisallowUnknownFields. This causes the decoder to return an error if the client sends JSON with fields that don't exist in your Go struct. It prevents unexpected data from being silently ignored and can protect against certain classes of bugs and attacks.
3. Building Reusable JSON Helpers
You could put decoding and encoding logic directly into every handler, but that would lead to a lot of repeated code. A much better approach, common in professional Go applications, is to create helper methods.
Let's create a new file, internal/server/json.go, to house these helpers. We'll define them as methods on our Server struct so they can access its dependencies, like the logger, if needed.
// internal/server/json.go
package server
import (
"encoding/json"
"fmt"
"net/http"
)
// writeJSON is a helper for writing arbitrary data as a JSON response.
func (s *Server) writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
// If encoding fails, we can't send a JSON error, so just log it.
// The client will likely see a partial or empty response.
fmt.Printf("error encoding json: %v\n", err)
}
}
// readJSON is a helper for decoding a JSON request body.
func (s *Server) readJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
// Use a max body size to prevent clients from sending excessively large requests.
// For example, 1MB.
r.Body = http.MaxBytesReader(w, r.Body, 1048576)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
// We'll improve this error handling in the next section.
return err
}
return nil
}
writeJSON: This helper centralizes response writing. It sets theContent-Typeheader, writes the HTTP status code, and then usesjson.NewEncoder(w).Encode(v)to stream the JSON-encoded data directly to thehttp.ResponseWriter.readJSON: This helper handles decoding. It useshttp.MaxBytesReaderto limit the request body size, then creates a decoder that disallows unknown fields, and finally decodes the body into the provided interfacev.
This pattern is highly effective and is also demonstrated in the article below by Ben Hoyt.
Improving the code from the official Go RESTful API tutorial - Ben Hoyt
This article discusses several improvements to a basic Go web service. The section on JSON helpers is particularly relevant to what we are building now.
Read the first part of the article, focusing on the writeJSON and readJSON helper functions. Note how they encapsulate the core logic of marshaling and unmarshaling.
4. Designing Structured Error Responses
Simply returning an error string is not enough for a good API. Clients, especially programmatic ones, benefit from a consistent, structured error format.

We'll create a helper to standardize our error responses. The goal is to always return JSON, even for errors.
First, let's define a generic ApiError struct in internal/server/json.go.
// internal/server/json.go
// ... add to existing file ...
type ApiError struct {
Status int `json:"status"`
Message string `json:"message"`
}
Now, we can create a writeError helper. This function will use our writeJSON helper to send the structured error.
// internal/server/json.go
// ... add to existing file ...
func (s *Server) writeError(w http.ResponseWriter, status int, message string) {
errResponse := ApiError{
Status: status,
Message: message,
}
s.writeJSON(w, status, errResponse)
}
With this in place, we can make our readJSON helper much more robust. Instead of just returning an error, it can directly write the appropriate HTTP response.
Let's refactor readJSON to use writeError.
// internal/server/json.go -- REVISED readJSON
func (s *Server) readJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
r.Body = http.MaxBytesReader(w, r.Body, 1048576)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
// Here, we can check for specific error types to provide better messages.
// For now, a generic bad request is fine.
s.writeError(w, http.StatusBadRequest, "Invalid request payload")
return err
}
return nil
}
Now, readJSON has a clear contract: it attempts to decode the JSON, and if it fails, it handles sending the error response to the client and returns a non-nil error. The calling handler can simply return if readJSON returns an error.
The article "5 API Design Patterns in Go" also describes this powerful pattern of using helper methods for uniform error handling.
5 API Design Patterns in Go That Solve Your Biggest Problems (2025)
This article covers several advanced API design patterns. The 'Handler Pattern' section has a subsection on error handling that aligns perfectly with our goal.
Read the subsection '3. Uniform Error Handling' within the 'The Handler Pattern' section. Notice the use of respondError and respondJSON helpers to create a consistent error handling strategy.
5. Implementing the handleCreateAccount Handler
We now have all the pieces to implement our first real handler. Let's put them together in internal/server/handlers.go.
// internal/server/handlers.go
package server
import (
"net/http"
// Use your actual module path
"your_module_path/internal/storage"
)
type CreateAccountRequest struct {
Owner string `json:"owner"`
Currency string `json:"currency"`
}
func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
var req CreateAccountRequest
if err := s.readJSON(w, r, &req); err != nil {
// readJSON already sent the error response
return
}
// --- Basic Validation ---
if req.Owner == "" || req.Currency == "" {
s.writeError(w, http.StatusBadRequest, "owner and currency are required fields")
return
}
// --- Business Logic ---
account, err := s.store.CreateAccount(r.Context(), &storage.Account{
Owner: req.Owner,
Currency: req.Currency,
Balance: 0, // Initial balance is always 0
})
if err != nil {
// This could be a duplicate key error, or something else.
// For now, we'll return a generic internal server error.
// In a future lesson, we can inspect this error more closely.
s.writeError(w, http.StatusInternalServerError, "Could not create account")
return
}
// --- Respond ---
s.writeJSON(w, http.StatusCreated, account)
}
// ... other handler stubs ...
Look at how clean the handler is. It reads like a sequence of steps, and the low-level details of JSON and HTTP are abstracted away in our helpers.
- It calls
s.readJSON. If that fails, it returns immediately. - It performs simple validation.
- It calls the storage layer. If that fails, it writes an internal server error.
- On success, it calls
s.writeJSONwith a201 Createdstatus and the newly created account.
You can now test this with curl:
Success Case:
curl -X POST http://localhost:8080/accounts \
-H "Content-Type: application/json" \
-d '{"owner": "alex", "currency": "USD"}'
# Expected Response (201 Created):
# {
# "id": 1,
# "owner": "alex",
# "balance": 0,
# "currency": "USD",
# "created_at": "..."
# }
Validation Error Case:
curl -X POST http://localhost:8080/accounts \
-H "Content-Type: application/json" \
-d '{"owner": "alex"}'
# Expected Response (400 Bad Request):
# {
# "status": 400,
# "message": "owner and currency are required fields"
# }
Malformed JSON Case:
curl -X POST http://localhost:8080/accounts \
-H "Content-Type: application/json" \
-d '{"owner": "alex",}' # Note the trailing comma
# Expected Response (400 Bad Request):
# {
# "status": 400,
# "message": "Invalid request payload"
# }
Conclusion
Fantastic work! You have successfully implemented a fully functional API endpoint with professional-grade request and response handling. This is a significant step forward from stub handlers.
Key Takeaways:
- Use
json.NewDecoderandjson.NewEncoderfor efficient streaming of JSON data from and to HTTP requests. - Helper functions (
readJSON,writeJSON,writeError) are essential for writing clean, maintainable, and DRY (Don't Repeat Yourself) handlers. - Structured JSON errors provide a much better experience for API clients than plain text errors.
DisallowUnknownFieldsadds a layer of security and robustness to your request parsing.
Next Up
Our server is now functional, but we have hardcoded values like the port number and database connection string. In the next lesson, we will address this by learning how to load application configuration from environment variables and files, a crucial step in making our application configurable and ready for different environments (development, testing, production).