Ordering Guarantees and Extension Resolvers Tighten a Go Admin Base Layer
This is a series blog. The author, a PHP full-stack engineer, will use AI tools (TRAE, claude code, codex, deepseek, Doubao, etc.) to learn the Go language from scratch and ultimately complete the open-source project ai-go-admin (github | gitee), documenting the entire process.
In the last installment, we worked on the "frontend remote dropdown input component." This installment will complete: further optimization of the base class.
Further Optimization of the Base Class
Strictly speaking, "base class" is not a concept in Go. In this blog, it is used to refer to the base controller, base service, and base repository.
Guaranteeing List Data Order
Many projects don't implement this, or are even unaware of the concept. It's actually a minor pitfall when using databases (including PostgreSQL and MySQL): when no ORDER BY clause is specified, the database does not guarantee ordering. It outputs rows in the order they are first found and does not guarantee the same data output order across multiple identical queries.
This means that for any list query, the developer must pass an ORDER BY clause. If you don't, the order you see is only temporary and may not be the same next time, which can be disastrous in scenarios like pagination.
In practice, we prioritize the sort field passed by the user. If none is passed, we default to reading the primary key field to guarantee order. Even if a sort field is passed, we still use the primary key field as a fallback (to prevent the loss of ordering guarantees when user-specified sort fields have duplicate values).
Base Controller Encapsulates the BuildSerOpts Method for Building Service Layer Option Data
For example, the Get method currently assembles service layer options like this:
entity, err := h.svc.Get(c, service.Options{
Omit: h.cfg.Omit.Get,
Select: h.cfg.Select.Get,
PrimaryKey: c.Param("pk"),
})
After adding the BuildSerOpts method, it can be simplified to:
opts := h.BuildSerOpts(c, "Get", Request{
PrimaryKeyValue: c.Param("pk"),
})
entity, err := h.svc.Get(c, opts)
That is, there's no need to pass the Omit and Select options separately. The benefit is even greater in the List method, where the code can be reduced from 7 lines to a single line: opts := h.BuildSerOpts(c, "List", req.Request).
Adding a Custom Extension Parameter Parsing Function to the Base Class
Currently, we can configure the base class with OmitFields (a blacklist of fields to omit for each action), SelectFields (a whitelist of fields to select for each action), and Adapter (a data adapter).
These are all fixed parameters. However, in actual business scenarios, there is often a need to pass some custom extension parameters down to the lower layers. The plan is as follows:
The repository layer is unrelated to custom extension parameters; they should be passed at most to the service layer.
The service layer accepts an additional
ExtensioninOptions, of typeany. The completeOptionsafter adding this parameter is as follows:// Options are common service operation options. // Options needed by each method can be found here, but not every method uses all options. type Options struct { OmitFields []string // Fields to exclude from database I/O, passed to the repository layer's Omit method SelectFields []string // Fields to select for database I/O, passed to the repository layer's Select method Wheres []Where // Query conditions, used to build WhereScopes, then passed to the repository layer's Scopes method SortField string // Sort field, used to build OrderScope SortOrder string // Sort order Page int // Page number, used to build PaginateScope Limit int // Number of items per page PrimaryKeyValue string // Primary key value, currently available for Get and Update methods to retrieve data rows PrimaryKeyValues []string // Primary key slice, currently available for the Delete method to batch delete rows Extension any // Arbitrary custom extension parameters }The controller layer doesn't just accept an
Extension any. A more reasonable approach is to accept a functionExtensionResolver func(c *gin.Context) any, which can be called anextension data resolver. The function's return value will be assigned to the service layer'sExtension any(simply call the resolver and assign the value within the previously addedBuildSerOptsmethod).
The above is the requirement description, which also serves as the prompt for the AI (the blog is much more detailed than what is actually sent). After the AI helped implement the above requirements, I specifically used it in practice:
For example, if we need to pass a custom AdminSession parameter to the service layer without rewriting the controller layer's methods, we first define the struct in the service layer (the field list and types are completely custom):
// AuthAdminRuleExtension is the extension parameter for the rule list
type AuthAdminRuleExtension struct {
AdminSession *dto.AdminSession
}
When initializing the controller with handler.NewHandler, simply pass an extension data resolver using WithExtension:
// NewAuthAdminRuleHandler creates a controller instance for menu and permission rule management
func NewAuthAdminRuleHandler(svc *svcAuth.AuthAdminRuleService) *AuthAdminRuleHandler {
return &AuthAdminRuleHandler{
Handler: handler.NewHandler(svc,
// Pass the `extension data resolver`
handler.WithExtension(func(c *gin.Context) any {
return &svcAuth.AuthAdminRuleExtension{
// Pass AdminSession
AdminSession: middleware.GetAdmin(c),
}
}),
),
svc: svc,
}
}
In the service layer, read and use the extension data:
// Extension data for `admin info` passed from the controller
extension, ok := opts.Extension.(*AuthAdminRuleExtension)
if !ok || extension.AdminSession == nil {
return nil, errors.New("parameter error, missing AdminSession extension data")
}
// opts.xxxx parameters are generally accepted by every method
// Now extension.AdminSession can be used
Create Defaults to Ignoring the Passed Primary Key Field for Database Insertion
We have already designed the OmitFields option to configure fields ignored during database insertion. However, when performing a create action, the primary key field should always be ignored. We wrote a method long ago to get the current model's primary key. Here, we directly use it in conjunction: when the OmitFields option is not set, the primary key field is ignored by default.