Documentation
Tutorial: Build a REST API
Build and call a working Go REST API with goclean, step by step.
In this tutorial you will generate a Go API, add a Product feature, run it and call every endpoint with curl. It uses SQLite, so you do not need Docker or a database server.
goclean doctor to check.1. Create the project
goclean new shop --router chi --database sqlite --module github.com/me/shopThis scaffolds the project, writes a .env file and runs go mod tidy. Move into it:
cd shopThe project already contains a working User feature, a /health endpoint, middleware for CORS, request IDs, logging and panic recovery, and a cmd/migrate command.
2. Generate a module
goclean make:module Product name:string price:float stock:intgoclean creates nine files and registers the module for you:
✓ Created: internal/domain/entity/product.go
✓ Created: internal/types/product.go
✓ Created: internal/domain/repository/product_repository.go
✓ Created: internal/infrastructure/persistence/product_repository.go
✓ Created: internal/usecase/interface/product_usecase.go
✓ Created: internal/usecase/implementation/product_usecase.go
✓ Created: internal/delivery/http/handlers/product_handler.go
✓ Created: migrations/000002_create_products_table.up.sql
✓ Created: migrations/000002_create_products_table.down.sql
✓ Registered Product in container.go
✓ Registered Product in routes.goAdd --dry-run first if you want to preview the list without writing anything.
3. Run the migration
make migrateThis applies every pending file in migrations, including the new products table.
4. Start the server
make devmake dev runs the server with Air hot reload. Use make run to run it once without watching files. In another terminal, check that it is healthy:
curl localhost:8080/health{"success":true,"data":{"database":"connected","status":"healthy"}}5. Call the endpoints
The module registered five routes under /api/v1/products.
Create a product:
curl -X POST localhost:8080/api/v1/products \
-H 'Content-Type: application/json' \
-d '{"name":"Keyboard","price":49.9,"stock":12}'{"success":true,"data":{"id":"95e47695-0407-42cc-9365-00af8e5f445d","name":"Keyboard","price":49.9,"stock":12,"created_at":"2026-11-04T18:18:59.878819614Z","updated_at":"2026-11-04T18:18:59.878819614Z"}}List products. Results are paginated:
curl localhost:8080/api/v1/products{"success":true,"data":[{"id":"95e47695-…","name":"Keyboard","price":49.9,"stock":12,…}],"meta":{"page":1,"page_size":20,"total_items":1,"total_pages":1}}Get one by ID, using the ID from the create response:
curl localhost:8080/api/v1/products/95e47695-0407-42cc-9365-00af8e5f445dUpdate it. Fields you leave out stay unchanged:
curl -X PUT localhost:8080/api/v1/products/95e47695-0407-42cc-9365-00af8e5f445d \
-H 'Content-Type: application/json' \
-d '{"stock":7}'Delete it:
curl -X DELETE localhost:8080/api/v1/products/95e47695-0407-42cc-9365-00af8e5f445d{"success":true,"data":{"deleted":true}}Errors use the same envelope. Asking for the deleted product again returns 404:
{"success":false,"error":{"code":"NOT_FOUND","message":"product not found"}}Sending a create request without a name returns 400 with BAD_REQUEST, because the name is required.
6. See how the layers fit
A request flows inward: the handler calls the usecase, and the usecase calls the repository. Each layer depends only on the interface of the layer below it.
The domain declares what it needs from storage, with no database code:
type ProductRepository interface {
Create(ctx context.Context, item *entity.Product) error
GetByID(ctx context.Context, id uuid.UUID) (*entity.Product, error)
List(ctx context.Context, limit, offset int) ([]*entity.Product, int64, error)
Update(ctx context.Context, item *entity.Product) error
Delete(ctx context.Context, id uuid.UUID) error
}The usecase holds the business operations and works with plain input types:
type ProductUsecase interface {
Create(ctx context.Context, input types.CreateProductInput) (*entity.Product, error)
GetByID(ctx context.Context, id uuid.UUID) (*entity.Product, error)
List(ctx context.Context, page, pageSize int) ([]*entity.Product, int64, error)
Update(ctx context.Context, input types.UpdateProductInput) (*entity.Product, error)
Delete(ctx context.Context, id uuid.UUID) error
}The handler only translates HTTP into usecase calls and back:
type ProductHandler struct {
usecase usecase.ProductUsecase
}The GORM repository in internal/infrastructure/persistence implements the domain interface. internal/infrastructure/container/container.go builds them in order and passes each one inward through constructors, which is where goclean registered Product for you.
7. Go further
- Generate another feature, such as
goclean make:module Order total:float paid:bool. It registers itself the same way. - Generate a single piece with
make:handler,make:usecaseormake:migration. See Available Commands. - Switch to PostgreSQL by creating the project with
--database postgres, then rundocker compose up -dbeforemake migrate. See Docker Setup. - Ship it with the Deployment checklist.