JSON API Route
Verified Scenario
We test this scenario against the current GoForj templates, including the generated files, wiring changes, commands, and verification steps.
This scenario adds a GET /api/v1/users/:id endpoint to a generated GoForj App.
The endpoint is intentionally small. It establishes the normal shape for application features: start from the make command, keep the controller thin, put behavior behind a service, register providers explicitly, and verify the route through the generated runtime.
What You Will Build
internal/users.Serviceowns user lookup behavior.internal/users.Controllertranslates HTTP into a service call.forj make:controller userscreates the controller wiring and route registration.wire/appSetprovides the service.forj route:listshows the registered endpoint.
Prerequisites
Start from a generated GoForj App with HTTP enabled.
Golden Path State
Before this scenario, the App has the generated HTTP runtime but no application-owned user feature.
After this scenario, the App has one tested GET /api/v1/users/:id route, a thin controller, a service boundary, Wire providers, and route registration that appears in route:list.
Files
This scenario edits or creates:
Users feature
internal/users/service.go
internal/users/service_test.go
internal/users/controller.goHTTP registration
wire/inject_http_controllers.go
internal/router/routes_registry.goApp wiring
wire/inject_app_services.goStep 1: Scaffold The Controller
Start with the real make command. It creates internal/users/controller.go, wires the controller constructor into wire/inject_http_controllers.go, and adds the controller routes to internal/router/routes_registry.go.
forj make:controller usersStep 2: Add The Service
Create internal/users/service.go.
The service owns application behavior. This first version is intentionally simple and keeps persistence out of the HTTP boundary.
Create or replace internal/users/service.go:
package users
import (
"context"
"errors"
)
var ErrUserNotFound = errors.New("user not found")
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type Service struct{}
func NewService() *Service {
return &Service{}
}
func (s *Service) Find(ctx context.Context, id string) (User, error) {
if id == "" {
return User{}, ErrUserNotFound
}
return User{
ID: id,
Name: "Ada Lovelace",
Email: "ada@example.test",
}, nil
}Step 3: Replace The Starter Controller
Replace internal/users/controller.go.
Keep the generated controller registration, but replace the starter handler with a service-backed GET /users/:id route.
The controller only reads HTTP input, calls the service, and writes the HTTP response. It does not own persistence, cache behavior, queue dispatch, or infrastructure setup.
Create or replace internal/users/controller.go:
package users
import (
"errors"
"net/http"
"github.com/goforj/web"
)
type Controller struct {
service *Service
}
func NewController(service *Service) *Controller {
return &Controller{service: service}
}
func (c *Controller) Routes() []web.Route {
return []web.Route{
web.NewRoute(http.MethodGet, "/users/:id", c.Show),
}
}
func (c *Controller) Show(ctx web.Context) error {
user, err := c.service.Find(ctx.Context(), ctx.Param("id"))
if errors.Is(err, ErrUserNotFound) {
return ctx.JSON(http.StatusNotFound, map[string]string{
"error": "user not found",
})
}
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, user)
}Step 4: Provide The Service
Open wire/inject_app_services.go.
Wire can already construct the controller after the make command, but the controller now needs *users.Service. Add the users package to the imports, using your App module path.
Update wire/inject_app_services.go so it includes:
"your/module/internal/makecmd"
"your/module/internal/users"Step 5: Add The Service Provider
Add users.NewService to appSet.
Service is now part of the compiled App dependency graph, and Wire can construct the controller because appSet provides *users.Service.
Update wire/inject_app_services.go so it includes:
users.NewService,
app.NewLifecycleRegistry,Step 6: Add A Service Test
Create internal/users/service_test.go.
The service test does not start HTTP. It proves the business behavior directly.
Create or replace internal/users/service_test.go:
package users
import (
"context"
"testing"
)
func TestServiceFindsUser(t *testing.T) {
service := NewService()
user, err := service.Find(context.Background(), "42")
if err != nil {
t.Fatalf("find user: %v", err)
}
if user.ID != "42" {
t.Fatalf("user id = %q, want %q", user.ID, "42")
}
}
func TestServiceRejectsEmptyID(t *testing.T) {
service := NewService()
_, err := service.Find(context.Background(), "")
if err == nil {
t.Fatal("expected error")
}
}Build And Verify
forj buildgo test ./...forj route:listExpected output includes:
/api/v1/users/:id
Try The Route
Run the HTTP server:
forj apiRequest the endpoint:
curl http://localhost:3000/api/v1/users/42Expected response:
{"id":"42","name":"Ada Lovelace","email":"ada@example.test"}Operations
Operational notes:
route:listshows it after registration.- HTTP request logs include requests to it when access logging is enabled.
- HTTP metrics include it when metrics are enabled.
- HTTP inspects can show request and response details when inspect capture is enabled.
- Lighthouse can display route and runtime information when Lighthouse is enabled.
Common Mistakes
Common mistakes
- Do not put user lookup logic in
Show. - Do not register routes directly in the HTTP server package.
- Do not edit
wire/wire_gen.goby hand. - Do not skip
forj buildafter changing Wire providers. - Do not import the underlying HTTP engine in normal App controllers.
Next Steps
- Next, extend this feature with a repository and named cache resource in Cached User Profile.
- Controllers explains handler structure.
- Wiring Recipes shows where providers belong.
