Skip to content

Controllers

Controllers hold the business logic of a Fountain service. Each business domain gets its own package under biz/core/<domain>/, and each controller implements the f_core.CoreController interface so the framework can discover, install and wire it during startup. ftkit verify enforces the conventions described below.

Anatomy of a controller

Every domain under biz/core/ declares its controller in a controller.go file. That file must implement f_core.CoreController — at minimum the InstallController() and RegisterCallback() methods, optionally AfterInstalledDone().

controller.go is reserved for the controller plumbing only. The verifier (verifyControllerFileContents) allows just these declarations in it:

  • InstallController(), RegisterCallback(), AfterInstalledDone() — the CoreController interface methods.
  • init() — used to register the controller (see below).
  • a single Get<Name>ControllerInstance() accessor.

Any other business method must be moved into a separate file in the same package. Keeping controller.go thin makes the lifecycle wiring easy to read and review.

go
// biz/core/orders/controller.go
package orders

type OrdersController struct {
    // injected dependencies (DAOs, other controllers) — no data structs
}

func (c *OrdersController) InstallController() error { /* wire DAOs, deps */ }
func (c *OrdersController) RegisterCallback(cb any)  { /* observe events */ }

Registration

Controllers self-register through their package's init() function, which calls f_core.RegisterCoreController(...). The package becomes active only when it is blank-imported (the biz/biz.go file blank-imports every biz/core/* package for this reason). ftkit verify (verifyControllers) checks that each domain calls RegisterCoreController.

The instance itself is a singleton guarded by sync.Once, so registration and the instance accessor are safe to call repeatedly without creating duplicates. The verifier warns when a controller does not use sync.Once.

go
var (
    once     sync.Once
    instance *OrdersController
)

func GetOrdersControllerInstance() *OrdersController {
    once.Do(func() { instance = &OrdersController{} })
    return instance
}

func init() {
    f_core.RegisterCoreController(GetOrdersControllerInstance())
}

The framework calls InstallController() after all dependencies (logger, caches, databases, DAOs, clients, servers) are installed, and uses RegisterCallback() to wire cross-controller observer events.

Error returns

Exported controller methods must return *froto.RpcError, never the plain error interface. ftkit verify (verifyControllerErrors) inspects every exported function and method in biz/core/*/ and flags any that declare an error return type. A typed *froto.RpcError carries the structured error code and message the transport layer needs to build a consistent RPC/HTTP response.

go
// Correct — typed RPC error
func (c *OrdersController) GetOrder(id string) (*models.Order, *froto.RpcError) {
    // ...
}

// Rejected by ftkit verify — plain error return
func (c *OrdersController) GetOrder(id string) (*models.Order, error) {
    // ...
}

A related rule (verifyNoInterfaceParamsInControllers): outside of controller.go, controller functions must not take interface{} / any parameters. controller.go is exempt because RegisterCallback(cb any) is part of the CoreController interface.

No data structs here

Controller files describe behaviour, not data. Data types — request/response payloads, domain entities, persistence shapes — belong in biz/dal/do or biz/dal/models. ftkit verify (verifyNoDataStructs) parses every controller file and rejects any struct whose name does not end in one of these allowed suffixes:

  • Controller
  • DAO / Dao
  • Callback
  • Observer
  • API
  • Handler
  • Impl

Anything else (for example type Order struct { ... }) must be defined under biz/dal/do or biz/dal/models instead. This keeps the controller layer focused on orchestration and the data layer as the single source of truth for shapes.