Go Programming

Generic HTTP Client and Repository Patterns in Go: Practical Implementation Guide

Since the introduction of generics in Go 1.18, the language has gained unprecedented flexibility without sacrificing its core philosophy of simplicity and performance. For intermediate and advanced developers, leveraging generics to abstract common boilerplate code—such as making HTTP requests or implementing the Repository pattern—is no longer just a neat trick, but a best practice for building scalable, maintainable microservices.

In this guide, we will explore how to create a generic HTTP client utility and apply the Repository pattern using Go's type parameters. This approach significantly reduces code duplication across your project while maintaining strict type safety.

Building a Generic HTTP Client

Traditionally, making an HTTP request in Go involves setting up the client, creating the request, handling the context, and manually marshaling the JSON response into a struct. If you have multiple endpoints, this logic often gets repeated. By introducing a generic function, we can abstract the decoding step.

Consider the following implementation. We define a function DoRequest that accepts a generic type T. This allows the compiler to ensure that the response body is successfully decoded into the specific struct you expect.

package client

import (
	"encoding/json"
	"io"
	"net/http"
)

// DoRequest performs an HTTP GET request and decodes the JSON response into the provided generic type T.
func DoRequest[T any](ctx context.Context, client *http.Client, url string) (*T, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var result T
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, fmt.Errorf("failed to decode response: %w", err)
	}

	return &result, nil
}

This single function can now be used to fetch User structs, Order structs, or any other JSON-serializable type, provided the URL is valid and the HTTP method is GET. While a full production-ready client would handle retries and timeouts, this demonstrates the power of generic type constraints.

The Generic Repository Pattern

The Repository pattern acts as a mediator between the domain and data mapping layers, providing a collection-like interface for accessing domain objects. In Go, while we don't have classes, we can use interfaces and generics to create a highly reusable repository abstraction.

By defining a generic interface, we can enforce that any struct implementing our business logic adheres to specific CRUD (Create, Read, Update, Delete) operations. This is particularly useful when working with different databases (e.g., SQL vs. NoSQL) or mocking services in tests.

package repository

import "context"

// Repository defines a generic interface for CRUD operations.
type Repository[T any, ID comparable] interface {
	Get(ctx context.Context, id ID) (*T, error)
	Create(ctx context.Context, item *T) error
	Delete(ctx context.Context, id ID) error
}

// UserService handles business logic for users.
type UserService struct {
	repo Repository[User, int]
}

func NewUserService(repo Repository[User, int]) *UserService {
	return &UserService{repo: repo}
}

func (s *UserService) GetUserProfile(ctx context.Context, id int) (*User, error) {
	// Business logic can be applied here before fetching from DB
	user, err := s.repo.Get(ctx, id)
	if err != nil {
		return nil, err
	}
	
	// Example: Enrich user data
	user.Status = "active"
	return user, nil
}

Practical Benefits and Considerations

Using generics for the HTTP client and Repository patterns offers three main advantages:

  1. Type Safety: The Go compiler catches mismatched types at build time rather than runtime, preventing subtle bugs where a JSON response is decoded into the wrong struct.
  2. Dry Code: Eliminating repetitive serialization and error-handling code makes your codebase easier to read and maintain.
  3. Testability: Generic interfaces allow you to easily mock dependencies. For instance, you can create a MockRepository[T] that returns predefined data for testing the UserService without hitting a real database or API.

However, developers should be cautious not to over-engineer. Not every function needs to be generic. Use generics where you see a clear pattern of type parameterization. For simple scripts or one-off utility functions, standard concrete types are often more readable and performant.

Conclusion

Generics in Go have opened the door to more sophisticated design patterns that were previously awkward or verbose to implement. By combining a generic HTTP client with the Repository pattern, you can build backend systems that are both flexible and robust. As you refactor your existing Go projects, look for opportunities to extract repetitive type-specific logic into generic abstractions. Your future self—and your team—will thank you for the cleaner, more maintainable codebase.

Share: