| name | jframe-module-dev |
| description | Implement, wire, and test code inside an existing jframe module. Invoke this skill when the user wants to: write handler/service/dao/model layer code, implement CRUD or business logic, register HTTP routes with jin (binding.JSON, binding.Query, render.JSON, DI-injected handler functions), register gRPC endpoints via gateway, use stdao generic DAO for GORM queries, use the settings system for dynamic config, wire module layers in mod.go Load(), write tests for handlers or services, or troubleshoot DI wiring issues (hub.Map/Load/Invoke). Trigger on phrases like 'implement X in module', 'write handler for', 'add API endpoint', 'use stdao', 'binding.JSON', 'DI inject', or any request to write/modify code within an existing mod/ directory. Do NOT use this skill for designing new modules from scratch or scaffolding (use jframe-module-design instead), or for framework-level changes to kernel/core. |
jframe Module Development
This skill guides you through implementing the internal code of a jframe module — the handler, service, dao, and model layers, plus DI wiring, route registration, and testing.
Prerequisite: The module skeleton should already exist (created via jframe create -n <name> or manually). If not, use the jframe-module-design skill first — it walks through requirements gathering, lifecycle phase selection, config struct design, DI planning, scaffolding, and module registration.
Module internal architecture
jframe modules follow a layered architecture convention:
mod/<name>/
├── mod.go # Lifecycle entry point — DI wiring, route registration
├── handler/ # HTTP/gRPC request handling — parse input, call service, format response
├── service/ # Business logic — orchestrates dao calls, enforces rules
├── dao/ # Data access — GORM queries, wraps stdao
├── model/ # Data models — GORM structs, DTOs
└── e/ # Error codes — domain-specific error definitions
Data flow: handler → service → dao → model
The handler receives requests, calls service methods with domain types, the service implements business rules and calls dao for persistence, and the dao talks to the database through GORM. This separation keeps each layer testable and replaceable.
Implementing the model layer
Models define your data structures. For database-backed models, embed stdao.Model which provides ULID primary key, timestamps, and soft delete:
package model
import "github.com/juanjiTech/jframe/pkg/stdao"
type User struct {
stdao.Model
Username string `json:"username" gorm:"uniqueIndex;size:64"`
Email string `json:"email" gorm:"size:256"`
Status int `json:"status" gorm:"default:1"`
}
stdao.Model gives you:
type Model struct {
ID string `json:"id" gorm:"primaryKey;size:26"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *gorm.DeletedAt `json:"deletedAt" gorm:"index"`
}
For request/response DTOs, define plain structs (no GORM tags):
type CreateUserReq struct {
Username string `json:"username"`
Email string `json:"email"`
}
type UserResp struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
}
Implementing the DAO layer
Use stdao.Std[T] as a generic base. It provides Create, List, Update, Delete, and context-based transaction propagation out of the box. Note that Delete() returns *gorm.DB (not error), so check .Error on the result:
package dao
import (
"context"
"github.com/juanjiTech/jframe/pkg/stdao"
"github.com/juanjiTech/jframe/mod/<name>/model"
"gorm.io/gorm"
)
type UserDao struct {
stdao.Std[*model.User]
}
func NewUserDao(db *gorm.DB) (*UserDao, error) {
d := &UserDao{}
if err := d.Init(db); err != nil {
return nil, err
}
return d, nil
}
func (d *UserDao) GetByUsername(ctx context.Context, username string) (*model.User, error) {
var user model.User
err := d.GetTxFromCtx(ctx).WithContext(ctx).Where("username = ?", username).First(&user).Error
return &user, err
}
Transaction propagation: stdao uses context to carry transactions. Any dao method that calls GetTxFromCtx(ctx) will automatically participate in an active transaction if one exists in the context:
tx := d.Begin()
ctx = stdao.SetTxToCtx(ctx, tx)
err1 := userDao.Create(ctx, user)
err2 := profileDao.Create(ctx, profile)
if err1 != nil || err2 != nil {
tx.Rollback()
} else {
tx.Commit()
}
Implementing the service layer
Services contain business logic. They receive dao instances (injected) and operate on domain types:
package service
import (
"context"
"github.com/juanjiTech/jframe/mod/<name>/dao"
"github.com/juanjiTech/jframe/mod/<name>/model"
"github.com/pkg/errors"
)
type UserService struct {
userDao *dao.UserDao
}
func NewUserService(userDao *dao.UserDao) *UserService {
return &UserService{userDao: userDao}
}
func (s *UserService) CreateUser(ctx context.Context, username, email string) (*model.User, error) {
existing, err := s.userDao.GetByUsername(ctx, username)
if err == nil && existing.ID != "" {
return nil, errors.New("username already exists")
}
user := &model.User{
Username: username,
Email: email,
}
if err := s.userDao.Create(ctx, user); err != nil {
return nil, errors.Wrap(err, "failed to create user")
}
return user, nil
}
Implementing the handler layer
jframe uses jin (a gin fork that removes the binding package and replaces it with dependency injection). This is the core design philosophy:
Handler = pure function. Input parameters are auto-injected request data. Return values are auto-registered as response data.
jin's key differences from gin:
HandlerFunc is interface{} — any function signature works, not just func(*Context)
Context.Next() calls handlers via inject.Invoke() — parameters are auto-resolved from DI
- Handler return values are auto-registered by return type —
c.Set(fnType.Out(i), val) (context.go:82) stores each return value keyed by its declared return type
jin.Context embeds inject.Injector — each request has its own DI scope
This means you can build a middleware pipeline where:
- Binding middleware parses the request →
c.Map(parsedStruct) into request-scoped DI
- Handler is a pure function that receives the parsed struct as a parameter and returns a response struct
- Render middleware reads the returned response struct from DI and writes HTTP response
The pure function handler pattern
func (h *UserHandler) createUser(req model.CreateUserReq) (*model.UserResp, error) {
user, err := h.svc.CreateUser(context.Background(), req.Username, req.Email)
if err != nil {
return nil, err
}
return &model.UserResp{ID: user.ID, Username: user.Username}, nil
}
Binding middleware — parse request into DI
jin provides official binding middleware at jin/middleware/binding. The binding package is the bridge between raw HTTP requests and jin's DI system — it transforms unstructured request data into typed Go structs and registers them into the request-scoped DI container via ctx.Map().
binding.JSON[T](T{}) — JSON body binding:
- Parses
r.Body using json.Decoder with UseNumber() for precision
- Uses
io.TeeReader to preserve the body — downstream handlers can still read r.Body
- On success:
ctx.Map(t) registers the parsed struct into DI
- On decode error (except EOF):
ctx.Error(err) + ctx.Abort()
- On empty body (EOF): silently continues without mapping (handler can check)
import "github.com/juanjiTech/jin/middleware/binding"
engine.POST("/users", binding.JSON(model.CreateUserReq{}), handler.createUser)
binding.Query[T](T{}) — URL query params binding:
- Uses
query:"key" struct tag (not form:"key" like gin)
- Supports: string, int/int8-64, uint/uint8-64, float32/64, bool, slices, nested structs
- Nested structs use dot notation:
?parent.child=value (format configurable via binding.DefaultQueryFormat)
- Slices use comma separation:
?ids=1,2,3
- Bool accepts:
true, 1, True → true; everything else → false
- Anonymous fields are skipped, fields without
query tag are skipped
type ListQuery struct {
Page int `query:"page"`
PageSize int `query:"page_size"`
Search string `query:"search"`
Tags []string `query:"tags"`
Filter struct {
Status int `query:"status"`
Sort string `query:"sort"`
} `query:"filter"`
}
engine.GET("/users", binding.Query(ListQuery{}), handler.listUsers)
You can also write custom binding middleware for other sources (headers, path params, etc.):
func BindPathParam(key string) jin.HandlerFunc {
return func(c *jin.Context) {
val, _ := c.Params.Get(key)
c.Map(val)
}
}
Route registration with middleware chain
import "github.com/juanjiTech/jin/middleware/binding"
func (h *UserHandler) RegisterRoutes(g *jin.RouterGroup) {
g.POST("/users", binding.JSON(model.CreateUserReq{}), h.createUser)
g.GET("/users", binding.Query(model.ListUserQuery{}), h.listUsers)
g.GET("/users/:id", h.getUser)
}
Handler function signatures
jin supports multiple handler signatures via inject.Invoke:
func(c *jin.Context) { ... }
func(w http.ResponseWriter, r *http.Request) { ... }
func(req model.CreateUserReq) (*model.UserResp, error) { ... }
func(req model.CreateUserReq, c *jin.Context) { ... }
func(db *gorm.DB, c *jin.Context) { ... }
Accessing request data directly
For handlers that don't use the binding middleware pattern:
id, _ := c.Params.Get("id")
page := c.Request.URL.Query().Get("page")
auth := c.Request.Header.Get("Authorization")
var req model.CreateUserReq
json.NewDecoder(c.Request.Body).Decode(&req)
Response rendering
jin provides a render package (no c.JSON shorthand — use c.Render):
import "github.com/juanjiTech/jin/render"
c.Render(http.StatusOK, render.JSON{Data: user})
c.Render(http.StatusOK, render.IndentedJSON{Data: user})
c.Writer.Header().Set("Content-Type", "application/json")
json.NewEncoder(c.Writer).Encode(user)
Complete handler example
package handler
import (
"net/http"
"github.com/juanjiTech/jin"
"github.com/juanjiTech/jin/middleware/binding"
"github.com/juanjiTech/jin/render"
"github.com/juanjiTech/jframe/mod/<name>/model"
"github.com/juanjiTech/jframe/mod/<name>/service"
"github.com/juanjiTech/jframe/mod/<name>/e"
"errors"
)
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) RegisterRoutes(g *jin.RouterGroup) {
g.POST("/users", binding.JSON(model.CreateUserReq{}), h.createUser)
g.GET("/users", binding.Query(model.ListUserQuery{}), h.listUsers)
g.GET("/users/:id", h.getUser)
}
func (h *UserHandler) createUser(req model.CreateUserReq, c *jin.Context) {
user, err := h.svc.CreateUser(c.Request.Context(), req.Username, req.Email)
if err != nil {
c.Render(http.StatusInternalServerError, render.JSON{Data: map[string]string{"error": err.Error()}})
return
}
c.Render(http.StatusOK, render.JSON{Data: user})
}
func (h *UserHandler) listUsers(query model.ListUserQuery, c *jin.Context) {
users, err := h.svc.ListUsers(c.Request.Context(), query.Page, query.PageSize)
if err != nil {
c.Render(http.StatusInternalServerError, render.JSON{Data: map[string]string{"error": err.Error()}})
return
}
c.Render(http.StatusOK, render.JSON{Data: users})
}
func (h *UserHandler) getUser(c *jin.Context) {
id, _ := c.Params.Get("id")
user, err := h.svc.GetUser(c.Request.Context(), id)
if err != nil {
if errors.Is(err, e.ErrUserNotFound) {
c.Render(http.StatusNotFound, render.JSON{Data: map[string]string{"error": err.Error()}})
return
}
c.Render(http.StatusInternalServerError, render.JSON{Data: map[string]string{"error": "internal error"}})
return
}
c.Render(http.StatusOK, render.JSON{Data: user})
}
Wiring everything in mod.go
The module's lifecycle methods connect all layers through the DI container:
func (m *Mod) Load(hub *kernel.Hub) error {
var j *jin.Engine
if err := hub.Load(&j); err != nil {
return errors.New("can't load jin.Engine from kernel")
}
var db *gorm.DB
if err := hub.Load(&db); err != nil {
return errors.New("can't load gorm.DB from kernel")
}
userDao, err := dao.NewUserDao(db)
if err != nil {
return errors.Wrap(err, "failed to init user dao")
}
userSvc := service.NewUserService(userDao)
userHandler := handler.NewUserHandler(userSvc)
g := j.Group("/api/<name>")
userHandler.RegisterRoutes(g)
return nil
}
The convention is: Load phase is where business modules wire their layers and register routes, because by this point all infrastructure modules (DB, Redis, HTTP engine) have already Mapped their resources in PreInit.
DI container, gRPC gateway, and settings
For detailed reference on these topics, read references/advanced.md. Quick summary:
- DI Container:
hub.Map(&val) to register, hub.Load(&var) to retrieve (always check error), hub.Invoke(func) for one-off injection. One value per concrete type — wrap in distinct structs if you need multiples.
- gRPC Gateway: Register in PostInit via
hub.Load(&gw) then gw.Register(...). Routes mount at /gapi/*.
- Settings:
settings.NewItem[T] for dynamic, DB-backed config with cache + env override.
Error handling patterns
Define domain errors in the e/ package:
package e
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateUser = errors.New("duplicate username")
ErrInvalidInput = errors.New("invalid input")
)
In handlers, map domain errors to HTTP status codes:
user, err := h.svc.GetUser(ctx, id)
if err != nil {
if errors.Is(err, e.ErrUserNotFound) {
c.Render(http.StatusNotFound, render.JSON{Data: map[string]string{"error": err.Error()}})
return
}
c.Render(http.StatusInternalServerError, render.JSON{Data: map[string]string{"error": "internal error"}})
return
}
Testing
For unit tests, integration tests, handler tests, and E2E verification patterns, read references/advanced.md (Testing Strategies section). Key points:
- Service tests: Mock the dao layer to test business rules in isolation
- DAO tests: Use a real test DB (SQLite/MySQL), call
dao.NewXxxDao(db) directly
- Handler tests: Create
jin.New(), register routes with binding middleware, use httptest.NewRecorder() + engine.ServeHTTP(w, req)
- E2E:
go build ./... → go vet ./... → start server → curl endpoints
Common pitfalls
- Forgetting
defer wg.Done() in Stop() — causes shutdown to hang forever.
- Mapping non-pointer types —
hub.Map(db) vs hub.Map(&db). Always use &.
- Loading in PreInit — other modules haven't run PreInit yet, so their dependencies aren't available. Load in Init or later.
- Duplicate type in DI — only one value per type. If you need two
*gorm.DB (read/write replicas), wrap them: type ReadDB struct{ *gorm.DB }.
- Missing mapstructure tags — config fields won't populate from env vars without
mapstructure:"fieldName" tags.
- Not checking Load errors — a silent nil pointer will panic later. Always handle the error from
hub.Load().
- Blocking in Start() — Start runs in a goroutine. If your Start doesn't block (e.g., it's just setup), you probably want Load instead. If it does block (e.g.,
http.Serve()), that's correct.
Checklist before completing module implementation