Build an HTTP service
In this guide, you'll build a complete HTTP service from scratch. You'll start with the basics of defining routes, then level up to type-safe handlers with dependency injection, request binding, and structured responses.
What is httpserver?
The httpserver package is built on top of Gin, one of the most popular HTTP frameworks for Go. It inherits all of Gin's routing, middleware, and parameter handling — so you can always fall back to raw *gin.Context handlers when you need to.
On top of that foundation, httpserver adds a structured, type-safe way to build HTTP services. The With pattern gives you clean dependency injection: each handler group gets its own constructor that receives ctx, config, and logger, so your dependencies are initialized in one place and shared across related routes. The Bind function replaces manual request parsing with struct-tag-driven binding and validation — declare an input struct with json, uri, or form tags, and the request data is populated and validated before your handler even runs. Your handlers return a typed Response instead of writing directly to the response writer, making them easy to test and compose.
Together with built-in middleware for logging, metrics, compression, and graceful shutdown — all configured through YAML — httpserver lets you focus on your application logic rather than HTTP plumbing.
Getting started
Install the package:
go get github.com/gosoline-project/httpserver@v0.4.1
The entry point for any HTTP server is a RouterFactory — a function that receives a *Router and registers routes on it:
httpserver.RunDefaultServer(func(ctx context.Context, config cfg.Config, logger log.Logger, router *httpserver.Router) error {
router.GET("/hello", func(ginCtx *gin.Context) {
ginCtx.String(200, "Hello, World!")
})
return nil
})
RunDefaultServer creates a server named "default" reading from the httpserver.default config key. All you need is a config.dist.yml:
httpserver:
default:
port: 8088
You can use standard Gin handlers directly. The Router supports all HTTP methods:
router.GET("/users", listUsers)
router.POST("/users", createUser)
router.PUT("/users/:id", updateUser)
router.DELETE("/users/:id", deleteUser)
Group related routes with router.Group():
api := router.Group("/api")
api.GET("/ping", func(ginCtx *gin.Context) {
ginCtx.JSON(200, gin.H{"message": "pong"})
})
Raw Gin handlers work fine for simple cases. For type safety and cleaner code, read on.
The With pattern
httpserver.With is the recommended way to define routes. It's a generic function that:
- Calls a handler factory to create a handler instance (with access to
ctx,config, andlogger) - Calls a registration function that wires handler methods to routes
router.Group("/api/users").HandleWith(httpserver.With(NewHandler, func(r *httpserver.Router, h *Handler) {
r.GET("", httpserver.Bind(h.ListUsers))
r.POST("", httpserver.Bind(h.CreateUser))
r.GET("/:id", httpserver.Bind(h.GetUser))
r.DELETE("/:id", httpserver.Bind(h.DeleteUser))
}))
Handler factory
The handler factory must have this signature:
func NewHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*Handler, error)
Use it to initialize dependencies — database clients, external services, caches:
type Handler struct {
db *sql.DB
}
func NewHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*Handler, error) {
db, err := sql.Open("postgres", config.GetString("db_url"))
if err != nil {
return nil, err
}
return &Handler{db: db}, nil
}
Bind vs BindN
Bind[I]— for handlers that accept an input struct:(ctx, *Input) (Response, error)BindN— for handlers with no input:(ctx) (Response, error)
// With input
func (h *Handler) GetUser(ctx context.Context, input *GetUserInput) (httpserver.Response, error) {
// input is populated from the request
}
// Without input
func (h *Handler) Health(ctx context.Context) (httpserver.Response, error) {
// no request data needed
}
You can register multiple handler groups on the same router, each with its own constructor:
router.Group("/api/users").HandleWith(httpserver.With(NewUserHandler, func(r *httpserver.Router, h *UserHandler) {
r.GET("", httpserver.Bind(h.ListUsers))
r.POST("", httpserver.Bind(h.CreateUser))
}))
router.Group("/api/orders").HandleWith(httpserver.With(NewOrderHandler, func(r *httpserver.Router, h *OrderHandler) {
r.GET("", httpserver.Bind(h.ListOrders))
}))
Binding request data
Bind automatically populates input structs from the incoming request using struct tags.
URI parameters
Use the uri tag to bind path parameters. Define them in your route with :name syntax:
type UserIdInput struct {
Id int `uri:"id" binding:"required"`
}
router.GET("/users/:id", httpserver.Bind(h.GetUser))
Query string and form parameters
Use the form tag for query string parameters (GET) or form-encoded bodies (POST):
type ListUsersInput struct {
Role string `form:"role"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}
JSON request body
Use the json tag to bind from a JSON body. The content type application/json is auto-detected:
type CreateUserInput struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"omitempty,oneof=admin user guest"`
}
Combining multiple sources
Combine tags to pull from multiple sources in a single input struct:
type ListFilesInput struct {
Database string `uri:"database"`
Table string `uri:"table"`
Partitions map[string]string `json:"partitions" form:"partitions"`
}
URI parameters are always bound. Body/query binding depends on the request's Content-Type header.
Auto-detection logic
Bind examines the input type's struct tags and the request Content-Type to determine how to bind:
| Content-Type | Tag used |
|---|---|
application/json | json |
application/xml | xml |
application/x-yaml | yaml |
application/x-protobuf | protobuf |
application/x-msgpack | msgpack |
application/x-www-form-urlencoded | form |
multipart/form-data | form |
text/plain | plain |
Validation
The binding tag uses go-playground/validator. If binding or validation fails, the request is rejected with 400 Bad Request and a descriptive client error:
| Tag | Description |
|---|---|
required | Field must be present and non-zero |
email | Must be a valid email |
oneof=a b c | Must be one of the listed values |
gte=0 | Must be greater than or equal to 0 |
omitempty | Skip validation if field is empty |
BindR — access the raw request
If you need the raw *http.Request alongside your input struct:
func (h *Handler) Upload(ctx context.Context, req *http.Request, input *UploadInput) (httpserver.Response, error) {
contentType := req.Header.Get("Content-Type")
// ...
}
Sending responses
All handler methods return an httpserver.Response interface.
JSON responses
NewJsonResponse serializes any value as JSON:
return httpserver.NewJsonResponse(user), nil
Text responses
return httpserver.NewTextResponse("Hello, plain text!"), nil
Status-only responses
NewStatusResponse returns a response with only a status code — useful for DELETE, PUT, PATCH:
return httpserver.NewStatusResponse(204), nil
Custom status codes and headers
Use functional options to customize any response:
return httpserver.NewJsonResponse(
user,
httpserver.WithStatusCode(http.StatusCreated),
httpserver.WithHeader("X-Custom-Header", "my-value"),
), nil
| Option | Description |
|---|---|
WithStatusCode(code int) | Set the HTTP status code (default: 200) |
WithHeader(key, value string) | Add a single header |
WithHeaders(headers http.Header) | Merge multiple headers |
WithBody(body []byte) | Set the raw response body |
Error handling
Return nil, error for unexpected errors. The error middleware returns 500 Internal Server Error with a sanitized {"err":"internal server error"} response, so internal details do not leak to clients:
func (h *Handler) Health(ctx context.Context) (httpserver.Response, error) {
if len(h.users) > 10000 {
return nil, fmt.Errorf("too many users")
}
return httpserver.NewJsonResponse(map[string]string{"status": "ok"}), nil
}
For client errors with a specific status code, use GetErrorHandler() — this returns a Response with nil error (a handled error):
func (h *Handler) GetUser(ctx context.Context, input *UserIdInput) (httpserver.Response, error) {
user, ok := h.users[input.Id]
if !ok {
return httpserver.GetErrorHandler()(http.StatusNotFound, errors.New("user not found")), nil
}
return httpserver.NewJsonResponse(user), nil
}
The key pattern: return a Response with nil error for handled errors, and nil, error for unexpected errors.
If middleware or lower-level code attaches an error to the Gin context and needs a specific status code, wrap it with NewErrorWithStatus:
ginCtx.Error(httpserver.NewErrorWithStatus(http.StatusBadRequest, err))
Complete example
main.go
package main
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/gosoline-project/httpserver"
"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/log"
)
func main() {
httpserver.RunDefaultServer(func(ctx context.Context, config cfg.Config, logger log.Logger, router *httpserver.Router) error {
router.Group("/api/users").HandleWith(httpserver.With(NewHandler, func(r *httpserver.Router, h *Handler) {
r.GET("", httpserver.Bind(h.ListUsers))
r.POST("", httpserver.Bind(h.CreateUser))
r.GET("/:id", httpserver.Bind(h.GetUser))
r.DELETE("/:id", httpserver.Bind(h.DeleteUser))
r.GET("/health", httpserver.BindN(h.Health))
}))
return nil
})
}
type ListUsersInput struct {
Role string `form:"role"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}
type CreateUserInput struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"omitempty,oneof=admin user guest"`
}
type UserIdInput struct {
Id int `uri:"id" binding:"required"`
}
type User struct {
Id int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
}
type Handler struct {
users map[int]*User
next int
}
func NewHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*Handler, error) {
return &Handler{
users: map[int]*User{},
next: 1,
}, nil
}
func (h *Handler) ListUsers(ctx context.Context, input *ListUsersInput) (httpserver.Response, error) {
var result []*User
for _, u := range h.users {
if input.Role != "" && u.Role != input.Role {
continue
}
result = append(result, u)
if input.Limit > 0 && len(result) >= input.Limit {
break
}
}
return httpserver.NewJsonResponse(result), nil
}
func (h *Handler) CreateUser(ctx context.Context, input *CreateUserInput) (httpserver.Response, error) {
user := &User{
Id: h.next,
Name: input.Name,
Email: input.Email,
Role: input.Role,
}
h.users[user.Id] = user
h.next++
return httpserver.NewJsonResponse(user, httpserver.WithStatusCode(http.StatusCreated)), nil
}
func (h *Handler) GetUser(ctx context.Context, input *UserIdInput) (httpserver.Response, error) {
user, ok := h.users[input.Id]
if !ok {
return httpserver.GetErrorHandler()(http.StatusNotFound, errors.New("user not found")), nil
}
return httpserver.NewJsonResponse(user), nil
}
func (h *Handler) DeleteUser(ctx context.Context, input *UserIdInput) (httpserver.Response, error) {
delete(h.users, input.Id)
return httpserver.NewStatusResponse(http.StatusNoContent), nil
}
func (h *Handler) Health(ctx context.Context) (httpserver.Response, error) {
if len(h.users) > 10000 {
return nil, fmt.Errorf("too many users")
}
return httpserver.NewJsonResponse(map[string]string{"status": "ok"}), nil
}
config.dist.yml
app:
env: dev
name: build-service
httpserver:
default:
port: 8088
Test it:
# Create a user
curl -X POST http://localhost:8088/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com","role":"admin"}'
# {"id":1,"name":"Alice","email":"alice@example.com","role":"admin"}
# List users
curl http://localhost:8088/api/users
# [{"id":1,"name":"Alice","email":"alice@example.com","role":"admin"}]
# Get a user by ID
curl http://localhost:8088/api/users/1
# {"id":1,"name":"Alice","email":"alice@example.com","role":"admin"}
# Get a missing user
curl http://localhost:8088/api/users/99
# {"err":"user not found"}
# Delete a user
curl -X DELETE http://localhost:8088/api/users/1 -v
# HTTP/1.1 204 No Content
# Health check
curl http://localhost:8088/api/users/health
# {"status":"ok"}
What's next?
- Stream with Server-Sent Events — push real-time updates to clients
- Serve a frontend — embed and serve a SPA from your Go binary
- Configure your server — timeouts, compression, multiple servers
- Add middleware — custom middleware, CORS
- Real-world example — patterns from production applications