Complete CRUD Walkthrough
Build a Product API end-to-end: model → DTO → migration → repository → service → controller → auth → seed → test. Follow along step by step or jump to the section you need.
What we're building
By the end of this guide you will have a working REST API for managing products, with these endpoints:
| Method | Endpoint | Auth? | Action |
|---|---|---|---|
| GET | /api/products | No | List all products |
| GET | /api/products/{id} | No | Get one product |
| POST | /api/products | JWT | Create a product |
| PUT | /api/products/{id} | JWT | Update a product |
| DELETE | /api/products/{id} | JWT | Delete a product |
How the layers fit together
Kashvi follows MVC with an extra Repository layer to keep all database calls in one place. Here is the flow for a POST /api/products request:
HTTP Request → Router → Middleware (JWT, Logger)
↓
Controller
• ctx.BindJSON(&dto) ← parse + validate body
• repo.Create(&model) ← call repository
• ctx.Created(model) ← send 201 JSON response
↓
Repository
• orm.DB().Create(...) ← the ONLY place that
touches the database
↓
Database (SQLite / MySQL / PostgreSQL)Step 1 — Generate the resource
From your project root (where go.mod lives), run one command to scaffold every file you need:
kashvi make:resource ProductThe CLI prints the exact route snippet to paste into your routes file, and creates the following files:
Step 2 — Define the model
Edit app/models/product.go. Add your fields with gorm, json, and validate tags. The validate tags are read automatically by ctx.BindJSON — no extra code needed.
package models
import "gorm.io/gorm"
type Product struct {
gorm.Model
Name string `json:"name" gorm:"not null" validate:"required,min=1,max=255"`
Description string `json:"description"`
Price float64 `json:"price" gorm:"not null" validate:"required,gte=0"`
SKU string `json:"sku" gorm:"uniqueIndex;not null" validate:"required"`
}
Common validate rules: required, email, min, max, gte, lte, in, url. A failed rule returns 422 Unprocessable Entity with field-level errors — your controller does not need to handle it.
Step 3 — Run the migration
The generated migration file at database/migrations/..._create_products_table.go already contains:
- Up:
db.AutoMigrate(&models.Product)— creates or updates theproductstable to match your model struct. - Down:
db.Migrator().DropTable("products")— removes the table on rollback.
Imports use your module path from go.mod. You only need to edit the migration file if you want to add custom SQL indexes or constraints beyond what GORM handles automatically.
kashvi migrateStep 4 — Repository (data layer)
The generated app/repositories/product.go embeds repository.Base[models.Product] and gives you these methods for free:
| Method | Description |
|---|---|
FindByID(id uint) | Fetch one record by primary key |
All() | Fetch all records |
Create(m *Product) | Insert a new record |
Update(m *Product) | Save changes to an existing record |
Delete(id uint) | Soft-delete by primary key |
Query() | Raw GORM chain for custom filters, pagination, joins |
No changes needed for basic CRUD. To add a custom query, add a method to this file:
// Optional: add custom queries
func (r *ProductRepository) FindBySKU(sku string) (*models.Product, error) {
var p models.Product
err := r.Query().Where("sku = ?", sku).First(&p)
if err != nil {
return nil, err
}
return &p, nil
}
Step 5 — Service (optional)
The service layer is optional for simple CRUD. Use it when you have business logic that spans multiple repositories, sends emails, publishes queue jobs, etc. The generated service already holds a repository reference:
func (s *ProductService) CreateProduct(input *models.Product) error {
if err := s.repo.Create(input); err != nil {
return err
}
// e.g. logger.Info("product_created", "id", input.ID)
return nil
}
For simple CRUD, the controller can call the repository directly and you can skip the service entirely.
Step 6 — DTOs (request / response)
The generated app/dto/product_dto.go holds:
- CreateProductRequest — all required fields with validate tags
- UpdateProductRequest — same fields as pointers (so partial updates work; a missing field means "don't change it")
- ProductResponse (optional) — a safe shape to return to API clients, hiding internal fields
Controllers bind the request body to these structs with ctx.BindJSON so validation runs before the model is touched. To generate DTOs without a full resource: kashvi make:dto Product.
Step 7 — Controller and validation
The generated controller uses the repository and DTOs — there is no orm import in the controller file. Each handler follows the same pattern:
- Store: binds JSON to
dto.CreateProductRequest, then maps intomodels.Productand callsrepo.Create. - Update: loads by ID via repo, binds
dto.UpdateProductRequest, applies pointer fields, thenrepo.Update. - Index / Show / Destroy: call
repo.All(),repo.FindByID,repo.Delete.
To add logging (recommended for create/update/delete):
import "github.com/shashiranjanraj/kashvi/pkg/logger"
func (c *ProductController) Store(ctx *appctx.Context) {
var input dto.CreateProductRequest
if !ctx.BindJSON(&input) {
return // BindJSON already sent 422 + validation errors
}
model := &models.Product{
Name: input.Name, Description: input.Description, Price: input.Price, SKU: input.SKU,
}
if err := c.repo.Create(model); err != nil {
ctx.Error(http.StatusBadRequest, "Failed to create product")
return
}
log := logger.WithCtx(ctx.R.Context())
log.Info("product_created", "id", model.ID, "sku", model.SKU)
ctx.Created(model)
}
Step 8 — Routes and authentication
Register routes in main.go or app/routes/api.go. The CLI printed the exact snippet when you ran kashvi make:resource — paste it here:
repo := repositories.NewProductRepository()
svc := services.NewProductService(repo)
ctrl := controllers.NewProductController(repo)
api := r.Group("/api")
// Public
api.Get("/products", "products.index", ctx.Wrap(ctrl.Index))
api.Get("/products/{id}", "products.show", ctx.Wrap(ctrl.Show))
// Protected (JWT required). Import: "github.com/shashiranjanraj/kashvi/pkg/middleware"
protected := api.Group("", middleware.AuthMiddleware)
protected.Post("/products", "products.store", ctx.Wrap(ctrl.Store))
protected.Put("/products/{id}", "products.update", ctx.Wrap(ctrl.Update))
protected.Delete("/products/{id}", "products.destroy", ctx.Wrap(ctrl.Destroy))Step 9 — Seed sample data
Edit database/seeders/product_seeder.go and add some sample rows:
func init() {
app.RegisterSeeder("products", func() {
database.DB.Create(&[]models.Product{
{Name: "Laptop", Description: "Gaming", Price: 999.99, SKU: "LAPTOP001"},
{Name: "Mouse", Description: "Wireless", Price: 29.99, SKU: "MOUSE001"},
})
})
}
Ensure migrations and seeders are registered in main.go via blank imports:
import (
_ "yourmodule/database/migrations"
_ "yourmodule/database/seeders"
)Step 10 — Run migrations, seed, and serve
kashvi migrate # create / update the products table
kashvi seed # insert sample data
kashvi serve # start the server on APP_PORT (default 8080)Set LOG_LEVEL and DB_LOG_MODE in .env (see Logging) to see app logs and SQL queries during development.
Step 11 — Test the API
Quick check with curl
# List all products
curl http://localhost:8080/api/products
# Create a product (no auth on this example)
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Laptop","description":"Gaming","price":999.99,"sku":"LAPTOP001"}'
# Get one product
curl http://localhost:8080/api/products/1
# Update
curl -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Gaming Laptop","description":"Gaming","price":1099.99,"sku":"LAPTOP001"}'
# Delete
curl -X DELETE http://localhost:8080/api/products/1Automated with TestKit
The generated testdata/product_scenarios.json defines scenarios (list, create, get, update, delete). Build your app's handler and run:
func TestProductAPI(t *testing.T) {
handler := app.New().
Routes(RegisterRoutes).
Handler()
testkit.RunDir(t, handler, "testdata")
}
Put request/response JSON fixtures in testdata/ as referenced by the scenario file (e.g. product_create_req.json, product_create_res.json). See the TestKit docs for the full scenario format.
Layer quick reference
| Layer | File | Responsibility |
|---|---|---|
| Model | app/models/product.go | Struct + gorm/json/validate tags |
| DTO | app/dto/product_dto.go | Request/response structs; validate tags; used with ctx.BindJSON |
| Migration | database/migrations/… | AutoMigrate / DropTable for the model |
| Repository | app/repositories/product.go | ALL DB access (FindByID, All, Create, Update, Delete, Query) |
| Service | app/services/product_service.go | Optional business logic using repository |
| Controller | app/controllers/product_controller.go | HTTP only: BindJSON (validation), call repo/service, log, respond |
| Routes | app/routes/api.go | Wire controller methods to HTTP methods + paths |
| Seeder | database/seeders/product_seeder.go | app.RegisterSeeder("key", func() { ... }) |
| Tests | testdata/product_scenarios.json | testkit + JSON scenarios |
What's next?
See also: Routing, Validation, Authentication, ORM & Database, Migrations & Seeders.