Introduction
If you’ve been writing Go HTTP clients for a while, you’ve probably noticed a pattern: you’re constantly writing the same boilerplate code to marshal requests, handle responses, and unmarshal JSON. Each API integration becomes a repetitive dance of error handling and type assertions.
Since Go 1.18 introduced generics, we finally have the tools to eliminate this repetition while maintaining type safety. Let me show you how.
The Traditional Approach (And Why It’s Painful)
Let’s start with a typical HTTP client implementation you’ve probably written dozens of times:
type Header struct {
Key string
Value string
}
func doJSONRequest(method string, url string, requestData interface{}, headers ...Header) (*http.Response, error) {
// Marshal request body
json...
Introduction
If you’ve been writing Go HTTP clients for a while, you’ve probably noticed a pattern: you’re constantly writing the same boilerplate code to marshal requests, handle responses, and unmarshal JSON. Each API integration becomes a repetitive dance of error handling and type assertions.
Since Go 1.18 introduced generics, we finally have the tools to eliminate this repetition while maintaining type safety. Let me show you how.
The Traditional Approach (And Why It’s Painful)
Let’s start with a typical HTTP client implementation you’ve probably written dozens of times:
type Header struct {
Key string
Value string
}
func doJSONRequest(method string, url string, requestData interface{}, headers ...Header) (*http.Response, error) {
// Marshal request body
jsonData, err := json.Marshal(requestData)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create request
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for _, header := range headers {
req.Header.Set(header.Key, header.Value)
}
// Execute request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
An attempt to create a generic HTTP client function
And here’s how you’d use it:
func main() {
type RequestData struct {
Key string `json:"key"`
Value string `json:"value"`
}
type ResponseData struct {
Result string `json:"result"`
Status string `json:"status"`
}
resp, err := doJSONRequest(
http.MethodPost,
"https://api.example.com/data",
RequestData{
Key: "foo",
Value: "bar",
},
Header{"User-Agent", "xxx-service-api-client"},
Header{"Authorization", "Bearer xxxx"},
)
if err != nil {
log.Fatal("Error:", err)
}
defer resp.Body.Close()
// Here's where the pain begins...
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal("Failed to read response:", err)
}
var data ResponseData
if err := json.Unmarshal(responseBody, &data); err != nil {
log.Fatal("Failed to unmarshal response:", err)
}
fmt.Println("Response:", data)
}
But the complexity of handling this function did not walked away
The problems are clear:
- Boilerplate everywhere: Every call requires manual response body handling
- No type safety: The function returns
*http.Response, forcing you to unmarshal manually - Repetitive error handling: The same unmarshal logic is duplicated across your codebase
- Poor reusability: Wrapping this in a type-specific function couples it to one response type
The Generic Solution
Go generics give us the power to abstract this pattern while preserving type safety. Here’s the improved version:
func doJSONRequest[R any](method string, url string, requestData interface{}, headers ...Header) (R, error) {
var resp R
// Serialize the request data to JSON
jsonData, err := json.Marshal(requestData)
if err != nil {
return resp, err
}
// Create an HTTP request
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
if err != nil {
return resp, err
}
req.Header.Set("Content-Type", "application/json")
for header, headerValue := range
// Send the request using the default HTTP client
client := &http.Client{}
resp, err := client.Do(requestData)
if err != nil {
return resp, err
}
defer resp.Body.Close()
_, err = rs.Body.Read(body)
if err != nil {
return resp, err
}
err = json.Unmarshal(body, &resp)
return resp, err
}
Now look how clean the calling code becomes:
func main() {
resp, err := doJSONRequest[ResponseData](
http.MethodPost,
"https://api.example.com/data",
RequestData{
Key: "foo",
Value: "bar",
},
Header{"User-Agent", "xxx-service-api-client"},
Header{"Authorization", "Bearer xxxx"},
)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Response: ", resp)
}
Beautiful! The response is already typed, deserialized, and ready to use. But we can do even better.
Making It Production-Ready
The generic version is great, but it’s still rigid. What if you need:
- Custom serialization (like Protocol Buffers or MessagePack)?
- Configurable timeouts?
- Request/response middleware?
- Different HTTP clients for different services?
Let’s build a flexible, production-ready solution:
package fetcher
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
)
var (
ErrMissingURL = errors.New("no URL specified")
ErrMissingHttpMethod = errors.New("no HTTP verb/method specified")
)
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type SerializationFunc func(any) ([]byte, error)
type DeserializationFunc func(data []byte, v any) error
type RequestOption func(*Request)
type statusHandlerFunc func(*http.Response) (any, error)
type Request struct {
method string
url string
contentType string
headers map[string]string
client HTTPClient
serialize SerializationFunc
deserialize DeserializationFunc
statusHandlers map[int]statusHandlerFunc
defaultStatusHandler statusHandlerFunc
body any
}
// Fetch sends requests, serializes/deserializes bodies, and sets http clients
func Fetch[RT any](options ...RequestOption) (RT, error) {
var resp RT
req := &Request{
contentType: "application/json",
client: http.DefaultClient,
method: http.MethodGet,
serialize: func(body any) ([]byte, error) { return json.Marshal(body) },
deserialize: func(data []byte, v any) error { return json.Unmarshal(data, v) },
defaultStatusHandler: func(response *http.Response) (any, error) {
var reqBody []byte
if response.Request != nil && response.Request.Body != nil {
reqBody, _ = io.ReadAll(response.Request.Body)
}
respBody, _ := io.ReadAll(response.Body)
log.Printf("[WARN] HANDLING UNKNOWN STATUS: %q, URL: %q,\n\tREQ_BODY: %q\n\tRESP_BODY: %q", response.Status, response.Request.URL, string(reqBody), string(respBody))
return resp, nil
},
}
for _, setter := range options {
setter(req)
}
if req.url == "" {
return resp, ErrMissingURL
}
if req.method == "" {
return resp, ErrMissingHttpMethod
}
var reqBody []byte
var err error
if req.body != nil {
reqBody, err = req.serialize(req.body)
if err != nil {
return resp, err
}
}
var reqReader io.Reader
if reqBody != nil {
reqReader = bytes.NewBuffer(reqBody)
}
httpReq, err := http.NewRequest(req.method, req.url, reqReader)
if err != nil {
return resp, err
}
httpReq.Header.Set("Content-Type", "application/json")
for header, headerVal := range req.headers {
httpReq.Header.Set(header, headerVal)
}
response, err := req.client.Do(httpReq)
if err != nil {
return resp, err
}
defer response.Body.Close()
if !(response.StatusCode == 200 || response.StatusCode == 201) {
handle, found := req.statusHandlers[response.StatusCode]
if !found {
handle = req.defaultStatusHandler
}
b, err := handle(response)
if err != nil {
return resp, err
}
resp, sameType := b.(RT)
if !sameType && found {
return resp, fmt.Errorf("%s HTTP Status handler does not return %T type value", response.Status, resp)
} else if !sameType && !found {
return resp, fmt.Errorf("default HTTP Status handler does not return %T type value", resp)
} else {
return resp, nil
}
}
respBody, err := io.ReadAll(response.Body)
if err != nil {
return resp, err
}
err = req.deserialize(respBody, &resp)
return resp, err
}
// RequestOptions functions for all Request struct fields
// WithMethod sets the HTTP method for the request
func WithMethod(method string) RequestOption {
return func(r *Request) {
r.method = method
}
}
// WithURL sets the URL for the request
func WithURL(url string) RequestOption {
return func(r *Request) {
r.url = url
}
}
// WithContentType sets the content type for the request
func WithContentType(contentType string) RequestOption {
return func(r *Request) {
r.contentType = contentType
}
}
// WithHeaders sets the headers for the request
func WithHeaders(headers map[string]string) RequestOption {
return func(r *Request) {
r.headers = headers
}
}
// WithHeader adds a single header to the request
func WithHeader(key, value string) RequestOption {
return func(r *Request) {
if r.headers == nil {
r.headers = make(map[string]string)
}
r.headers[key] = value
}
}
// WithClient sets a custom HTTP client for the request
func WithClient(client HTTPClient) RequestOption {
return func(r *Request) {
r.client = client
}
}
// WithSerialize sets a custom serialization function for the request body
func WithSerialize(serialize SerializationFunc) RequestOption {
return func(r *Request) {
r.serialize = serialize
}
}
// WithDeserialize sets a custom deserialization function for the response body
func WithDeserialize(deserialize DeserializationFunc) RequestOption {
return func(r *Request) {
r.deserialize = deserialize
}
}
// WithStatusHandlers sets the status handlers map for the request
func WithStatusHandlers(handlers map[int]statusHandlerFunc) RequestOption {
return func(r *Request) {
r.statusHandlers = handlers
}
}
// WithStatusHandler adds a single status handler for a specific status code
func WithStatusHandler(statusCode int, handler statusHandlerFunc) RequestOption {
return func(r *Request) {
if r.statusHandlers == nil {
r.statusHandlers = make(map[int]statusHandlerFunc)
}
r.statusHandlers[statusCode] = handler
}
}
// WithDefaultStatusHandler sets the default status handler for unhandled status codes
func WithDefaultStatusHandler(handler statusHandlerFunc) RequestOption {
return func(r *Request) {
r.defaultStatusHandler = handler
}
}
// WithBody sets the request body
func WithBody(body any) RequestOption {
return func(r *Request) {
r.body = body
}
}
Now you have complete flexibility (let’s consider the HTTP client for GitHub API):
import (
"fmt"
"log"
"net/http"
"time"
"github.com/CristiCurteanu/abaxus-api/internal/fetcher"
)
const (
githubAPIUrl = "https://api.github.com"
githubClientId = "<your_github_client_id>"
githubClientSecret = "<your_github_client_secret>"
githubOauthRedirectCode = "<gh_oauth_redirect_code>"
)
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
type ProfileData struct {
Id int `json:"id"`
Username string `json:"login"`
AvatarURL string `json:"avatar_url"`
Company string `json:"company"`
Repos int `json:"public_repos"`
Gists int `json:"public_gists"`
Followers int `json:"followers"`
Following int `json:"following"`
}
func main() {
httpClient := &http.Client{Timeout: 10 * time.Second}
tokenResponse, err := fetcher.Fetch[AccessTokenResponse](
fetcher.WithURL("https://github.com/login/oauth/access_token"),
fetcher.WithMethod(http.MethodPost),
fetcher.WithHeader("Accept", "application/json"),
fetcher.WithBody(
map[string]any{
"client_id": githubClientId,
"client_secret": githubClientSecret,
"code": githubOauthRedirectCode,
},
),
fetcher.WithClient(httpClient),
)
if err != nil {
log.Fatal(err.Error())
}
profile, err := fetcher.Fetch[ProfileData](
fetcher.WithURL(githubAPIUrl+"/user"),
fetcher.WithHeader("Authorization", fmt.Sprintf("token %s", tokenResponse.AccessToken)),
)
if err != nil {
log.Fatal(err.Error())
}
log.Printf("profile: %+v\n", profile)
}
With request options variadic functions parameter, it is possible to (re-)configure the `Request` itself, which provides a much cleaner API for the `Fetch` function, and we just specify the type of response object that should be deserialized as generic type.
There is also possibility to handle different status codes, by registering status handlers functions with `fetcher.WithStatusHandler` option function.
When to Use This Pattern
This approach shines when you:
- Have multiple API integrations with similar patterns
- Want type safety without code generation
- Need flexibility in serialization/deserialization
- Want to centralize HTTP client configuration
However, for very simple one-off requests, the standard library might be sufficient. Choose the right tool for the job.
What’s Next?
This foundation can be extended further:
- Add retry logic with exponential backoff
- Add structured logging
- Support streaming responses
- Add metrics and tracing
Wrapping Up
Go generics aren’t just syntactic sugar—they’re a powerful tool for eliminating boilerplate while maintaining type safety. By building reusable, flexible abstractions like this HTTP client, you can write cleaner code and ship features faster.
The key is following the Open/Closed Principle: our function is open for extension (through custom serializers, clients, and headers) but closed for modification (the core logic remains stable).
Give it a try in your next project. Your future self will thank you when you’re not copy-pasting HTTP client code for the hundredth time.