Building Modular and Testable Web Applications with Go's net/http
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
In the rapidly evolving landscape of software development, building scalable and maintainable web applications is paramount. Go, with its strong concurrency primitives and simple yet powerful standard library, offers an excellent foundation for this. Oftentimes, developers jump directly to complex frameworks, overlooking the inherent capabilities of Go's net/http
package. While frameworks offer convenience, true proficiency often comes from understanding the underlying mechanisms. This article will demonstrate how to leverage net/http
to construct web applications that are not only efficient and performant but also modular in their design and readily testable, paving the way for long-term project success and easier collaboration.
Core Concepts for Robust HTTP Applications
Before diving into implementation, let's establish a common understanding of key terms fundamental to building excellent HTTP applications in Go.
- Handler: In
net/http
, aHandler
is an interface with a single method:ServeHTTP(w http.ResponseWriter, r *http.Request)
. This method is responsible for processing an incoming HTTP request (r
) and sending an HTTP response (w
). Functions that match the signaturefunc(w http.ResponseWriter, r *http.Request)
can be easily converted intohttp.Handler
instances usinghttp.HandlerFunc
. - Middleware: Middleware functions are functions that wrap around other handlers. They can intercept requests and responses, performing actions like logging, authentication, error handling, or modifying request/response headers before or after the main handler executes. This promotes code reuse and separation of concerns.
- Routing: Routing is the process of mapping incoming HTTP requests (based on their URL path, HTTP method, etc.) to specific handlers. While
net/http
provides basic routing (http.HandleFunc
andhttp.ServeMux
), for more complex applications, custom or third-party routers are often employed to manage routes efficiently. - Dependency Injection (DI): DI is a design pattern where dependencies (objects or functions that a component needs to operate) are provided to the component rather than the component creating them itself. This significantly improves testability and flexibility, as different "mock" dependencies can be injected during testing.
- Testability: The ease with which software can be tested. A highly testable application often benefits from loose coupling, clear interfaces, and small, focused units of code, which is precisely what modular design aims for.
Building a Modular and Testable Web Application
Our goal is to create a web application that handles user-related actions (e.g., getting a user by ID) in a structured, maintainable, and testable manner. We'll achieve this by organizing our code into distinct modules and leveraging Go's interface system.
1. Defining Application Structure
A common and effective structure for Go web applications involves separating concerns into packages like handlers
, services
(or usecases
), and repositories
(or stores
).
.
├── cmd/app/main.go # Application entry point
├── internal/
│ ├── handlers/ # HTTP request handlers
│ │ └── user_handler.go
│ ├── models/ # Data structures
│ │ └── user.go
│ ├── services/ # Business logic
│ │ └── user_service.go
│ └── repositories/ # Data access layer
│ └── user_repo.go
└── go.mod
└── go.sum
2. Models: Defining Data Structures
First, let's define our User
model in internal/models/user.go
.
package models import "fmt" type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } func (u User) String() string { return fmt.Sprintf("ID: %s, Name: %s, Email: %s", u.ID, u.Name, u.Email) }
3. Repository: Data Access Layer (with Interface)
The repository pattern abstracts the data storage mechanism. By defining an interface, we can easily swap out implementations (e.g., in-memory for testing, PostgreSQL for production).
internal/repositories/user_repo.go
:
package repositories import ( "context" "errors" "fmt" "yourproject/internal/models" ) // ErrUserNotFound is returned when a user cannot be found. var ErrUserNotFound = errors.New("user not found") // UserRepository defines the interface for user data operations. type UserRepository interface { GetUserByID(ctx context.Context, id string) (*models.User, error) // Add other methods like CreateUser, UpdateUser, DeleteUser } // InMemoryUserRepository is a simple in-memory implementation of UserRepository. type InMemoryUserRepository struct { users map[string]*models.User } // NewInMemoryUserRepository creates a new InMemoryUserRepository. func NewInMemoryUserRepository() *InMemoryUserRepository { return &InMemoryUserRepository{ users: map[string]*models.User{ "1": {ID: "1", Name: "Alice", Email: "alice@example.com"}, "2": {ID: "2", Name: "Bob", Email: "bob@example.com"}, }, } } // GetUserByID retrieves a user by their ID from memory. func (r *InMemoryUserRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) { // Simulate a database call delay // time.Sleep(10 * time.Millisecond) if user, ok := r.users[id]; ok { return user, nil } return nil, fmt.Errorf("%w: %s", ErrUserNotFound, id) }
4. Service: Business Logic Layer (with Interface)
The service layer contains the application's core business logic. It orchestrates interactions between repositories and handles specific use cases. Again, an interface boosts testability.
internal/services/user_service.go
:
package services import ( "context" "yourproject/internal/models" "yourproject/internal/repositories" ) // UserService defines the interface for user-related business operations. type UserService interface { GetUserByID(ctx context.Context, id string) (*models.User, error) } // UserServiceImpl is an implementation of UserService. type UserServiceImpl struct { userRepo repositories.UserRepository } // NewUserService creates a new UserService. func NewUserService(repo repositories.UserRepository) *UserServiceImpl { return &UserServiceImpl{userRepo: repo} } // GetUserByID fetches a user by ID, performing any necessary business logic. func (s *UserServiceImpl) GetUserByID(ctx context.Context, id string) (*models.User, error) { // Here you could add validation, authorization checks, etc. user, err := s.userRepo.GetUserByID(ctx, id) if err != nil { // Log the error for debugging // log.Printf("Error getting user by ID %s: %v", id, err) return nil, err // Pass the error up } return user, nil }
5. Handlers: HTTP Request Handling
Handlers are the entry points for HTTP requests. They delegate to the service layer for business logic. Notice the use of json.Marshal
and json.NewDecoder
.
internal/handlers/user_handler.go
:
package handlers import ( "encoding/json" "log" "net/http" "yourproject/internal/services" ) // UserHandler handles HTTP requests related to users. type UserHandler struct { userService services.UserService } // NewUserHandler creates a new UserHandler. func NewUserHandler(svc services.UserService) *UserHandler { return &UserHandler{userService: svc} } // GetUserByID handles fetching a user by ID. // It expects a URL path like /users/{id}. func (h *UserHandler) GetUserByID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Basic parsing: in a real app, use a router that extracts path parameters. // For simplicity, we'll manually parse the last segment. id := r.URL.Path[len("/users/"):] if id == "" { http.Error(w, "User ID is required", http.StatusBadRequest) return } user, err := h.userService.GetUserByID(r.Context(), id) if err != nil { if err == services.ErrUserNotFound { // Check against the specific error http.Error(w, err.Error(), http.StatusNotFound) return } log.Printf("Error getting user: %v", err) // Log internal errors http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(user); err != nil { log.Printf("Error encoding response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } }
6. Application Entry Point and Wiring
The main
function in cmd/app/main.go
will be responsible for wiring up our components.
package main import ( "log" "net/http" "time" "yourproject/internal/handlers" "yourproject/internal/repositories" "yourproject/internal/services" ) func main() { // Initialize Repository userRepo := repositories.NewInMemoryUserRepository() // Initialize Service with Repository userService := services.NewUserService(userRepo) // Initialize Handler with Service userHandler := handlers.NewUserHandler(userService) // Create a new ServeMux for routing mux := http.NewServeMux() // Register routes // Note: For more advanced routing, consider a third-party router like Chi or Gorilla Mux. // We're using a simple prefix match for demonstration. mux.Handle("/users/", http.HandlerFunc(userHandler.GetUserByID)) // Apply Middlewares (Optional but recommended for cross-cutting concerns) wrappedMux := loggingMiddleware(mux) // Add a simple logging middleware // Configure the HTTP server server := &http.Server{ Addr: ":8080", Handler: wrappedMux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } log.Printf("Server starting on %s", server.Addr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed to start: %v", err) } } // loggingMiddleware logs incoming requests. 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 %s %s", r.Method, r.RequestURI, time.Since(start), r.RemoteAddr) }) }
Now, run this with go run cmd/app/main.go
and send a GET
request to http://localhost:8080/users/1
or http://localhost:8080/users/3
.
7. Testability
The beauty of this modular design shines in testing. Thanks to interfaces and dependency injection, we can easily mock dependencies.
internal/services/user_service_test.go
:
package services_test import ( "context" "errors" "testing" "yourproject/internal/models" "yourproject/internal/repositories" "yourproject/internal/services" // Import the package under test ) // MockUserRepository is a mock implementation of repositories.UserRepository. type MockUserRepository struct { GetUserByIDFunc func(ctx context.Context, id string) (*models.User, error) } // GetUserByID calls the mock function. func (m *MockUserRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) { if m.GetUserByIDFunc != nil { return m.GetUserByIDFunc(ctx, id) } return nil, errors.New("GetUserByID not implemented") // Default panic for missing mock } func TestUserService_GetUserByID(t *testing.T) { // Test Case 1: User found t.Run("user found", func(t *testing.T) { expectedUser := &models.User{ID: "123", Name: "Test User", Email: "test@example.com"} mockRepo := &MockUserRepository{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { if id == "123" { return expectedUser, nil } return nil, repositories.ErrUserNotFound }, } svc := services.NewUserService(mockRepo) user, err := svc.GetUserByID(context.Background(), "123") if err != nil { t.Errorf("Expected no error, got %v", err) } if user == nil || user.ID != "123" { t.Errorf("Expected user ID 123, got %v", user) } }) // Test Case 2: User not found t.Run("user not found", func(t *testing.T) { mockRepo := &MockUserRepository{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { return nil, repositories.ErrUserNotFound }, } svc := services.NewUserService(mockRepo) _, err := svc.GetUserByID(context.Background(), "unknown") if err == nil { t.Error("Expected an error, got nil") } if !errors.Is(err, repositories.ErrUserNotFound) { t.Errorf("Expected ErrUserNotFound, got %v", err) } }) // Test Case 3: Repository error t.Run("repository error", func(t *testing.T) { internalErr := errors.New("database connection failed") mockRepo := &MockUserRepository{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { return nil, internalErr }, } svc := services.NewUserService(mockRepo) _, err := svc.GetUserByID(context.Background(), "123") if err == nil { t.Error("Expected an error, got nil") } if !errors.Is(err, internalErr) { t.Errorf("Expected internal error, got %v", err) } }) }
Testing handlers directly can be done using net/http/httptest
.
internal/handlers/user_handler_test.go
:
package handlers_test import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "yourproject/internal/handlers" "yourproject/internal/models" "yourproject/internal/repositories" // Import repository errors for comparison "yourproject/internal/services" // Import service errors for comparison ) // MockUserService is a mock implementation of services.UserService. type MockUserService struct { GetUserByIDFunc func(ctx context.Context, id string) (*models.User, error) } // GetUserByID calls the mock function. func (m *MockUserService) GetUserByID(ctx context.Context, id string) (*models.User, error) { if m.GetUserByIDFunc != nil { return m.GetUserByIDFunc(ctx, id) } return nil, errors.New("GetUserByID not implemented") // Default } func TestUserHandler_GetUserByID(t *testing.T) { // Test Case 1: User found t.Run("user found", func(t *testing.T) { expectedUser := &models.User{ID: "1", Name: "Alice", Email: "alice@example.com"} mockService := &MockUserService{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { if id == "1" { return expectedUser, nil } return nil, services.ErrUserNotFound // Use exported service error via repository }, } handler := handlers.NewUserHandler(mockService) req := httptest.NewRequest(http.MethodGet, "/users/1", nil) rec := httptest.NewRecorder() handler.GetUserByID(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) } var actualUser models.User if err := json.NewDecoder(rec.Body).Decode(&actualUser); err != nil { t.Fatalf("Failed to decode response: %v", err) } if actualUser.ID != expectedUser.ID || actualUser.Name != expectedUser.Name { t.Errorf("Expected user %+v, got %+v", expectedUser, actualUser) } }) // Test Case 2: User not found t.Run("user not found", func(t *testing.T) { mockService := &MockUserService{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { return nil, repositories.ErrUserNotFound // Use the base error directly for handler }, } handler := handlers.NewUserHandler(mockService) req := httptest.NewRequest(http.MethodGet, "/users/99", nil) rec := httptest.NewRecorder() handler.GetUserByID(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("Expected status %d, got %d", http.StatusNotFound, rec.Code) } if rec.Body.String() != "user not found: 99\n" && rec.Body.String() != "user not found\n" { // Depending on how the error is propagated t.Errorf("Expected 'user not found', got '%s'", rec.Body.String()) } }) // Test Case 3: Invalid Method t.Run("invalid method", func(t *testing.T) { mockService := &MockUserService{} // No need for actual service logic handler := handlers.NewUserHandler(mockService) req := httptest.NewRequest(http.MethodPost, "/users/1", nil) rec := httptest.NewRecorder() handler.GetUserByID(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("Expected status %d, got %d for POST request", http.StatusMethodNotAllowed, rec.Code) } }) }
These tests demonstrate how easily we can isolate components for testing by creating mock objects that implement the same interfaces as our actual dependencies. This allows us to test business logic and HTTP handling independently without needing a live database or external services.
Application Scenarios
This modular approach is ideal for:
- RESTful APIs: Clearly structured endpoints for resource management.
- Microservices: Each service can be a standalone, modular application.
- Growing Codebases: New features can be added in new packages without disrupting existing code, enhancing maintainability.
- Collaborative Development: Different teams can work on different services or layers with minimal merge conflicts due to clear boundaries.
Conclusion
Building modular and testable web applications in Go using net/http
is not just achievable; it's a powerful approach that yields robust, maintainable, and verifiable systems. By embracing interfaces, dependency injection, and a layered architecture, developers can craft applications that are easy to understand, extend, and, critically, test. This inherent simplicity and clarity, derived directly from Go's design philosophy, positions your applications for enduring success.