Golang Monolith Mastery: Building a DELETE Endpoint and Refactoring with the Constructor Pattern
This chapter covers the end-to-end implementation of an HTTP DELETE endpoint using Go's standard library multiplexer (net/http), parameterized SQL queries with PostgreSQL, and idiomatic REST response conventions. It then addresses the architectural limitations of closure-based dependency injection and explores a comprehensive refactoring to the Constructor Pattern using structs and method receivers.
1. Introduction: The Need for a DELETE Endpoint
In any RESTful monolithic service, resource life cycles require full CRUD capabilities: Create, Read, Update, and Delete. Prior to this implementation, the listing service supported data retrieval (GET /listings), but lacked a mechanism to purge stale or invalid listings from the PostgreSQL database.
The implementation objectives are:
- Define a route matching
DELETE /listings/{id}using dynamic path wildcards. - Safely parse and sanitize the dynamic identifier from the incoming request.
- Issue a parameterized
DELETESQL query against the database usingdatabase/sql. - Return an appropriate HTTP response code adhering to REST standards.
- Identify scaling bottlenecks in how dependencies (like database connections) are supplied to HTTP handlers, paving the way for clean dependency injection.
2. Dynamic Routing and Path Parameters in Go 1.22+
Historically, Go's standard library http.ServeMux lacked native support for HTTP method matching and dynamic path segments, forcing teams to rely on third-party routers like chi, gorilla/mux, or gin. Starting in Go 1.22, the standard library multiplexer natively supports both HTTP method prefixes and wildcard segments enclosed in curly braces ({param}).
Defining the Route
In cmd/api/main.go, the route is registered on the multiplexer using the METHOD /path/{param} syntax:
mux.HandleFunc("DELETE /listings/{id}", handlers.DeleteListing(db))
The {id} notation indicates a path wildcard. Any value supplied in that segment of the URI path will be captured dynamically by the router during request dispatching.
Extracting Path Values with r.PathValue()
To retrieve the captured segment inside an http.HandlerFunc, Go provides the PathValue(name string) method on *http.Request:
func DeleteListing(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
http.Error(w, "missing listing id", http.StatusBadRequest)
return
}
// Proceed with database deletion...
}
}
Note: The string identifier passed to
r.PathValue("id")must match the segment name defined inmux.HandleFunc(".../{id}", ...)exactly. If no match exists,PathValuereturns an empty string.
3. Database Execution: db.Exec vs. db.Query
When interacting with relational databases via Go's database/sql package, selecting the correct execution method is critical for resource management and query correctness.
Query Parameterization to Prevent SQL Injection
Never concatenate user-supplied input directly into an SQL string:
// INSECURE: Highly vulnerable to SQL Injection
query := fmt.Sprintf("DELETE FROM listings WHERE id = '%s'", id)
db.Exec(query)
Instead, use PostgreSQL positional placeholders ($1, $2, etc.) to create prepared statements handled by the database driver:
query := "DELETE FROM listings WHERE id = $1"
result, err := db.Exec(query, id)
if err != nil {
log.Printf("error executing delete query: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
By passing id as a variadic parameter to db.Exec(), the driver handles parameter binding and sanitization, neutralizing injection vectors.
4. Response Codes and Production Security Mindset for Deletions
Status Codes: 200 OK vs. 204 No Content
When an HTTP DELETE command succeeds, the API must return an appropriate status code:
For a standard synchronous deletion returning no body, 204 No Content is the idiomatic REST standard:
w.WriteHeader(http.StatusNoContent)
The Security Mindset: Preventing Resource Enumeration
db.Exec() returns an sql.Result interface providing two methods:
LastInsertId() (int64, error): Typically used after anINSERTin engines that support it (PostgreSQL usesRETURNING idinstead).RowsAffected() (int64, error): Indicates the count of table rows modified by the command.
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
// The target ID did not exist in the database.
}
A naive approach returns a 404 Not Found error to the client when rows == 0. However, in production security architectures, this introduces a Resource Enumeration / User Enumeration vulnerability. Malicious actors can systematically probe endpoint IDs to discover valid internal identifiers based on whether the server replies with 404 Not Found vs 204 No Content.
Unless a specific internal business requirement mandates notifying the caller that an entity was absent, returning 204 No Content idempotently on valid execution runs conceals internal state, protects resource privacy, and simplifies client integration.
5. Architectural Breakdown: The Problem with Closure-Based Dependency Injection
Prior to refactoring, handlers used a Closure Factory Pattern to inject dependencies like the database connection:
// Naive Factory returning a closure:
func DeleteListing(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Uses `db` from outer scope closure
}
}
The Route Registration Bottleneck
While functional for small applications with single dependencies, this approach quickly breaks down as real-world production requirements are introduced:
// Status Quo Route Registration in main.go
mux.HandleFunc("GET /listings", handlers.List(db))
mux.HandleFunc("DELETE /listings/{id}", handlers.DeleteListing(db))
Imagine adding typical enterprise dependencies:
- A structured logger (
*slog.Loggeror*zap.Logger) - A Redis cache client (
*redis.Client) - An application configuration struct (
*config.Config) - A metrics collector / tracer (
*statsd.Client)
With closure factories, every single route registration in main.go degenerates into an unmaintainable chain of arguments:
// The Dependency Explosion Problem:
mux.HandleFunc("GET /listings", handlers.List(db, logger, cache, cfg, metrics))
mux.HandleFunc("POST /listings", handlers.Create(db, logger, cache, cfg, metrics))
mux.HandleFunc("DELETE /listings/{id}", handlers.Delete(db, logger, cache, cfg, metrics))
This leads to several maintenance problems:
- Severe Code Duplication: Dependencies must be passed redundantly to every route.
- Fragile Function Signatures: Modifying dependencies across handlers requires updating every factory signature and call site.
- Parameter Ordering Bugs: Handlers sharing identical parameter types (e.g., multiple configurations or loggers) risk ordering mismatches at compile time.
6. Mental Model: Structs and Method Receivers in Go
To eliminate factory argument explosion, idiomatic Go replaces functional closures with Structs and Method Receivers.
Structs as State Containers
A struct binds dependencies together in memory. Rather than passing arguments into isolated functions, dependencies are stored as fields on a dedicated controller or handler struct:
type ListingHandler struct {
db *sql.DB
}
Method Receivers
By binding a function to a struct receiver (lh ListingHandler), that function gains direct access to all fields stored on the struct via lh.FieldName:
func (lh ListingHandler) Delete(w http.ResponseWriter, r *http.Request) {
// Direct access to dependencies without closure scopes
lh.db.Exec(...)
}
Because Delete now matches the exact signature func(http.ResponseWriter, *http.Request), it directly fulfills the http.HandlerFunc contract without needing outer wrapper closures.
7. The Constructor Pattern (NewListingHandler)
Go does not feature built-in class constructors like __construct() in PHP, constructor() in TypeScript, or class-named constructors in Java/C++. Instead, idiomatic Go uses simple constructor functions following the convention New<StructName>.
// NewListingHandler acts as the explicit constructor for ListingHandler.
func NewListingHandler(db *sql.DB) *ListingHandler {
return &ListingHandler{
db: db,
}
}
Value vs. Pointer Returns in Constructors
By returning a pointer reference (*ListingHandler), dependencies are instantiated once in main.go, and handler methods run against the exact same memory instance throughout the application lifecycle.
8. Step-by-Step Refactoring: Handlers to Struct Methods
Let us walk through the evolution of internal/handlers/listings.go from outer closure factories to a clean, struct-backed receiver model.
Before: Closure Factory Pattern
package handlers
import (
"database/sql"
"net/http"
)
// Naive: Requires a closure wrapper to capture db
func DeleteListing(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
_, err := db.Exec("DELETE FROM listings WHERE id = $1", id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
After: Struct Receiver Pattern
package handlers
import (
"database/sql"
"log"
"net/http"
)
// 1. Define the handler struct encapsulating dependencies
type ListingHandler struct {
db *sql.DB
}
// 2. Define the constructor function
func NewListingHandler(db *sql.DB) *ListingHandler {
return &ListingHandler{
db: db,
}
}
// 3. Attach List directly as a method
func (lh ListingHandler) List(w http.ResponseWriter, r *http.Request) {
// Uses lh.db for query operations
}
// 4. Attach Delete directly as a method
func (lh ListingHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Prepared statement execution using the struct dependency
_, err := lh.db.Exec("DELETE FROM listings WHERE id = $1", id)
if err != nil {
log.Printf("delete execution failed: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// Return 204 No Content
w.WriteHeader(http.StatusNoContent)
}
Noticeable Simplifications:
- No nested closures: Code indentation drops by an entire level; cognitive complexity decreases.
- Standard handler signatures: Both
ListandDeleteconform tofunc(http.ResponseWriter, *http.Request). - No runtime allocation of closures: Handlers are referenced directly as method values.
9. Rewiring the Application Entry Point (main.go) and Clean Naming
Lexical Scoping and Clean Method Naming
Under the old functional pattern, functions had to be uniquely named across the package to avoid collisions:
handlers.ListListingshandlers.DeleteListinghandlers.CreateListing
When using a struct receiver (ListingHandler), the struct provides its own contextual namespace. Repeating the word "Listing" in the method name becomes redundant stutter:
// Redundant:
lh.DeleteListing(...)
// Clean, Idiomatic Go:
lh.Delete(...)
lh.List(...)
lh.Create(...)
Updating main.go
With the constructor pattern in place, dependencies are injected once during initialization. In cmd/api/main.go:
package main
import (
"log"
"net/http"
"yourproject/internal/database"
"yourproject/internal/handlers"
)
func main() {
// 1. Initialize core dependencies
db, err := database.Connect()
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
defer db.Close()
// 2. Instantiate Handlers via Constructors (Dependency Injection)
lh := handlers.NewListingHandler(db)
// 3. Register routes using method references
mux := http.NewServeMux()
mux.HandleFunc("GET /listings", lh.List)
mux.HandleFunc("DELETE /listings/{id}", lh.Delete)
// 4. Start HTTP Server
log.Println("Server running on port :8088")
if err := http.ListenAndServe(":8088", mux); err != nil {
log.Fatal(err)
}
}
Notice that lh.List and lh.Delete are passed directly without parentheses. In Go, passing a method without invoking it evaluates to a Method Value—a first-class function value that retains its receiver instance and satisfies http.HandlerFunc.
10. Execution Flow Walkthrough
The following diagram tracks the complete runtime flow of a DELETE request through the refactored architecture:
11. Architectural Comparison: Closure Pattern vs. Constructor Pattern
12. Looking Ahead: Exec vs. ExecContext in Production
While db.Exec() functions correctly in basic development, it lacks an awareness of request lifecycles.
In high-concurrency production deployments, every database interaction should use context-aware variants:
db.QueryContext(ctx, ...)db.ExecContext(ctx, ...)
// Production-grade context propagation:
ctx := r.Context()
result, err := lh.db.ExecContext(ctx, "DELETE FROM listings WHERE id = $1", id)
Why ExecContext is Essential:
- Client Cancellation Handling: If a user cancels an HTTP request or closes their browser tab mid-flight,
r.Context()is canceled.ExecContextpropagates that cancellation signal to PostgreSQL, terminating the query immediately instead of letting orphaned queries waste database CPU and I/O. - Timeout Enforcements: When configuring global API timeouts (e.g., using
context.WithTimeout),ExecContextensures that slow database queries or connection pool deadlocks fail fast rather than hanging indefinitely.
Summary and Key Takeaways
- Standard Library Route Wildcards: Starting with Go 1.22,
http.ServeMuxnatively supports path parameters using{param}and retrieves them viar.PathValue("param"). - Safe SQL Deletion with
db.Exec: Non-reading SQL operations (INSERT,UPDATE,DELETE) should be executed usingdb.Exec(), utilizing positional placeholders ($1) to prevent SQL injection. - REST Deletion Status Codes: A successful synchronous deletion with no body content should return
204 No Contentviaw.WriteHeader(http.StatusNoContent). - Security via Idempotence: Concealing whether a record was present upon deletion prevents resource enumeration and attacker probing attacks.
- Flaws of Closure Factories: Passing dependencies directly to functional handler wrappers causes signature bloat, code duplication, and tight coupling across
main.go. - The Go Constructor Pattern: Grouping dependencies inside a struct (e.g.,
ListingHandler) and instantiating it with aNewListingHandler(db)constructor simplifies dependency management. - Method Receivers: Attaching handlers to struct receivers (
func (lh ListingHandler) Delete(...)) allows handlers to access shared dependencies like databases, loggers, and caches throughlh.FieldName. - Zero Third-Party DI Overhead: Idiomatic Go implements robust, type-safe dependency injection without requiring heavyweight reflection-based third-party dependency injection frameworks.