Implementation: @Handler Creation
When to Use
Use this skill when:
- Implementing HTTP REST endpoints from API specification
- Creating handler services with multiple routes
- Setting up dependency injection for handlers
- Adding automatic request validation and error handling
- Building CRUD operations with path parameters
Prerequisites:
- ✅ API specification finalized (see: design-lokstra-api-specification)
- ✅ Domain models created (DTOs with validation tags)
- ✅ Repository interfaces defined (see: design-lokstra-module-requirements)
- ✅ config.yaml with service definitions ready (see: implementation-lokstra-yaml-config)
- ✅ Framework initialized in main.go (see: implementation-lokstra-init-framework)
Basic Handler Structure
Example: User Handler
File: modules/user/application/user_handler.go
package application
import (
"myapp/modules/user/domain"
"github.com/primadi/lokstra/core/request"
)
// @Handler annotation defines:
// - name: unique service identifier for DI
// - prefix: base URL path for all routes in this handler
// - middlewares (optional): global middlewares for all routes
//
// @Handler name="user-handler", prefix="/api/users"
type UserHandler struct {
// @Inject dependency injection
// References service name from config.yaml or another @Service
//
// @Inject "user-repository"
UserRepo domain.UserRepository
// @Inject config values using "cfg:" prefix
// @Inject "cfg:app.timeout"
Timeout time.Duration
}
// @Route "GET /{id}"
// Returns user or error (Lokstra auto-converts to JSON response)
func (h *UserHandler) GetByID(id string) (*domain.User, error) {
return h.UserRepo.GetByID(id)
}
// @Route "GET /"
// List all users
func (h *UserHandler) List() ([]*domain.User, error) {
return h.UserRepo.List()
}
// @Route "POST /", middlewares=["auth"]
// Create user with request validation
// params are auto-validated based on struct tags
func (h *UserHandler) Create(params *domain.CreateUserRequest) (*domain.User, error) {
user := &domain.User{
Name: params.Name,
Email: params.Email,
}
return h.UserRepo.Create(user)
}
// @Route "PUT /{id}", middlewares=["auth"]
// Update user with path parameter and request body
func (h *UserHandler) Update(id string, params *domain.UpdateUserRequest) (*domain.User, error) {
user, err := h.UserRepo.GetByID(id)
if err != nil {
return nil, err
}
user.Name = params.Name
user.Email = params.Email
return h.UserRepo.Update(user)
}
// @Route "DELETE /{id}", middlewares=["auth", "admin"]
// Delete user (admin only)
func (h *UserHandler) Delete(id string) error {
return h.UserRepo.Delete(id)
}
File: modules/user/domain/user_dto.go
package domain
// Request DTOs with validation tags
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"omitempty,min=0,max=150"`
}
type UpdateUserRequest struct {
Name string `json:"name" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
}
// Domain model
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Handler Components Deep Dive
1. @Handler Annotation
Defines the handler service and its configuration:
// Basic handler
// @Handler name="user-handler", prefix="/api/users"
type UserHandler struct { }
// With global middlewares
// @Handler name="admin-handler", prefix="/api/admin", middlewares=["auth", "admin"]
type AdminHandler struct { }
// Multiple handlers in same module
// @Handler name="user-public-handler", prefix="/api/public/users"
type UserPublicHandler struct { }
// @Handler name="user-admin-handler", prefix="/api/admin/users", middlewares=["auth", "admin"]
type UserAdminHandler struct { }
Annotation Parameters:
| Parameter | Required | Description | Example |
|---|---|---|---|
name |
Yes | Unique service identifier for DI | "user-handler" |
prefix |
Yes | Base URL path for all routes | "/api/users" |
middlewares |
No | Global middlewares for all routes | ["auth", "logger"] |
Key Rules:
- Handler names must be unique across the application
- Prefix should follow REST conventions (
/api/resource) - Middlewares are applied to ALL routes in this handler
- Use empty prefix
prefix=""to mount routes at root
2. @Inject Dependency Injection
Inject services and configuration into handler fields:
type UserHandler struct {
// Pattern 1: Direct service injection
// @Inject "user-repository"
UserRepo domain.UserRepository
// Pattern 2: Service from config (interface selection)
// References service name from config.yaml
// @Inject "@repositories.user"
UserRepo domain.UserRepository
// Pattern 3: Config value injection
// @Inject "cfg:app.timeout"
Timeout time.Duration
// @Inject "cfg:app.name"
AppName string
// @Inject "cfg:features.email_enabled"
EmailEnabled bool
// Pattern 4: Indirect config reference
// Looks up value at key specified in another config
// @Inject "cfg:@database.connection_string"
DatabaseURL string
// Multiple injections
// @Inject "logger-service"
Logger domain.Logger
// @Inject "cache-service"
Cache domain.CacheService
}
Injection Patterns:
| Pattern | Syntax | Use Case | Example |
|---|---|---|---|
| Direct service | "service-name" |
Inject registered service | "user-repository" |
| Config service ref | "@config.key" |
Service name from config | "@repository.impl" |
| Config value | "cfg:key" |
Direct config value | "cfg:app.timeout" |
| Indirect config | "cfg:@key" |
Config value reference | "cfg:@jwt.secret_path" |
3. @Route HTTP Endpoints
Define HTTP methods and paths for handler functions:
// Basic routes
// @Route "GET /" // List all
// @Route "GET /{id}" // Get by ID
// @Route "POST /" // Create
// @Route "PUT /{id}" // Update
// @Route "PATCH /{id}" // Partial update
// @Route "DELETE /{id}" // Delete
// With route-specific middlewares
// @Route "POST /", middlewares=["auth"]
// @Route "DELETE /{id}", middlewares=["auth", "admin"]
// Multiple path parameters
// @Route "GET /{userId}/posts/{postId}"
// @Route "PUT /{teamId}/members/{memberId}"
// Complex paths
// @Route "POST /{userId}/activate"
// @Route "POST /{userId}/suspend"
// @Route "GET /{id}/export"
// Query parameters (use struct with query tags)
// @Route "GET /search"
func (h *UserHandler) Search(params *SearchParams) ([]*User, error) { }
Route Parameters:
| Component | Description | Example |
|---|---|---|
| HTTP Method | GET, POST, PUT, PATCH, DELETE | "GET" |
| Path | URL path (relative to handler prefix) | "/{id}" |
| Path params | {paramName} in path |
"/{userId}/posts/{postId}" |
| Middlewares | Route-specific middleware list | middlewares=["auth", "rate-limit"] |
Important Notes:
- Paths are relative to handler's
prefix - Path parameters must match function parameter names
- Middlewares in
@Routeare added to handler's global middlewares - Route methods generate:
GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD
Handler Function Signatures
Lokstra supports 29+ handler signatures. Choose the signature that best fits your needs.
1. Simple Return (No Parameters)
// @Route "GET /ping"
func (h *UserHandler) Ping() string {
return "pong"
}
// @Route "GET /status"
func (h *UserHandler) Status() map[string]interface{} {
return map[string]interface{}{
"status": "healthy",
"timestamp": time.Now(),
}
}
Use Case: Simple endpoints, health checks, static responses
2. Return with Error
// @Route "GET /{id}"
func (h *UserHandler) GetByID(id string) (*domain.User, error) {
user, err := h.UserRepo.GetByID(id)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
return user, nil
}
// @Route "GET /"
func (h *UserHandler) List() ([]*domain.User, error) {
return h.UserRepo.List()
}
Use Case: Most common pattern, automatic JSON response, error handling
3. Request Body with Auto-Validation
// @Route "POST /"
func (h *UserHandler) Create(req *domain.CreateUserRequest) (*domain.User, error) {
// req is already validated by framework
user := &domain.User{
Name: req.Name,
Email: req.Email,
}
return h.UserRepo.Create(user)
}
Validation Tags (in DTO):
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"omitempty,min=0,max=150"`
Website string `json:"website" validate:"omitempty,url"`
Password string `json:"password" validate:"required,min=8"`
}
Use Case: POST/PUT endpoints with request body validation
4. Path Parameters
// Single parameter
// @Route "GET /{id}"
func (h *UserHandler) GetByID(id string) (*domain.User, error) {
return h.UserRepo.GetByID(id)
}
// Multiple parameters
// @Route "PUT /{id}"
func (h *UserHandler) Update(id string, req *domain.UpdateUserRequest) (*domain.User, error) {
user, err := h.UserRepo.GetByID(id)
if err != nil {
return nil, err
}
user.Name = req.Name
user.Email = req.Email
return h.UserRepo.Update(user)
}
// Multiple path parameters
// @Route "GET /{userId}/posts/{postId}"
func (h *UserHandler) GetPost(userId, postId string) (*domain.Post, error) {
return h.PostRepo.GetByUserAndPost(userId, postId)
}
Use Case: RESTful CRUD operations with resource identifiers
5. With Context (Custom Response Control)
// @Route "GET /{id}"
func (h *UserHandler) GetByID(ctx *request.Context, id string) error {
if id == "" {
return ctx.Api.BadRequest("id is required")
}
user, err := h.UserRepo.GetByID(id)
if err != nil {
return ctx.Api.NotFound("user not found")
}
return ctx.Api.Ok(user)
}
// @Route "POST /"
func (h *UserHandler) Create(ctx *request.Context, req *domain.CreateUserRequest) error {
user := &domain.User{
Name: req.Name,
Email: req.Email,
}
saved, err := h.UserRepo.Create(user)
if err != nil {
return ctx.Api.InternalServerError("failed to create user")
}
// Return 201 Created
return ctx.Api.Created(saved)
}
// @Route "DELETE /{id}"
func (h *UserHandler) Delete(ctx *request.Context, id string) error {
err := h.UserRepo.Delete(id)
if err != nil {
return ctx.Api.InternalServerError("failed to delete user")
}
// Return 204 No Content
return ctx.Api.NoContent()
}
Available Response Helpers:
ctx.Api.Ok(data) // 200 OK
ctx.Api.Created(data) // 201 Created
ctx.Api.NoContent() // 204 No Content
ctx.Api.BadRequest(message) // 400 Bad Request
ctx.Api.Unauthorized(message) // 401 Unauthorized
ctx.Api.Forbidden(message) // 403 Forbidden
ctx.Api.NotFound(message) // 404 Not Found
ctx.Api.Conflict(message) // 409 Conflict
ctx.Api.InternalServerError(message) // 500 Internal Server Error
Use Case: Custom HTTP status codes, advanced error handling, accessing request context
6. Query Parameters
// Define struct with query tags
type SearchParams struct {
Query string `query:"q" validate:"required"`
Page int `query:"page" validate:"min=1"`
Limit int `query:"limit" validate:"min=1,max=100"`
SortBy string `query:"sort" validate:"omitempty,oneof=name email created_at"`
}
// @Route "GET /search"
func (h *UserHandler) Search(params *SearchParams) ([]*domain.User, error) {
return h.UserRepo.Search(params.Query, params.Page, params.Limit, params.SortBy)
}
// With context
// @Route "GET /search"
func (h *UserHandler) SearchWithContext(ctx *request.Context, params *SearchParams) error {
users, err := h.UserRepo.Search(params.Query, params.Page, params.Limit, params.SortBy)
if err != nil {
return ctx.Api.InternalServerError("search failed")
}
return ctx.Api.Ok(users)
}
Use Case: Search endpoints, filtering, pagination
7. Combined Parameters (Context + Path + Body)
// @Route "PUT /{id}"
func (h *UserHandler) Update(ctx *request.Context, id string, req *domain.UpdateUserRequest) error {
// Validate ID
if id == "" {
return ctx.Api.BadRequest("id is required")
}
// Check existence
existing, err := h.UserRepo.GetByID(id)
if err != nil {
return ctx.Api.NotFound("user not found")
}
// Update fields
existing.Name = req.Name
existing.Email = req.Email
// Save
updated, err := h.UserRepo.Update(existing)
if err != nil {
return ctx.Api.InternalServerError("update failed")
}
return ctx.Api.Ok(updated)
}
// @Route "POST /{teamId}/members"
func (h *TeamHandler) AddMember(ctx *request.Context, teamId string, req *domain.AddMemberRequest) error {
member, err := h.TeamRepo.AddMember(teamId, req.UserID, req.Role)
if err != nil {
return ctx.Api.InternalServerError("failed to add member")
}
return ctx.Api.Created(member)
}
Use Case: Complex operations requiring fine-grained control
request.Context: Abstraction Layers
The request.Context is the core API for handler functions. It provides multiple layers of abstraction over Go's standard HTTP primitives, allowing you to choose the level of abstraction that fits your needs.
Architecture: Four-Layer Abstraction
Layer 4 (Business Logic)
↓
ctx.Api // Opinionated API responses (wrapped in standard format)
↓
Layer 3 (Structured Responses)
↓
ctx.Resp // Generic response builder (JSON, HTML, streaming)
↓
Layer 2 (Low-Level HTTP)
↓
ctx.W // ResponseWriter | ctx.R.Method, ctx.R.URL, etc.
↓
Layer 1 (Go Standard Library)
↓
http.ResponseWriter | http.Request
Context Fields Overview
type Context struct {
context.Context // Embedded - standard Go context (for transactions, values)
// Layer 4: High-level, opinionated API responses
Api *ApiHelper // Best for: REST APIs, standard response format
// Layer 3: Generic, low-level response builder
Resp *Response // Best for: Custom responses, non-JSON formats
// Layer 2: Request/Response primitives + helpers
Req *RequestHelper // Request data binding & validation (high-level)
W *writerWrapper // ResponseWriter wrapper (raw HTTP output)
R *http.Request // Go's standard http.Request (raw HTTP input)
}
Layer 1: Go Standard Library
Raw HTTP primitives from Go's standard library. Use when you need maximum control.
http.Request (ctx.R)
Access the raw HTTP request:
// Basic information
method := ctx.R.Method // "GET", "POST", etc.
url := ctx.R.URL.String() // Full request URL
path := ctx.R.URL.Path // Just the path
query := ctx.R.URL.Query() // Query parameters as map
headers := ctx.R.Header // All HTTP headers
// Specific values
contentType := ctx.R.Header.Get("Content-Type")
userAgent := ctx.R.Header.Get("User-Agent")
// Request body (use ctx.Req.RawRequestBody() instead)
body := ctx.R.Body
// Client address
remoteAddr := ctx.R.RemoteAddr // "192.168.1.1:12345"
// Standard context (for database, timeouts, cancellation)
goContext := ctx.R.Context() // context.Context
http.ResponseWriter (ctx.W)
Write raw HTTP responses (use ctx.Resp or ctx.Api instead):
// ⚠️ Low-level API - prefer ctx.Resp or ctx.Api
ctx.W.Header().Set("X-Custom-Header", "value") // Set header
ctx.W.WriteHeader(http.StatusCreated) // Set status code
ctx.W.Write([]byte("raw data")) // Write body
Layer 2: Request/Response Helpers (High-Level Primitives)
Type-safe, validated access to request data and response building.
RequestHelper (ctx.Req)
Extract and bind request data with validation:
// Single value getters (safe, with defaults)
value := ctx.Req.QueryParam("q", "") // Query: ?q=value
value := ctx.Req.PathParam("id", "") // Path: /users/{id}
value := ctx.Req.HeaderParam("X-API-Key", "") // Header: X-API-Key
value := ctx.Req.FormParam("field", "") // Form field
// Multiple value getters
values := ctx.Req.QueryParams("tags") // []string - multiple query values
values := ctx.Req.HeaderValues("Accept") // []string - header values
// All at once
all := ctx.Req.AllQueryParams() // map[string][]string
all := ctx.Req.AllHeaders() // map[string][]string
// Raw request body
body, err := ctx.Req.RawRequestBody() // []byte + error
// Binding with automatic validation
err := ctx.Req.BindPath(¶ms) // Bind path params + validate
err := ctx.Req.BindQuery(¶ms) // Bind query params + validate
err := ctx.Req.BindHeader(¶ms) // Bind headers + validate
err := ctx.Req.BindBody(¶ms) // Bind JSON body + validate
err := ctx.Req.BindAll(¶ms) // Bind all sources + validate
err := ctx.Req.BindAllAuto(¶ms) // Auto-detect content-type + validate
Example:
// @Route "GET /users"
func (h *UserHandler) List(params *ListUsersRequest) ([]*User, error) {
// params auto-bound and validated from:
// - Query: ?page=1&limit=10&status=active
// - Already validated by framework before entering handler
page := params.Page // Already converted to int, validated min=1
limit := params.Limit // Already converted to int, validated min=1,max=100
return h.repo.List(page, limit, params.Status)
}
type ListUsersRequest struct {
Page int `query:"page" validate:"omitempty,min=1"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
Status string `query:"status" validate:"omitempty,oneof=active inactive"`
}
Response (ctx.Resp)
Generic response builder supporting multiple content types:
// JSON (most common)
ctx.Resp.WithStatus(http.StatusOK).Json(map[string]any{
"id": 1,
"name": "John",
})
// HTML
ctx.Resp.WithStatus(http.StatusOK).Html("<h1>Welcome</h1>")
// Plain text
ctx.Resp.WithStatus(http.StatusOK).Text("Hello, World!")
// Raw bytes with content-type
ctx.Resp.WithStatus(http.StatusOK).Raw("text/csv", csvBytes)
// Streaming (for large files, SSE, etc.)
ctx.Resp.Stream("text/event-stream", func(w http.ResponseWriter) error {
for i := 0; i < 10; i++ {
fmt.Fprintf(w, "data: event %d\n\n", i)
}
return nil
})
// Custom headers
ctx.Resp.RespHeaders = map[string][]string{
"X-Custom": {"value1", "value2"},
}
ctx.Resp.WithStatus(http.StatusOK).Json(data)
Layer 3: Direct Response (ctx.Resp)
Build responses with full control over status codes and content types. Use this when ctx.Api doesn't fit your needs.
Common Patterns
// @Route "GET /{id}"
func (h *UserHandler) GetByID(ctx *request.Context, id string) error {
user, err := h.repo.GetByID(id)
if err != nil {
// Return 404 with custom message
return ctx.Resp.WithStatus(http.StatusNotFound).Json(map[string]string{
"error": "user not found",
})
}
// Return 200 with data
return ctx.Resp.WithStatus(http.StatusOK).Json(user)
}
// @Route "POST /"
func (h *UserHandler) Create(ctx *request.Context, req *CreateUserRequest) error {
user := &User{Name: req.Name}
if err := h.repo.Create(user); err != nil {
// Return 500 with error
return ctx.Resp.WithStatus(http.StatusInternalServerError).Json(map[string]string{
"error": "failed to create user",
})
}
// Return 201 Created with location header
ctx.Resp.RespHeaders = map[string][]string{
"Location": {fmt.Sprintf("/users/%s", user.ID)},
}
return ctx.Resp.WithStatus(http.StatusCreated).Json(user)
}
// Streaming large file
// @Route "GET /export"
func (h *UserHandler) ExportCSV(ctx *request.Context) error {
return ctx.Resp.Stream("text/csv", func(w http.ResponseWriter) error {
w.Header().Set("Content-Disposition", "attachment; filename=users.csv")
// Write CSV header
fmt.Fprintln(w, "ID,Name,Email")
// Stream users
users, _ := h.repo.List()
for _, user := range users {
fmt.Fprintf(w, "%s,%s,%s\n", user.ID, user.Name, user.Email)
}
return nil
})
}
Layer 4: API Helper (ctx.Api) - Recommended for REST APIs
Opinionated, structured API responses wrapped in a standard format. Automatically handles status codes, error formatting, and response wrapping.
Success Responses
// @Route "GET /{id}"
func (h *UserHandler) GetByID(ctx *request.Context, id string) error {
user, err := h.repo.GetByID(id)
if err != nil {
return ctx.Api.NotFound("user not found")
}
return ctx.Api.Ok(user) // 200 OK, wrapped in standard format
}
// @Route "POST /"
func (h *UserHandler) Create(ctx *request.Context, req *CreateUserRequest) error {
user := &User{Name: req.Name, Email: req.Email}
if err := h.repo.Create(user); err != nil {
return ctx.Api.InternalError("failed to create user")
}
return ctx.Api.Created(user, "User created successfully") // 201 Created
}
// List with pagination
// @Route "GET /"
func (h *UserHandler) List(params *ListRequest) error {
users, total := h.repo.List(params.Page, params.Limit)
return ctx.Api.OkList(users, &api_formatter.ListMeta{
Page: params.Page,
Limit: params.Limit,
Total: total,
TotalPages: (total + params.Limit - 1) / params.Limit,
})
}
Error Responses
// Validation errors (automatic - framework handles)
// 400 Bad Request with field-level errors
ctx.Api.ValidationError("Validation failed", []api_formatter.FieldError{
{Field: "email", Code: "INVALID_FORMAT", Message: "invalid email"},
{Field: "age", Code: "OUT_OF_RANGE", Message: "must be 18-100"},
})
// Client errors
ctx.Api.BadRequest("INVALID_INPUT", "email format is invalid") // 400
ctx.Api.Unauthorized("token is expired") // 401
ctx.Api.Forbidden("insufficient permissions") // 403
ctx.Api.NotFound("user not found") // 404
// Server error
ctx.Api.InternalError("database connection failed") // 500
// Custom status code
ctx.Api.Error(http.StatusConflict, "DUPLICATE_EMAIL", "email already exists") // 409
Response Format
All ctx.Api.* responses wrap data in a standard structure (configurable via api_formatter):
{
"success": true,
"code": "SUCCESS",
"message": "User retrieved",
"data": {
"id": "user-123",
"name": "John Doe",
"email": "john@example.com"
}
}
Error response:
{
"success": false,
"code": "NOT_FOUND",
"message": "user not found",
"data": null
}
Validation error:
{
"success": false,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "invalid email format"
}
]
}
Context Utilities: Values & Transactions
Context Values Storage
Store and retrieve request-scoped values:
// Simple storage (request-scoped only, not propagated)
ctx.Set("user_id", "user-123")
ctx.Set("tenant_id", "tenant-abc")
userID := ctx.Get("user_id").(string)
// Standard context values (propagated to child contexts)
ctx.SetContextValue("tracing_id", "trace-xyz")
traceID := ctx.GetContextValue("tracing_id").(string)
// Pass to services
user, err := s.repo.FindByID(ctx, userID) // ctx embeds context.Context
Transaction Management
Manage database transactions with automatic commit/rollback:
// @Route "POST /"
func (h *UserHandler) Create(ctx *request.Context, req *CreateRequest) error {
// Begin transaction (auto-managed)
ctx.BeginTransaction("postgres-db")
// All repository calls within same handler use transaction
user := &User{Name: req.Name}
if err := h.repo.Create(ctx, user); err != nil {
return err // Auto-rollback on error
}
// Auto-commit on successful return (2xx status)
return ctx.Api.Created(user)
}
// Multiple transactions (finalized in reverse order - LIFO)
ctx.BeginTransaction("postgres-primary")
ctx.BeginTransaction("postgres-replica")
// ... operations ...
// Auto-finalize: replica first, then primary
Transaction Rules:
- ✅ Auto-commit on 2xx status (200, 201, etc.)
- ✅ Auto-rollback on error or 4xx/5xx status
- ✅ Multiple transactions supported (LIFO finalization)
- ⚠️ Manual commit/rollback only safe in single handler (no nested calls)
- ✅ Context propagates to all repository calls
Choosing the Right Layer
| Scenario | Layer | Example |
|---|---|---|
| Standard REST API | Layer 4: ctx.Api |
ctx.Api.Ok(user) |
| Custom status codes | Layer 3: ctx.Resp |
ctx.Resp.WithStatus(409).Json(data) |
| Streaming/File download | Layer 3: ctx.Resp |
ctx.Resp.Stream(...) |
| HTML/Text response | Layer 3: ctx.Resp |
ctx.Resp.Html(...) |
| Raw HTTP control | Layer 1: ctx.R/ctx.W |
Direct HTTP calls |
| Request data extraction | Layer 2: ctx.Req |
ctx.Req.QueryParam() |
| Get client IP | Layer 2: Helper | utils.ClientIP(ctx.R) |
| Database context | Layer 2: ctx.R |
ctx.R.Context() |
Quick Decision Tree
Is it a REST API?
↓ Yes
Use ctx.Api (Layer 4)
✓ Standard response format
✓ Automatic error handling
✓ Built-in validation error formatting
↓ No
Do you need custom response format?
↓ Yes
Use ctx.Resp (Layer 3)
✓ Full control over status & headers
✓ Multiple content types
✓ Streaming support
↓ No
Use standard Go HTTP
✓ ctx.R for request
✓ ctx.W for response
Complete Example: Multi-Layer Usage
// @Route "POST /{tenantId}/users"
func (h *UserHandler) CreateUser(
ctx *request.Context,
tenantId string,
req *CreateUserRequest,
) error {
// Layer 2: Extract client IP using helper
import "github.com/primadi/lokstra/common/utils"
clientIP := utils.ClientIP(ctx.R)
// Layer 2: Extract other headers
userAgent := ctx.Req.HeaderParam("User-Agent", "unknown")
apiKey := ctx.Req.HeaderParam("X-API-Key", "")
// Validate API key
if apiKey == "" {
return ctx.Api.Unauthorized("missing API key") // Layer 4
}
// Layer 1: Use standard context for database timeout
dbCtx, cancel := context.WithTimeout(ctx.R.Context(), 5*time.Second)
defer cancel()
// Create transaction
ctx.BeginTransaction("postgres-db")
// Create user with metadata
user := &User{
TenantID: tenantId,
Name: req.Name,
Email: req.Email,
CreatedBy: apiKey,
ClientIP: clientIP,
}
if err := h.repo.Create(ctx, user); err != nil {
// If business logic error, use custom layer 3
if errors.Is(err, ErrEmailExists) {
return ctx.Resp.WithStatus(http.StatusConflict).Json(map[string]string{
"error": "email already exists",
"code": "DUPLICATE_EMAIL",
})
}
// Generic error - use layer 4
return ctx.Api.InternalError("failed to create user")
}
// Success - layer 4 opinionated response
return ctx.Api.Created(user, "User created successfully")
// Transaction auto-commits because status is 201 (2xx)
}
// Service layer example
func (s *AuthService) Register(
ctx *request.Context, // Can be used as context.Context
tenantID string,
params *RegisterRequest,
) (*RegisterResponse, error) {
// Pass ctx directly to repository (it embeds context.Context)
existingUser, err := s.repo.FindByUsername(ctx, params.Username)
if err != nil {
return nil, err
}
if existingUser != nil {
return nil, ErrUsernameTaken
}
// ... more service logic ...
return &RegisterResponse{...}, nil
}
Service Implementation Best Practices
When implementing @Service annotated services that receive request.Context:
// @Service "user-service"
type UserService struct {
// @Inject "user-repository"
repo UserRepository
}
// Service method receiving request.Context from handler
func (s *UserService) Create(ctx *request.Context, req *CreateUserRequest) (*User, error) {
// Pass ctx directly - it embeds context.Context
// Repository expects context.Context
user := &User{Name: req.Name, Email: req.Email}
if err := s.repo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// Repository interface (uses standard context.Context)
type UserRepository interface {
Create(ctx context.Context, user *User) error
FindByID(ctx context.Context, id string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
}
// Repository implementation
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) Create(ctx context.Context, user *User) error {
// ctx can be passed directly to database queries
// Works with both context.Context and request.Context (embedding)
return r.db.ExecContext(ctx, "INSERT INTO users ...", user.Name)
}
Key Points:
- ✅
request.Contextembedscontext.Context- passes anywhere context is expected - ✅ Repositories should use
context.Context, notrequest.Context - ✅ Services can accept either
request.Contextorcontext.Context - ✅ Pass
ctxdirectly to repositories - no need forctx.R.Context() - ✅ Transaction context automatically propagated through embedded context
Error Handling Patterns
1. Automatic Error Response (Return error)
Framework automatically converts errors to 500 Internal Server Error:
// @Route "GET /{id}"
func (h *UserHandler) GetByID(id string) (*domain.User, error) {
if id == "" {
// Returns 500 with error message
return nil, fmt.Errorf("id required")
}
user, err := h.UserRepo.GetByID(id)
if err != nil {
// Returns 500 with error message
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}
Use Case: Simple error handling, when 500 is acceptable for all errors
2. Custom Error Responses (With Context)
Use request.Context to return specific HTTP status codes:
// @Route "GET /{id}"
func (h *UserHandler) GetByID(ctx *request.Context, id string) error {
// Validation error - 400
if id == "" {
return ctx.Api.BadRequest("id is required")
}
// Resource not found - 404
user, err := h.UserRepo.GetByID(id)
if err == sql.ErrNoRows {
return ctx.Api.NotFound(fmt.Sprintf("user %s not found", id))
}
if err != nil {
// Database error - 500
return ctx.Api.InternalServerError("database error")
}
// Success - 200
return ctx.Api.Ok(user)
}
3. Domain-Specific Errors
Create custom error types for better error handling:
// domain/errors.go
type UserError struct {
Code string
Message string
Status int
}
func (e *UserError) Error() string {
return e.Message
}
var (
ErrUserNotFound = &UserError{"USER_NOT_FOUND", "user not found", 404}
ErrInvalidEmail = &UserError{"INVALID_EMAIL", "invalid email format", 400}
ErrDuplicateEmail = &UserError{"DUPLICATE_EMAIL", "email already exists", 409}
ErrUnauthorized = &UserError{"UNAUTHORIZED", "unauthorized access", 401}
)
// Handler
// @Route "POST /"
func (h *UserHandler) Create(ctx *request.Context, req *domain.CreateUserRequest) error {
// Check for duplicate
existing, _ := h.UserRepo.GetByEmail(req.Email)
if existing != nil {
return ctx.Api.Conflict("email already exists")
}
user := &domain.User{
Name: req.Name,
Email: req.Email,
}
created, err := h.UserRepo.Create(user)
if err != nil {
return ctx.Api.InternalServerError("failed to create user")
}
return ctx.Api.Created(created)
}
4. Validation Errors
Validation errors are automatically handled by framework:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"omitempty,min=18,max=150"`
Password string `json:"password" validate:"required,min=8,containsany=!@#$%"`
}
// @Route "POST /"
func (h *UserHandler) Create(req *CreateUserRequest) (*domain.User, error) {
// If validation fails, framework returns 400 Bad Request with validation errors
// This code only runs if validation passes
user := &domain.User{
Name: req.Name,
Email: req.Email,
}
return h.UserRepo.Create(user)
}
Validation Error Response Format:
{
"error": "validation failed",
"details": [
{
"field": "email",
"message": "invalid email format"
},
{
"field": "password",
"message": "must be at least 8 characters"
}
]
}
Middleware Configuration
Global Handler Middlewares
Applied to all routes in the handler:
// @Handler name="user-handler", prefix="/api/users", middlewares=["auth", "logger"]
type UserHandler struct {
// @Inject "user-repository"
UserRepo domain.UserRepository
}
// All routes below inherit ["auth", "logger"] middlewares
// @Route "GET /{id}"
func (h *UserHandler) GetByID(id string) (*domain.User, error) { }
// @Route "POST /"
func (h *UserHandler) Create(req *domain.CreateUserRequest) (*domain.User, error) { }
Route-Specific Middlewares
Add additional middlewares to specific routes:
// @Handler name="user-handler", prefix="/api/users", middlewares=["logger"]
type UserHandler struct {
// @Inject "user-repository"
UserRepo domain.UserRepository
}
// Only logger middleware
// @Route "GET /{id}"
func (h *UserHandler) GetByID(id string) (*domain.User, error) { }
// logger + auth middlewares
// @Route "POST /", middlewares=["auth"]
func (h *UserHandler) Create(req *domain.CreateUserRequest) (*domain.User, error) { }
// logger + auth + admin middlewares
// @Route "DELETE /{id}", middlewares=["auth", "admin"]
func (h *UserHandler) Delete(id string) error { }
Middleware Registration in main.go
Middlewares must be registered before bootstrap:
package main
import (
"github.com/primadi/lokstra/lokstra_init"
"github.com/primadi/lokstra/middleware/recovery"
"github.com/primadi/lokstra/middleware/request_logger"
"myapp/middleware/auth"
"myapp/middleware/admin"
_ "myapp/modules/user/application" // Import handlers
)
func main() {
// Register middlewares
middlewares := map[string]func(http.Handler) http.Handler{
"recovery": recovery.Middleware(nil),
"logger": request_logger.Middleware(nil),
"auth": auth.Middleware(),
"admin": admin.AdminMiddleware(),
"rate-limit": ratelimit.Middleware(100, time.Minute),
}
// Bootstrap with middlewares
lokstra_init.BootstrapAndRun(
lokstra_init.WithConfigFiles("configs/config.yaml"),
lokstra_init.WithMiddlewares(middlewares),
)
}
Custom Middleware Example
// middleware/auth/auth.go
package auth
import (
"net/http"
"strings"
"github.com/primadi/lokstra/core/request"
)
func Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get token from header
token := r.Header.Get("Authorization")
if token == "" {
ctx := request.NewContext(w, r)
ctx.Api.Unauthorized("missing authorization token")
return
}
// Validate token
token = strings.TrimPrefix(token, "Bearer ")
if !validateToken(token) {
ctx := request.NewContext(w, r)
ctx.Api.Unauthorized("invalid token")
return
}
// Add user to context
userID := extractUserID(token)
r = r.WithContext(context.WithValue(r.Context(), "user_id", userID))
next.ServeHTTP(w, r)
})
}
}
Middleware Order:
- Handler global middlewares
- Route-specific middlewares
- Handler function
Example: ["logger"] (handler) + ["auth", "admin"] (route) = ["logger", "auth", "admin"]
Project Structure & Organization
Recommended Structure (DDD Bounded Context)
modules/
├── user/
│ ├── domain/ # Business logic & interfaces
│ │ ├── user.go # Domain model
│ │ ├── user_dto.go # Request/Response DTOs
│ │ ├── user_repository.go # Repository interface
│ │ └── user_service.go # Service interface (optional)
│ ├── application/ # Handlers (@Handler)
│ │ ├── user_handler.go # Main CRUD handler
│ │ ├── user_admin_handler.go # Admin operations (optional)
│ │ └── zz_generated.lokstra.go # Auto-generated
│ └── infrastructure/ # Data access (@Service)
│ ├── postgres_user_repo.go # PostgreSQL implementation
│ └── zz_generated.lokstra.go # Auto-generated
├── order/
│ ├── domain/
│ │ ├── order.go
│ │ ├── order_dto.go
│ │ └── order_repository.go
│ ├── application/
│ │ ├── order_handler.go
│ │ └── zz_generated.lokstra.go
│ └── infrastructure/
│ ├── postgres_order_repo.go
│ └── zz_generated.lokstra.go
└── ...
Handler File Organization Options
Option 1: Single Handler Per Module (Recommended)
Best for small to medium modules with 5-15 endpoints:
modules/user/application/
├── user_handler.go # All user CRUD operations
└── zz_generated.lokstra.go # Auto-generated
user_handler.go:
// @Handler name="user-handler", prefix="/api/users"
type UserHandler struct {
// @Inject "user-repository"
UserRepo domain.UserRepository
}
// @Route "GET /"
func (h *UserHandler) List() ([]*domain.User, error) { }
// @Route "GET /{id}"
func (h *UserHandler) GetByID(id string) (*domain.User, error) { }
// @Route "POST /"
func (h *UserHandler) Create(req *domain.CreateUserRequest) (*domain.User, error) { }
// @Route "PUT /{id}"
func (h *UserHandler) Update(id string, req *domain.UpdateUserRequest) (*domain.User, error) { }
// @Route "DELETE /{id}"
func (h *UserHandler) Delete(id string) error { }
Option 2: Multiple Handlers (Public/Admin Separation)
Best for modules with different access levels:
modules/user/application/
├── user_public_handler.go # Public endpoints
├── user_admin_handler.go # Admin endpoints
└── zz_generated.lokstra.go
user_public_handler.go:
// @Handler name="user-public-handler", prefix="/api/users"
type UserPublicHandler struct {
// @Inject "user-repository"
UserRepo domain.UserRepository
}
// @Route "GET /"
func (h *UserPublicHandler) List() ([]*domain.User, error) { }
// @Route "GET /{id}"
func (h *UserPublicHandler) GetByID(id string) (*domain.User, error) { }
user_admin_handler.go:
// @Handler name="user-admin-handler", prefix="/api/admin/users", middlewares=["auth", "admin"]
type UserAdminHandler struct {
// @Inject "user-repository"
UserRepo domain.UserRepository
}
// @Route "POST /"
func (h *UserAdminHandler) Create(req *domain.CreateUserRequest) (*domain.User, error) { }
// @Route "PUT /{id}"
func (h *UserAdminHandler) Update(id string, req *domain.UpdateUserRequest) (*domain.User, error) { }
// @Route "DELETE /{id}"
func (h *UserAdminHandler) Delete(id string) error { }
// @Route "POST /{id}/suspend"
func (h *UserAdminHandler) Suspend(id string) error { }
Option 3: Feature-Based Handlers
Best for large modules with distinct feature sets:
modules/user/application/
├── user_crud_handler.go # Basic CRUD
├── user_auth_handler.go # Login/logout/password
├── user_profile_handler.go # Profile management
└── zz_generated.lokstra.go
Naming Conventions
**Handler Nam
…(truncated)