Your First CRUD API
The Quick Start served a hand-written handler. This tutorial builds the next thing you will actually need: a complete CRUD API for a Product entity — model, table, typed request params, generic CRUD operations, and one customization hook — all verifiable with curl.
What you will build
- an
app/productRPC resource exposingcreate,update,delete, andfind_page - a
app_producttable that follows the database conventions - keyword and status filtering plus pagination, driven by
searchtags - a pre-create hook that rejects duplicate product codes
Prerequisites
- a working application from the Quick Start
- the
sqlite3command-line tool (the tutorial keeps the SQLite setup from the quick start)
The finished layout looks like this:
my-app/
├── configs/
│ └── application.toml
├── data/
│ └── app.db
├── db/
│ └── app_product.sql
├── internal/
│ └── product/
│ ├── model.go
│ ├── payload.go
│ ├── resource.go
│ └── module.go
└── main.go
1. Define the model
Create internal/product/model.go:
package product
import (
"github.com/uptrace/bun"
"github.com/coldsmirk/vef-framework-go/orm"
)
type Product struct {
bun.BaseModel `bun:"table:app_product,alias:ap"`
orm.FullAuditedModel
Name string `json:"name" bun:"name,notnull"`
Code string `json:"code" bun:"code,notnull"`
Stock int `json:"stock" bun:"stock,notnull"`
IsActive bool `json:"isActive" bun:"is_active,notnull"`
Remark string `json:"remark" bun:"remark"`
}
Two embedded types do the heavy lifting:
bun.BaseModelbinds the struct to theapp_producttable with aliasaporm.FullAuditedModelcontributesID,CreatedAt,CreatedBy,CreatedByName,UpdatedAt,UpdatedBy, andUpdatedByName
You never assign these framework-owned fields yourself. On insert, the framework generates a compact string ID for an empty string primary key and fills created_at / created_by; on update it maintains updated_at / updated_by using the current principal. The base model catalog is covered in Models.
2. Create the table
VEF does not generate schema from models. Application projects own their DDL scripts, following the database conventions: a module prefix in the table name (app_ here), fixed audit columns, and named constraints.
Create db/app_product.sql:
BEGIN;
CREATE TABLE IF NOT EXISTS app_product (
id VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(32) NOT NULL DEFAULT 'system',
updated_by VARCHAR(32) NOT NULL DEFAULT 'system',
name VARCHAR(32) NOT NULL,
code VARCHAR(32) NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
remark VARCHAR(512),
CONSTRAINT pk_app_product PRIMARY KEY (id),
CONSTRAINT uk_app_product__code UNIQUE (code)
);
COMMIT;
Apply it to the SQLite database file the app will use:
mkdir -p data
sqlite3 data/app.db < db/app_product.sql
This script is intentionally the portable subset. In a real PostgreSQL project the conventions additionally require LOCALTIMESTAMP defaults, COMMENT ON statements for every table and column, and created_by / updated_by foreign keys to sys_user(id) — see the full templates in Database Conventions.
3. Define write params and search params
Persistence models should not double as request contracts. Create internal/product/payload.go with one struct for writes and one for reads:
package product
import (
"github.com/coldsmirk/vef-framework-go/api"
)
type ProductParams struct {
api.P
ID string `json:"id"`
Name string `json:"name" validate:"required,max=32" label:"Name"`
Code string `json:"code" validate:"required,max=32" label:"Code"`
Stock int `json:"stock" validate:"gte=0" label:"Stock"`
IsActive *bool `json:"isActive"`
Remark string `json:"remark" validate:"max=512" label:"Remark"`
}
type ProductSearch struct {
api.P
Keyword *string `json:"keyword" search:"contains,column=name|code"`
IsActive *bool `json:"isActive" search:"eq"`
MinStock *int `json:"minStock" search:"gte,column=stock"`
}
What each piece does:
- the embedded
api.Psentinel tells the framework to decode this struct from the request'sparamsfield and validate it validatetags run automatically before your operation executes;labelnames the field in error messagessearchtags translate directly intoWHEREclauses:keywordbecomes aLIKEmatch acrossnameORcode,minStockbecomesstock >= ?IDstays empty on create (the framework generates one) and is required on update- pointer fields distinguish "not provided" from a zero value — update merges non-empty fields only, so
IsActive *boolis what lets a client explicitly sendfalse
4. Assemble the API resource
Create internal/product/resource.go:
package product
import (
"github.com/coldsmirk/vef-framework-go/api"
"github.com/coldsmirk/vef-framework-go/crud"
)
type ProductResource struct {
api.Resource
crud.FindPage[Product, ProductSearch]
crud.Create[Product, ProductParams]
crud.Update[Product, ProductParams]
crud.Delete[Product]
}
func NewProductResource() api.Resource {
return &ProductResource{
Resource: api.NewRPCResource("app/product"),
FindPage: crud.NewFindPage[Product, ProductSearch]().Public(),
Create: crud.NewCreate[Product, ProductParams]().Public(),
Update: crud.NewUpdate[Product, ProductParams]().Public(),
Delete: crud.NewDelete[Product]().Public(),
}
}
Each embedded builder implements api.OperationsProvider, so the framework collects them automatically and registers one operation per builder:
| Embedded builder | Default action | Behavior |
|---|---|---|
crud.FindPage[Product, ProductSearch] | find_page | filtered, paginated list with total count |
crud.Create[Product, ProductParams] | create | copies params into a model, inserts it in a transaction |
crud.Update[Product, ProductParams] | update | loads the record by id, merges non-empty fields, updates |
crud.Delete[Product] | delete | loads the record by id, deletes it in a transaction |
Public() keeps the tutorial runnable without an auth provider. In a real application, drop it and protect each operation instead:
crud.NewCreate[Product, ProductParams]().RequiredPermission("app:product:create")
5. Register the module and run
Create internal/product/module.go:
package product
import (
"github.com/coldsmirk/vef-framework-go"
)
var Module = vef.Module(
"app:product",
vef.ProvideAPIResource(NewProductResource),
)
Compose it in main.go:
package main
import (
"github.com/coldsmirk/vef-framework-go"
"example.com/my-app/internal/product"
)
func main() {
vef.Run(
product.Module,
)
}
Point configs/application.toml at the database file from step 2:
[vef.app]
name = "my-app"
port = 8080
[vef.data_sources.primary]
type = "sqlite"
path = "data/app.db"
[vef.event.transports.outbox]
enabled = true
[[vef.event.routing]]
pattern = "vef.storage.*"
transports = ["outbox"]
The last two blocks are new compared to the quick start. The generic write operations run the file storage lifecycle inside their transactions, and storage publishes its domain events through a transactional transport — the framework fails fast at startup if vef.storage.* events have no such route. Enabling the outbox transport (it creates its own table automatically) and routing storage events to it satisfies the check.
Start the app:
go run .
6. Call the API
All four operations go through the same RPC endpoint, POST /api, selected by the resource / action / version envelope fields.
Create
curl http://localhost:8080/api \
-H 'Content-Type: application/json' \
-d '{
"resource": "app/product",
"action": "create",
"version": "v1",
"params": {
"name": "Espresso Beans",
"code": "P-1001",
"stock": 20,
"isActive": true
}
}'
The response returns the generated primary key:
{
"code": 0,
"message": "新增成功",
"data": {
"id": "d1nbkq2s7kg5jkvvs7lg"
}
}
As in the quick start, messages follow the framework's default language; set VEF_I18N_LANGUAGE=en to get Created successfully instead.
Find page
Filters come from params (your ProductSearch), pagination from meta:
curl http://localhost:8080/api \
-H 'Content-Type: application/json' \
-d '{
"resource": "app/product",
"action": "find_page",
"version": "v1",
"params": { "keyword": "Espresso" },
"meta": { "page": 1, "size": 10 }
}'
The data payload is a page object:
{
"code": 0,
"message": "成功",
"data": {
"page": 1,
"size": 10,
"total": 1,
"items": [
{
"id": "d1nbkq2s7kg5jkvvs7lg",
"createdAt": "2026-07-09 10:30:00",
"createdBy": "anonymous",
"createdByName": "",
"updatedAt": "2026-07-09 10:30:00",
"updatedBy": "anonymous",
"updatedByName": "",
"name": "Espresso Beans",
"code": "P-1001",
"stock": 20,
"isActive": true,
"remark": ""
}
]
}
}
Note createdBy: "anonymous": the audit columns were filled automatically, and because the operation is public there is no authenticated principal yet.
Update
Send the id plus the fields to change; unset fields keep their stored values:
curl http://localhost:8080/api \
-H 'Content-Type: application/json' \
-d '{
"resource": "app/product",
"action": "update",
"version": "v1",
"params": {
"id": "d1nbkq2s7kg5jkvvs7lg",
"name": "Espresso Beans",
"code": "P-1001",
"stock": 35
}
}'
{ "code": 0, "message": "保存成功", "data": null }
Delete
curl http://localhost:8080/api \
-H 'Content-Type: application/json' \
-d '{
"resource": "app/product",
"action": "delete",
"version": "v1",
"params": { "id": "d1nbkq2s7kg5jkvvs7lg" }
}'
{ "code": 0, "message": "删除成功", "data": null }
The envelope fields and transport rules are specified in Routing, and the code / message / data contract in Results and Errors.
7. Add a create hook
The generic builders accept hooks that run inside the same transaction as the write. Enforce the unique product code at the API level with WithPreCreate, so callers get a structured business error instead of a raw constraint violation.
Update the Create builder in internal/product/resource.go:
Create: crud.NewCreate[Product, ProductParams]().
Public().
WithPreCreate(func(model *Product, params *ProductParams, query orm.InsertQuery, ctx fiber.Ctx, tx orm.DB) error {
exists, err := tx.NewSelect().Model((*Product)(nil)).
Where(func(cb orm.ConditionBuilder) { cb.Equals("code", model.Code) }).
Exists(ctx.Context())
if err != nil {
return err
}
if exists {
return result.Err("product code already exists",
result.WithCode(result.ErrCodeRecordAlreadyExists))
}
return nil
}),
Add the new imports to the file: github.com/gofiber/fiber/v3, github.com/coldsmirk/vef-framework-go/orm, and github.com/coldsmirk/vef-framework-go/result.
Restart the app and replay the create request from step 6 twice. The second call now fails cleanly:
{
"code": 2002,
"message": "product code already exists",
"data": null
}
The hook runs before the insert, inside the transaction, with the pending model, the decoded params, the insert query, and the transactional orm.DB. Every builder has a matching pair — WithPreUpdate / WithPostUpdate, WithPreDelete / WithPostDelete, and more — cataloged in Generic CRUD.
What the framework did for you
You wrote a model, two request structs, and a resource. You did not write SQL for the four operations, request decoding, validation wiring, transactions, ID generation, audit-column maintenance, pagination counting, or response envelopes.
Where to go next
- Generic CRUD: every builder, option, and hook in the
crudpackage - API Resources: operations, auth config, and custom actions beyond CRUD
- Models: base model types, tags, and search struct patterns