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()— theCoreControllerinterface 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.
// 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.
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.
// 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:
ControllerDAO/DaoCallbackObserverAPIHandlerImpl
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.