Golang Monolith Mastery: Building the CREATE Listing Endpoint
This chapter focuses on designing and implementing a production-grade POST /listings HTTP endpoint in Go. It explores the request lifecycle from JSON decoding and payload mapping to parameterized PostgreSQL insertions using RETURNING clauses, defensive error management, structured contextual logging, and automated cloud deployment.
1. RESTful Design & The HTTP 405 Method Not Allowed
In REST architecture, endpoints represent resources rather than actions. The action performed on a resource is dictated by the HTTP method (verb) rather than changes to the URL path:
GET /listingsretrieves listings.POST /listingscreates a new listing.DELETE /listings/{id}removes a listing.
When a client sends an HTTP request using an unsupported method to an existing route (for instance, firing a POST request when only GET has been registered), the router should not return 404 Not Found. Returning 404 falsely implies that the resource URI does not exist.
Instead, compliant HTTP routers return 405 Method Not Allowed, explicitly indicating that the resource exists at this path, but the server is not configured to accept that specific HTTP verb.
2. Domain Modeling & Financial Precision (int64 vs Floats)
When receiving resource creation payloads, the fields must map cleanly from JSON to internal Go structs.
Modeling Financial Values
Handling currency (e.g., product prices) using floating-point types (float32, float64) introduces catastrophic rounding errors due to IEEE-754 binary floating-point representation.
0.1 + 0.2 = 0.30000000000000004
In production systems, monetary values should be stored as 64-bit integers (int64) representing the currency's lowest denominational unit (e.g., cents in USD, paise in INR):
For example, a price of $150.00 is represented and stored as 15000.
Struct Definition
PostgreSQL handles the id (via gen_random_uuid()) and created_at (via CURRENT_TIMESTAMP / now()) automatically. Therefore, the incoming JSON payload should only deserialize the attributes provided by the client:
type Listing struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Price int64 `json:"price"` // Stored in smallest currency unit (cents/paise)
City string `json:"city"`
CreatedAt time.Time `json:"created_at"`
}
3. Request Flow Architecture
The data lifecycle for creating a listing transitions through four distinct layers:
Client (HTTP Client / Insomnia)
|
| 1. Sends HTTP POST with JSON body
v
HTTP Transport Layer (net/http Handler)
|
| 2. Decodes JSON stream into Go struct (Listing)
| 3. Extracts Request ID from context
v
Persistence Layer (PostgreSQL Driver)
|
| 4. Executes parameterized INSERT with RETURNING id
| 5. Scans generated UUID into local variable
v
HTTP Response Writer
|
| 6. Serializes response payload {"id": "<uuid>"}
| 7. Writes HTTP Status 201 Created and Content-Type header
v
Client Receives Confirmation
4. JSON Payload Decoding & Deserialization
Go provides two primary mechanisms to parse JSON: json.Unmarshal and json.NewDecoder.
Because r.Body implements io.ReadCloser, streaming the payload via json.NewDecoder(r.Body).Decode(&target) avoids buffering the entire request body into memory before parsing.
var req Listing
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Context extraction for distributed tracing
reqID := httpx.GetRequestID(r.Context())
lh.logger.Error("Failed to decode listing request body",
"request_id", reqID,
"error", err.Error(),
)
httpx.Error(
w,
http.StatusBadRequest,
"Invalid body",
httpx.CodeMalformedJSON,
)
return
}
Note: Calling
json.NewDecoder().Decode()does not close or finalize the HTTP connection. If decoding fails, the handler must issue an explicitreturnstatement to halt execution; otherwise, the runtime will continue down the function and attempt database operations on uninitialized data.
5. Parameterized SQL & Preventing Injection
Directly interpolating user input into an SQL query string leaves the system open to SQL Injection attacks:
// INSECURE: Vulnerable to SQL Injection
query := fmt.Sprintf("INSERT INTO listings (title) VALUES ('%s')", req.Title)
Instead, use parameterized placeholders. In PostgreSQL, placeholders use ordinal dollar notation ($1, $2, $3, $4).
INSERT INTO listings (
title,
description,
price,
city
) VALUES (
$1, $2, $3, $4
) RETURNING id;
When passing parameters to the database driver:
- Placeholders must match the driver's syntax (
$nfor PostgreSQL,?for MySQL/SQLite). - The number of placeholders must precisely match the number of bound arguments.
- The positional index of each argument in Go must correspond exactly to the column order declared in the
INSERTstatement.
6. Execution Strategy: ExecContext vs. QueryRowContext
The standard library database/sql package provides different query methods depending on the expected return values:
Why QueryRowContext for Insertions?
In standard REST practices, creating a resource should return the unique identifier (or the full entity) created by the server.
While some database drivers (like MySQL) populate LastInsertId() via ExecContext, PostgreSQL does not support LastInsertId() for UUID primary keys.
To retrieve the generated UUID without a separate SELECT query round-trip, use the RETURNING id clause:
var id string
query := `
INSERT INTO listings (title, description, price, city)
VALUES ($1, $2, $3, $4)
RETURNING id
`
// QueryRowContext returns a *sql.Row, deferring errors until .Scan() is called
err := lh.db.QueryRowContext(
r.Context(),
query,
req.Title,
req.Description,
req.Price,
req.City,
).Scan(&id)
if err != nil {
// Database failure handling
}
QueryRowContext returns a non-nil *sql.Row pointer immediately. Any underlying execution or connection errors are deferred until Scan(&id) evaluates the returned row.
7. Defensive Error Handling & Contextual Logging
Production architectures enforce strict boundaries between client responses and internal logs.
Security Considerations for Client Responses
Never leak raw database error messages (e.g., pq: relation "listings" does not exist or connection pool exhaustion strings) to the client. Leaking schema details, table names, or internal IP addresses aids attackers in reconnaissance.
- Log Internally at Error Level: Capture the exact underlying error, operation context, and the correlation
request_id. - Send Sanitized Client Responses: Transmit a standard HTTP 500 code with a safe message (
"Something went wrong") and an internal error classification code.
if err != nil {
reqID := httpx.GetRequestID(r.Context())
lh.logger.Error("Failed to insert listing into database",
"request_id", reqID,
"error", err.Error(),
)
httpx.Error(
w,
http.StatusInternalServerError,
"Something went wrong",
httpx.CodeInternalError,
)
return
}
8. HTTP 201 Response Construction
According to the HTTP/1.1 specification (RFC 7231):
200 OK: Request succeeded, standard response payload.201 Created: Request fulfilled, resulting in the creation of a new resource.
To finalize the creation workflow:
- Set the
Content-Typeheader toapplication/json. - Explicitly write the HTTP status header using
w.WriteHeader(http.StatusCreated). - Encode the response JSON containing the new resource's identifier.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
responsePayload := map[string]string{
"id": id,
}
if err := json.NewEncoder(w).Encode(responsePayload); err != nil {
lh.logger.Error("Failed to encode response payload", "error", err.Error())
}
9. Complete End-to-End Implementation
The HTTP Handler Implementation
File: internal/handlers/listings.go
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/jmoiron/sqlx"
"yourproject/internal/httpx"
)
type Listing struct {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
Price int64 `json:"price" db:"price"`
City string `json:"city" db:"city"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type ListingHandler struct {
db *sqlx.DB
logger *slog.Logger
}
func NewListingHandler(db *sqlx.DB, logger *slog.Logger) *ListingHandler {
return &ListingHandler{
db: db,
logger: logger,
}
}
func (lh *ListingHandler) Create(w http.ResponseWriter, r *http.Request) {
reqID := httpx.GetRequestID(r.Context())
// 1. Decode incoming JSON payload
var req Listing
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
lh.logger.Error("Failed to decode listing body",
"request_id", reqID,
"error", err.Error(),
)
httpx.Error(w, http.StatusBadRequest, "Invalid body", httpx.CodeMalformedJSON)
return
}
// 2. Execute parameterized SQL query with RETURNING clause
const query = `
INSERT INTO listings (title, description, price, city)
VALUES ($1, $2, $3, $4)
RETURNING id;
`
var createdID string
err := lh.db.QueryRowContext(
r.Context(),
query,
req.Title,
req.Description,
req.Price,
req.City,
).Scan(&createdID)
if err != nil {
lh.logger.Error("Failed to insert listing",
"request_id", reqID,
"error", err.Error(),
)
httpx.Error(w, http.StatusInternalServerError, "Something went wrong", httpx.CodeInternalError)
return
}
// 3. Emit structured audit log
lh.logger.Info("Listing created successfully",
"request_id", reqID,
"listing_id", createdID,
)
// 4. Return HTTP 201 Created with JSON response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{
"id": createdID,
})
}
Route Registration
File: cmd/api/main.go
mux := http.NewServeMux()
// Registering specific method and path patterns (Go 1.22+)
mux.HandleFunc("POST /listings", listingHandler.Create)
mux.HandleFunc("GET /listings", listingHandler.GetAll)
mux.HandleFunc("DELETE /listings/{id}", listingHandler.Delete)
10. Production Deployment Verification & Observability
Once committed and pushed to git, modern hosting providers (e.g., Render) automatically detect commits on the primary branch, build the binary, and start the service.
1. Production Verification Request
Using an API client against the production endpoint:
curl -X POST https://api.yourdomain.com/listings \
-H "Content-Type: application/json" \
-d '{
"title": "Smart Television",
"description": "55-inch 4K OLED in mint condition",
"price": 4500000,
"city": "Mumbai"
}'
Expected Response:
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "e4b5f884-297d-419b-a316-2487e411b518"
}
2. Log Analysis
Inspecting production logs confirms the entry passes through the middleware stack:
{
"time": "2025-02-14T10:15:30.124Z",
"level": "INFO",
"source": "handlers/listings.go:78",
"msg": "Listing created successfully",
"request_id": "req-c8914ab0-001",
"listing_id": "e4b5f884-297d-419b-a316-2487e411b518"
}
Correlating request_id with listing_id makes tracking requests straightforward across distributed systems, load balancers, and internal logs.
11. Production Pitfalls & Architectural Roadmap
1. Lack of Payload Validation
Currently, an empty JSON {} passes decoding without errors, inserting empty strings and zero values into the database. A comprehensive validation layer is required to enforce:
title: Non-empty, minimum length (e.g., 5 characters), maximum length (e.g., 100 characters).price: Strict positive non-zero validation ().city: Standardized against allowed locations or non-blank strings.
2. Boilerplate Serialization Overhead
Manually managing headers, status codes, and JSON encoders across every handler introduces repetitive boilerplate:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(payload)
A common refactoring pattern is centralizing this logic into a utility helper (e.g., httpx.JSON(w, http.StatusCreated, payload)).
3. Missing Relational Constraints (Authentication & Ownership)
The current listing entity lacks a foreign key pointing to an owning user (user_id). In subsequent iterations:
- Authentication middleware must verify client credentials and inject the caller's identity into
r.Context(). - A database schema migration will add a
user_id UUID NOT NULL REFERENCES users(id)column to thelistingstable. - The
INSERTquery will map the authenticated user's ID alongside the listing details.
Summary and Key Takeaways
- Adhere to REST Semantics: Shared URIs (
/listings) are differentiated by their HTTP verb. Issuing unsupported verbs against valid paths must return405 Method Not Allowed, not404 Not Found. - Maintain Precision in Monetary Fields: Never use floating-point types for currency. Represent money as an integer (
int64) in the smallest base denomination (e.g., cents, paise) to eliminate rounding errors. - Stream Request Bodies: Prefer
json.NewDecoder(r.Body).Decode(&target)overjson.Unmarshalwhen parsing incoming HTTP streams to minimize unnecessary memory allocations. - Enforce SQL Parameterization: Always supply variables through database driver placeholders (2, ...) rather than string concatenation, protecting the database against SQL injection vulnerabilities.
- Optimize Database Round-Trips: Use PostgreSQL's
RETURNINGclause withdb.QueryRowContext()to retrieve system-generated keys (like UUIDs) in a single atomic database operation. - Decouple Internal Logs from Client Responses: Protect internal system details by logging technical errors internally while returning generic, structured error objects to clients.
- Standardize Creation Status Codes: Resource creation endpoints must return
201 Createdalong with the unique resource identifier rather than a generic200 OK. - Trace via Contextual Logging: Always include correlation identifiers (
request_id) and domain identifiers (listing_id) in structured logs to make production debugging traceable.