A practical walkthrough of building a clean REST API in Go using GoFiber, GORM, and a layered architecture — routes, handlers, usecases, and repositories.
Building web APIs with Go is straightforward, and using a framework like GoFiber speeds things up further. This article walks through a simple product CRUD API — covering the project structure, each architectural layer, and how they connect.
The full source code is available at github.com/uhkrowi/go-simple-crud.
Getting Started
Make sure your environment has Go installed. The project uses MySQL for persistence — you can swap it for another database, but you'll need to update the GORM driver and .env config accordingly.
Entry Point
At /cmd/app/main.go, the main() function initialises the app and starts the server. A separate setup() function wires up the database, validator, and routes:
func main() {
defer config.CloseDBConnection()
app := fiber.New(fiber.Config{
AppName: "CRUD",
})
app.Use(cors.New())
setup(app)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
err := app.Listen(":" + port)
if err != nil {
panic(err)
}
}
func setup(app *fiber.App) {
config.InitDB()
db := config.DBConn
validate := validator.New()
apiV1 := app.Group("/api/v1")
route.ProductRoute(apiV1.Group("/product"), db, validate)
}
The Models
The Product model lives at /internal/app/model/product.go. A product has a list of Variant records. The gorm:"-" tag tells GORM to ignore Variants during direct DB operations — it's populated manually in the usecase layer.
type Product struct {
ID uuid.UUID `gorm:"primaryKey;column:id" json:"id"`
Name string `gorm:"column:name" json:"name"`
IsActive bool `gorm:"column:is_active" json:"is_active"`
Variants []Variant `gorm:"-" json:"variants"`
}
type Variant struct {
ID uuid.UUID `gorm:"primaryKey;column:id" json:"id"`
Name string `gorm:"column:name" json:"name"`
ProductID uuid.UUID `gorm:"column:product_id" json:"product_id"`
ProductName string `gorm:"column:product_name" json:"product_name"`
Price float64 `gorm:"column:price" json:"price"`
Stock int `gorm:"column:stock" json:"stock"`
}
Layered Architecture
The project follows a clean layered structure. Each layer has one responsibility:
- Route — maps HTTP endpoints to the correct handler. For example,
GET /api/v1/productcallsProductHandler.GetAll. - Handler — receives the HTTP request, parses and validates input, calls the usecase, and returns the response. It owns request/response shaping but no business logic.
- Usecase — the core logic layer. It orchestrates calls to the repository, applies business rules (e.g., "a product can only be deactivated if it has no active variants"), and may call other usecases.
- Repository — all database interaction lives here. Usecases call repositories through interfaces, which keeps the business logic independently testable.
Create Flow (Example)
When a client sends POST /api/v1/product:
- The router directs the request to
ProductHandler.Create. - The handler parses the JSON body into a
Productstruct and runs validation viago-playground/validator. If validation fails, it returns a400with details immediately. - The usecase generates a UUID for the new product, then calls
ProductRepository.Create. - The repository runs
db.Create(&product)via GORM and returns any error. - The handler receives the result and returns
201with the created product, or500if something went wrong.
Why This Structure?
Separating these layers may feel like extra boilerplate for a small CRUD app, but the payoff comes quickly as the project grows:
- You can test usecases without a real database by swapping the repository with a mock.
- Changing the database (say, from MySQL to PostgreSQL) only touches the repository layer.
- Adding a second transport layer (e.g., gRPC alongside HTTP) reuses the same usecase and repository code.
For a small API it's optional. For anything production-facing, it's worth the setup cost from day one.