Building Your First Feature
This tutorial walks you through adding a complete feature to the Wippidu app, touching every layer of the stack. You will learn the patterns used across the codebase by following a realistic example.
What we'll build: A simple "Feedback" endpoint where parents can submit feedback about the app.
Step 1: Add the model
Create or extend a GORM model in internal/model/. Models define the database schema.
// internal/model/feedback.go
package model
import "gorm.io/gorm"
type Feedback struct {
gorm.Model
UserID uint `gorm:"not null"`
User User `gorm:"foreignKey:UserID"`
Subject string `gorm:"size:200;not null"`
Body string `gorm:"type:text;not null"`
Rating int `gorm:"default:0"`
}
Register it in internal/model/db.go inside AutoMigrate():
db.AutoMigrate(
// ... existing models ...
&Feedback{},
)
Step 2: Add the controller
Create a controller in internal/controller/. Follow the dual-response pattern:
// internal/controller/feedback.go
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"your-module/internal/model"
)
func FeedbackForm(c *gin.Context) {
isJSON := shouldReturnJSON(c)
user := c.MustGet("User").(*model.User)
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"user_id": user.ID},
})
return
}
c.HTML(http.StatusOK, "feedback.html", gin.H{
"title": "Feedback",
"user": user,
"lang": c.GetString("lang"),
})
}
func FeedbackSubmit(c *gin.Context) {
isJSON := shouldReturnJSON(c)
user := c.MustGet("User").(*model.User)
subject := c.PostForm("subject")
body := c.PostForm("body")
if subject == "" || body == "" {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_INPUT",
"message": "Subject and body are required",
},
})
return
}
c.HTML(http.StatusBadRequest, "feedback.html", gin.H{
"title": "Feedback",
"user": user,
"lang": c.GetString("lang"),
"error": "Subject and body are required",
})
return
}
feedback := model.Feedback{
UserID: user.ID,
Subject: subject,
Body: body,
}
if err := model.DB.Create(&feedback).Error; err != nil {
// handle error...
return
}
if isJSON {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"id": feedback.ID},
})
return
}
c.HTML(http.StatusOK, "feedback.html", gin.H{
"title": "Feedback",
"user": user,
"lang": c.GetString("lang"),
"success": true,
})
}
Step 3: Register routes
Add routes in internal/route/auth.go:
// In MainRoutes() — HTML routes
auth.GET("/:lang/feedback", controller.FeedbackForm)
auth.POST("/:lang/feedback", controller.FeedbackSubmit)
// In APIRoutes() — JSON API routes
api.GET("/feedback", controller.FeedbackForm)
api.POST("/feedback", controller.FeedbackSubmit)
Step 4: Add i18n translations
Add keys to both internal/i18n/locales/en.json and de.json:
"feedback.title": {
"other": "Feedback"
},
"feedback.subject": {
"other": "Subject"
},
"feedback.body": {
"other": "Your feedback"
},
"feedback.submit": {
"other": "Submit"
},
"feedback.success": {
"other": "Thank you for your feedback!"
}
Step 5: Create the template
Create cmd/app-server/templates/pages/feedback.html:
{{ template "layout/head" . }}
<h1>{{ t .lang "feedback.title" }}</h1>
{{ if .success }}
<div class="alert alert-success">
{{ t .lang "feedback.success" }}
</div>
{{ end }}
{{ if .error }}
<div class="alert alert-error">{{ .error }}</div>
{{ end }}
<form method="POST" action="/{{ .lang }}/feedback">
<div class="form-group">
<label for="subject">{{ t .lang "feedback.subject" }}</label>
<input type="text" id="subject" name="subject" required>
</div>
<div class="form-group">
<label for="body">{{ t .lang "feedback.body" }}</label>
<textarea id="body" name="body" rows="5" required></textarea>
</div>
<button type="submit" class="btn btn-primary">
{{ t .lang "feedback.submit" }}
</button>
</form>
{{ template "layout/foot" . }}
Step 6: Write tests
Create internal/controller/feedback_test.go:
package controller
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"your-module/internal/model"
)
func TestFeedbackSubmit_Success(t *testing.T) {
db := SetupTestDBWithModels(t)
model.DB = db
user := CreateTestUser(t, db, "parent@example.com", "Parent")
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
form := url.Values{}
form.Add("subject", "Great app")
form.Add("body", "I love this app!")
req, _ := http.NewRequest(http.MethodPost, "/api/v1/feedback",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.Request = req
c.Set("User", user)
FeedbackSubmit(c)
assert.Equal(t, http.StatusOK, w.Code)
// Verify database
var feedback model.Feedback
err := db.First(&feedback).Error
require.NoError(t, err)
assert.Equal(t, "Great app", feedback.Subject)
assert.Equal(t, user.ID, feedback.UserID)
}
func TestFeedbackSubmit_MissingFields(t *testing.T) {
db := SetupTestDBWithModels(t)
model.DB = db
user := CreateTestUser(t, db, "parent@example.com", "Parent")
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
form := url.Values{}
form.Add("subject", "")
form.Add("body", "")
req, _ := http.NewRequest(http.MethodPost, "/api/v1/feedback",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.Request = req
c.Set("User", user)
FeedbackSubmit(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
Run the tests:
cd backend
go test ./internal/controller -run Feedback -v
Summary
Every new feature in Wippidu follows this pattern:
- Model — Define the data structure in
internal/model/ - Controller — Handle requests with
shouldReturnJSON()dual-response pattern - Routes — Register in
MainRoutes()(HTML) andAPIRoutes()(JSON) - i18n — Add translation keys to
de.jsonanden.json - Template — Create the HTML template in
templates/pages/ - Tests — Write tests covering success, validation, and authorization
Next steps
- Add an API Endpoint — Detailed checklist for new endpoints
- Write Tests — Testing patterns and helpers
- Architecture Overview — Understand the full system