Controller
Writing controllers in Spine.
What is a Controller?
A controller is a layer that accepts HTTP requests and delegates them to a service.
A Spine controller is a pure Go struct. No annotations, decorators, or special interface implementations are required.
// This is all
type UserController struct {
svc *service.UserService
}Basic Structure
1. Define Struct
package controller
type UserController struct {
svc *service.UserService // Dependency
}2. Write Constructor
// Constructor parameter = Dependency declaration
func NewUserController(svc *service.UserService) *UserController {
return &UserController{svc: svc}
}3. Write Handler Method
// Function signature is the API spec
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
) httpx.Response[dto.UserResponse] {
user, err := c.svc.Get(ctx, int(userId.Value))
if err != nil {
return httpx.Response[dto.UserResponse]{
Options: httpx.ResponseOptions{Status: 404},
}
}
return httpx.Response[dto.UserResponse]{Body: user}
}4. Register Route
app.Route("GET", "/users/:id", (*UserController).GetUser)Handler Signature
Spine analyzes the handler's function signature to automatically bind inputs.
Supported Parameter Types
| Type | Description | Example |
|---|---|---|
context.Context | Request Context | ctx context.Context |
query.Values | Query Parameters | q query.Values |
query.Pagination | Pagination | page query.Pagination |
header.Values | HTTP Headers | headers header.Values |
*struct (DTO) | JSON Request Body | req *CreateUserRequest |
*struct (Form) | Form Data | form *CreatePostForm |
multipart.UploadedFiles | Multipart Files | files multipart.UploadedFiles |
path.* | Path Parameters | userId path.Int |
spine.Ctx | Controller Context | spineCtx spine.Ctx |
Supported Return Types
| Type | Description |
|---|---|
httpx.Response[T] | JSON or String response (including status code, headers, cookies) |
httpx.Redirect | Redirect response |
error | Error response |
Receiving Input
Query Parameters
Use query.Values to parse the query string.
// GET /users?id=1&name=alice&active=true
func (c *UserController) GetUser(
ctx context.Context,
q query.Values,
) httpx.Response[dto.UserResponse] {
id := q.Int("id", 0) // int64, default 0
name := q.String("name") // string
active := q.GetBoolByKey("active", false) // bool, default false
user, _ := c.svc.Get(ctx, int(id))
return httpx.Response[dto.UserResponse]{Body: user}
}query.Values Methods
| Method | Return Type | Description |
|---|---|---|
Get(key) | string | First value (empty string if missing) |
String(key) | string | String value |
Int(key, default) | int64 | Integer value |
GetBoolByKey(key, default) | bool | Boolean value |
Has(key) | bool | Existence check |
Pagination
Using query.Pagination automatically parses page and size query parameters.
// GET /users?page=2&size=10
func (c *UserController) ListUsers(
ctx context.Context,
page query.Pagination,
) httpx.Response[[]dto.UserResponse] {
// page.Page = 2 (default: 1)
// page.Size = 10 (default: 20)
users, _ := c.svc.List(ctx, page.Page, page.Size)
return httpx.Response[[]dto.UserResponse]{Body: users}
}HTTP Headers
Use header.Values to access HTTP headers.
import "github.com/NARUBROWN/spine/pkg/header"
func (c *CommonController) CheckHeader(
headers header.Values,
) httpx.Response[dto.HeaderInfo] {
return httpx.Response[dto.HeaderInfo]{
Body: dto.HeaderInfo{
UserAgent: headers.Get("User-Agent"),
ContentType: headers.Get("Content-Type"),
},
}
}header.Values Methods
| Method | Return Type | Description |
|---|---|---|
Get(key) | string | Header value |
Has(key) | bool | Header existence check |
Path Parameters
Use types from the path package to bind route path parameters.
// GET /users/:id
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
) httpx.Response[dto.UserResponse] {
user, _ := c.svc.Get(ctx, int(userId.Value))
return httpx.Response[dto.UserResponse]{Body: user}
}path Package Structs
package path
type Int struct {
Value int64
}
type String struct {
Value string
}
type Boolean struct {
Value bool
}JSON Request Body
Declaring a DTO struct as a pointer automatically binds JSON.
// POST /users
// Body: {"name": "Alice", "email": "alice@example.com"}
func (c *UserController) CreateUser(
ctx context.Context,
req *dto.CreateUserRequest, // ← Declare as pointer
) httpx.Response[dto.UserResponse] {
user, _ := c.svc.Create(ctx, req.Name, req.Email)
return httpx.Response[dto.UserResponse]{
Body: user,
Options: httpx.ResponseOptions{
Status: 201,
},
}
}// dto/user_request.go
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}Controller Context (spine.Ctx)
Use spine.Ctx when referencing values injected by interceptors.
import "github.com/NARUBROWN/spine/pkg/spine"
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
spineCtx spine.Ctx,
) httpx.Response[dto.UserResponse] {
// Retrieve value set in interceptor
if v, ok := spineCtx.Get("userRole"); ok {
role := v.(string)
// ...
}
user, _ := c.svc.Get(ctx, int(userId.Value))
return httpx.Response[dto.UserResponse]{Body: user}
}Returning Responses
Spine supports three return types: httpx.Response[T], httpx.Redirect, and error.
1. httpx.Response[T] — JSON/String Response
httpx.Response[T] allows fine-grained control over status codes, headers, and cookies.
import "github.com/NARUBROWN/spine/pkg/httpx"Basic JSON Response
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
) httpx.Response[dto.UserResponse] {
user, _ := c.svc.Get(ctx, int(userId.Value))
return httpx.Response[dto.UserResponse]{
Body: user, // 200 OK (default)
}
}String Response
func (c *UserController) Health() httpx.Response[string] {
return httpx.Response[string]{
Body: "OK",
}
}Specifying Status Code
func (c *UserController) CreateUser(
ctx context.Context,
req *dto.CreateUserRequest,
) httpx.Response[dto.UserResponse] {
user, _ := c.svc.Create(ctx, req.Name, req.Email)
return httpx.Response[dto.UserResponse]{
Body: user,
Options: httpx.ResponseOptions{
Status: 201, // Created
},
}
}Adding Custom Headers
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
) httpx.Response[dto.UserResponse] {
user, _ := c.svc.Get(ctx, int(userId.Value))
return httpx.Response[dto.UserResponse]{
Body: user,
Options: httpx.ResponseOptions{
Headers: map[string]string{
"X-Custom-Header": "custom-value",
"Cache-Control": "max-age=3600",
},
},
}
}Setting Cookies
func (c *AuthController) Login(
ctx context.Context,
req *dto.LoginRequest,
) httpx.Response[dto.LoginResponse] {
token, refreshToken, _ := c.svc.Login(ctx, req.Email, req.Password)
return httpx.Response[dto.LoginResponse]{
Body: dto.LoginResponse{Success: true},
Options: httpx.ResponseOptions{
Cookies: []httpx.Cookie{
httpx.AccessTokenCookie(token, 15*time.Minute),
httpx.RefreshTokenCookie(refreshToken, 7*24*time.Hour),
},
},
}
}httpx.Cookie Struct
type Cookie struct {
Name string
Value string
Path string
Domain string
MaxAge int
Expires *time.Time
HttpOnly bool
Secure bool
SameSite SameSite // SameSiteLax, SameSiteStrict, SameSiteNone
Priority string // "Low" | "Medium" | "High"
}Cookie Helper Functions
| Function | Description |
|---|---|
httpx.AccessTokenCookie(token, ttl) | Create Access Token cookie |
httpx.RefreshTokenCookie(token, ttl) | Create Refresh Token cookie |
httpx.DefaultRefreshTokenCookie(token) | 7-day TTL Refresh Token cookie |
httpx.ClearAccessTokenCookie() | Delete Access Token cookie |
httpx.ClearRefreshTokenCookie() | Delete Refresh Token cookie |
Built-in response handlers validate all cookie fields before writing any response output. Invalid names, values, domains, paths, SameSite, priority, or related combinations return an error instead of being silently rewritten. For arbitrary Unicode, spaces, delimiters, or binary string data, encode explicitly:
encoded := httpx.EncodeCookieValue(rawValue)
decoded, err := httpx.DecodeCookieValue(encoded)2. httpx.Redirect — Redirect Response
Use httpx.Redirect to redirect the client to another URL.
Basic Redirect (302 Found)
func (c *AuthController) OAuthCallback(
ctx context.Context,
q query.Values,
) httpx.Redirect {
code := q.String("code")
c.svc.ProcessOAuthCode(ctx, code)
return httpx.Redirect{
Location: "/dashboard", // 302 Found (default)
}
}Specifying Status Code
import "net/http"
func (c *UserController) MovedPermanently() httpx.Redirect {
return httpx.Redirect{
Location: "/new-location",
Options: httpx.ResponseOptions{
Status: http.StatusMovedPermanently, // 301
},
}
}Redirect with Cookies
func (c *AuthController) Login(
ctx context.Context,
req *dto.LoginRequest,
) httpx.Redirect {
token, _ := c.svc.Login(ctx, req.Email, req.Password)
return httpx.Redirect{
Location: "/dashboard",
Options: httpx.ResponseOptions{
Cookies: []httpx.Cookie{
httpx.AccessTokenCookie(token, 15*time.Minute),
},
},
}
}Logout (Redirect after Deleting Cookies)
func (c *AuthController) Logout() httpx.Redirect {
return httpx.Redirect{
Location: "/login",
Options: httpx.ResponseOptions{
Cookies: []httpx.Cookie{
httpx.ClearAccessTokenCookie(),
httpx.ClearRefreshTokenCookie(),
},
},
}
}3. error — Error Response
Use httperr package to return HTTP status codes and messages.
import "github.com/NARUBROWN/spine/pkg/httperr"
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
) error {
_, err := c.svc.Get(ctx, int(userId.Value))
if err != nil {
return httperr.NotFound("User not found.")
}
return nil
}httperr Functions
| Function | Status Code |
|---|---|
httperr.BadRequest(msg) | 400 |
httperr.Unauthorized(msg) | 401 |
httperr.NotFound(msg) | 404 |
Error Response Format:
{
"message": "User not found."
}Using httpx.Response[T] with error
If error handling is needed, you can handle it via status code within httpx.Response[T] or create a separate error handler method.
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
) httpx.Response[dto.UserResponse] {
user, err := c.svc.Get(ctx, int(userId.Value))
if err != nil {
return httpx.Response[dto.UserResponse]{
Options: httpx.ResponseOptions{
Status: 404,
},
}
}
return httpx.Response[dto.UserResponse]{Body: user}
}Response Method Selection Guide
| Situation | Recommended Return Type |
|---|---|
| JSON response (incl. status code/header/cookie) | httpx.Response[T] |
| String response | httpx.Response[string] |
| Redirect | httpx.Redirect |
| Return only error | error |
Form DTO and Multipart Usage
Spine supports Form DTO, Multipart, and the standard pattern of using them together.
Spine's core principles are:
- DTO must be received as
*Struct(Pointer) - Value type
Structis a Semantic Type - File upload is handled by a separate Semantic Type, not DTO
- Resolver handles only one meaning
1. Form DTO Example (multipart/form-data)
Form DTO is a DTO for binding text fields only in multipart/form-data or application/x-www-form-urlencoded requests.
Form DTO Definition
type CreatePostForm struct {
Title string `form:"title"`
Content string `form:"content"`
}Controller
func (c *PostController) Create(
form *CreatePostForm, // Form DTO
) httpx.Response[string] {
fmt.Println("Title :", form.Title)
fmt.Println("Content:", form.Content)
return httpx.Response[string]{Body: "OK"}
}Request Example (curl)
curl -X POST http://localhost:8080/posts \
-F "title=hello" \
-F "content=spine"2. Multipart File Upload Example
File uploads are handled by Semantic Types, not DTOs.
Multipart Semantic Type
Use types from github.com/NARUBROWN/spine/pkg/multipart package.
import "github.com/NARUBROWN/spine/pkg/multipart"Controller
func (c *FileController) Upload(
files multipart.UploadedFiles, // Multipart files
) httpx.Response[string] {
fmt.Println("Files count:", len(files.Files))
for _, f := range files.Files {
fmt.Println(
"field:", f.FieldName,
"name:", f.Filename,
"size:", f.Size,
)
}
return httpx.Response[string]{Body: "OK"}
}UploadedFile Struct
type UploadedFile struct {
FieldName string
Filename string
ContentType string
Size int64
Open func() (io.ReadCloser, error)
}Request Example (curl)
curl -X POST http://localhost:8080/upload \
-F "file=@test1.png" \
-F "file=@test2.jpg"3. Using Form DTO + Multipart + Query Together
Controller
func (c *PostController) Upload(
ctx context.Context,
form *CreatePostForm, // form fields
files multipart.UploadedFiles, // multipart files
page query.Pagination, // query
) httpx.Response[string] {
fmt.Println("[FORM] Title :", form.Title)
fmt.Println("[FORM] Content:", form.Content)
fmt.Println("[QUERY] Page:", page.Page)
fmt.Println("[QUERY] Size:", page.Size)
fmt.Println("[FILES] Count:", len(files.Files))
return httpx.Response[string]{Body: "OK"}
}4. Spine DTO Rules Summary
*Struct → DTO (JSON / Form)
Struct → Semantic Type (Query / Path / Multipart)By following these rules, the execution flow is revealed directly in the signature.
Registering Routes
Connect controller methods to routes.
// routes/user_routes.go
package routes
import (
"my-app/controller"
"github.com/NARUBROWN/spine"
)
func RegisterUserRoutes(app spine.App) {
app.Route("GET", "/users", (*controller.UserController).ListUsers)
app.Route("GET", "/users/:id", (*controller.UserController).GetUser)
app.Route("POST", "/users", (*controller.UserController).CreateUser)
app.Route("PUT", "/users/:id", (*controller.UserController).UpdateUser)
app.Route("DELETE", "/users/:id", (*controller.UserController).DeleteUser)
}// main.go
func main() {
app := spine.New()
app.Constructor(/* ... */)
routes.RegisterUserRoutes(app)
app.Run(boot.Options{
Address: ":8080",
EnableGracefulShutdown: true,
ShutdownTimeout: 10 * time.Second,
HTTP: &boot.HTTPOptions{},
})
}Complete Example
// controller/user_controller.go
package controller
import (
"context"
"net/http"
"time"
"my-app/dto"
"my-app/service"
"github.com/NARUBROWN/spine/pkg/httperr"
"github.com/NARUBROWN/spine/pkg/httpx"
"github.com/NARUBROWN/spine/pkg/path"
"github.com/NARUBROWN/spine/pkg/query"
)
type UserController struct {
svc *service.UserService
}
func NewUserController(svc *service.UserService) *UserController {
return &UserController{svc: svc}
}
// GET /users?page=1&size=20
func (c *UserController) ListUsers(
ctx context.Context,
page query.Pagination,
) httpx.Response[[]dto.UserResponse] {
users, _ := c.svc.List(ctx, page.Page, page.Size)
return httpx.Response[[]dto.UserResponse]{
Body: users,
Options: httpx.ResponseOptions{
Headers: map[string]string{
"X-Total-Count": "100",
},
},
}
}
// GET /users/:id
func (c *UserController) GetUser(
ctx context.Context,
userId path.Int,
) httpx.Response[dto.UserResponse] {
user, err := c.svc.Get(ctx, int(userId.Value))
if err != nil {
return httpx.Response[dto.UserResponse]{
Options: httpx.ResponseOptions{Status: 404},
}
}
return httpx.Response[dto.UserResponse]{Body: user}
}
// POST /users
func (c *UserController) CreateUser(
ctx context.Context,
req *dto.CreateUserRequest,
) httpx.Response[dto.UserResponse] {
user, _ := c.svc.Create(ctx, req.Name, req.Email)
return httpx.Response[dto.UserResponse]{
Body: user,
Options: httpx.ResponseOptions{
Status: http.StatusCreated, // 201
},
}
}
// PUT /users/:id
func (c *UserController) UpdateUser(
ctx context.Context,
userId path.Int,
req *dto.UpdateUserRequest,
) httpx.Response[dto.UserResponse] {
user, err := c.svc.Update(ctx, int(userId.Value), req.Name)
if err != nil {
return httpx.Response[dto.UserResponse]{
Options: httpx.ResponseOptions{Status: 404},
}
}
return httpx.Response[dto.UserResponse]{Body: user}
}
// DELETE /users/:id
func (c *UserController) DeleteUser(
ctx context.Context,
userId path.Int,
) error {
return c.svc.Delete(ctx, int(userId.Value))
}// controller/auth_controller.go
package controller
import (
"context"
"time"
"my-app/dto"
"my-app/service"
"github.com/NARUBROWN/spine/pkg/httpx"
)
type AuthController struct {
svc *service.AuthService
}
func NewAuthController(svc *service.AuthService) *AuthController {
return &AuthController{svc: svc}
}
// POST /auth/login
func (c *AuthController) Login(
ctx context.Context,
req *dto.LoginRequest,
) httpx.Redirect {
token, _ := c.svc.Login(ctx, req.Email, req.Password)
return httpx.Redirect{
Location: "/dashboard",
Options: httpx.ResponseOptions{
Cookies: []httpx.Cookie{
httpx.AccessTokenCookie(token, 15*time.Minute),
},
},
}
}
// POST /auth/logout
func (c *AuthController) Logout() httpx.Redirect {
return httpx.Redirect{
Location: "/login",
Options: httpx.ResponseOptions{
Cookies: []httpx.Cookie{
httpx.ClearAccessTokenCookie(),
httpx.ClearRefreshTokenCookie(),
},
},
}
}Key Takeaways
| Concept | Description |
|---|---|
| No Annotations | Pure Go structs and methods |
| Constructor = Dependency | Parameters are dependency declarations |
| Signature = API Spec | Input/Output types are explicit |
| Auto Binding | Automtically parses query, JSON body, headers |
| httpx.Response[T] | Control status code, headers, cookies |
| httpx.Redirect | Redirect response |
Next Steps
- Tutorial: Dependency Injection — DI Principles
- Tutorial: Interceptor — Pre/Post Request Processing
