HTTP Services: net/http vs ASP.NET Core

ASP.NET Core is a sophisticated web framework. Dependency injection, middleware pipelines, model binding, routing with attributes, OpenAPI generation… it does a lot for you.

Go’s net/http is a standard library package. It does HTTP. That’s about it.

This sounds like a step down. It is, in some ways. But there’s power in simplicity, and Go’s HTTP story is more capable than it first appears.

The Simplest Server

Go:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    
    http.ListenAndServe(":8080", nil)
}

C# minimal API:

var app = WebApplication.Create();
app.MapGet("/", () => "Hello, World!");
app.Run();

C#’s is shorter. But Go’s has zero dependencies, zero configuration, and zero framework magic.

Handlers: The Building Block

In ASP.NET Core, you have controllers, minimal API delegates, or Razor Pages. In Go, you have handlers.

A handler is anything that implements http.Handler:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

One method. That’s it.

type HelloHandler struct {
    greeting string
}

func (h HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "%s, visitor!", h.greeting)
}

func main() {
    http.Handle("/hello", HelloHandler{greeting: "Welcome"})
    http.ListenAndServe(":8080", nil)
}

Or use http.HandlerFunc for simpler cases:

http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello!")
})

Request and Response

The *http.Request contains everything about the incoming request:

func handler(w http.ResponseWriter, r *http.Request) {
    // Method
    method := r.Method  // "GET", "POST", etc.
    
    // URL and path
    path := r.URL.Path
    query := r.URL.Query().Get("id")
    
    // Headers
    contentType := r.Header.Get("Content-Type")
    
    // Body
    body, _ := io.ReadAll(r.Body)
    defer r.Body.Close()
    
    // Form data
    r.ParseForm()
    name := r.FormValue("name")
    
    // Context (for cancellation, deadlines, values)
    ctx := r.Context()
}

The http.ResponseWriter is how you respond:

func handler(w http.ResponseWriter, r *http.Request) {
    // Set headers (before writing body)
    w.Header().Set("Content-Type", "application/json")
    
    // Set status code (before writing body)
    w.WriteHeader(http.StatusCreated)
    
    // Write body
    w.Write([]byte(`{"status": "ok"}`))
    
    // Or use fmt
    fmt.Fprintf(w, `{"id": %d}`, 123)
}

Order matters: headers and status must be set before writing the body.

Routing: The Weak Spot

Go 1.22 improved the standard library router significantly:

mux := http.NewServeMux()

mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)      // path parameters!
mux.HandleFunc("DELETE /users/{id}", deleteUser)

http.ListenAndServe(":8080", mux)

Access path parameters:

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")  // Go 1.22+
    // ...
}

Before 1.22, you needed a third-party router. Many projects still use them:

chi — lightweight, idiomatic:

r := chi.NewRouter()
r.Get("/users/{id}", getUser)

gorilla/mux — feature-rich (now maintained by community):

r := mux.NewRouter()
r.HandleFunc("/users/{id}", getUser).Methods("GET")

gin — fast, popular, framework-like:

r := gin.Default()
r.GET("/users/:id", getUser)

If you’re starting fresh with Go 1.22+, try the standard library first.

Middleware

Middleware in Go is just a function that wraps a handler:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !isValidToken(token) {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Chain them:

handler := loggingMiddleware(authMiddleware(myHandler))
http.Handle("/api/", handler)

Compare to ASP.NET Core:

app.UseLogging();
app.UseAuthentication();
app.MapControllers();

Go’s is more manual but the pattern is explicit.

Comparing to ASP.NET Core

FeatureASP.NET CoreGo net/http
DIBuilt-inManual or wire
RoutingAttribute or minimalMethod + path (1.22+)
Model bindingAutomaticManual (JSON decode)
ValidationDataAnnotationsManual or validator lib
MiddlewarePipelineFunction wrapping
OpenAPISwashbuckleThird-party (swag, etc.)
AuthIdentity, policiesManual or third-party
ConfigIConfigurationEnvironment or libs

ASP.NET Core does more out of the box. Go makes you build it yourself or choose libraries.

A Realistic Handler

Here’s what a real handler looks like:

type UserHandler struct {
    repo UserRepository
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    
    user, err := h.repo.FindByID(r.Context(), id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "User not found", http.StatusNotFound)
            return
        }
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var input CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Validation
    if input.Email == "" {
        http.Error(w, "Email required", http.StatusBadRequest)
        return
    }
    
    user, err := h.repo.Create(r.Context(), input)
    if err != nil {
        http.Error(w, "Failed to create user", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

More code than an ASP.NET Core controller. But you can see exactly what’s happening.

Graceful Shutdown

Something ASP.NET Core handles automatically that Go requires explicitly:

func main() {
    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }
    
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()
    
    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    
    // Graceful shutdown with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("shutdown: %v", err)
    }
    
    log.Println("Server stopped")
}

The Honest Take

ASP.NET Core is a full-featured web framework. Go’s net/http is a building block.

What Go does better:

  • Simple mental model
  • No framework lock-in
  • Explicit control over everything
  • Very fast startup
  • Small binaries

What ASP.NET Core does better:

  • Batteries included
  • Model binding and validation
  • Authentication/authorization
  • OpenAPI generation
  • Richer middleware ecosystem

The verdict: For simple services, net/http is delightfully straightforward. For complex APIs with lots of endpoints, validation, and documentation needs, you’ll either write a lot of code or reach for a framework like Gin or Echo.

Neither is wrong. Go gives you the choice that ASP.NET Core makes for you.


Next up: JSON handling—struct tags, marshalling, and why it’s more manual than Newtonsoft.