Skip to content
/ Tutorial: Build a REST API

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.

You need goclean installed (see Installation) and Go 1.23 or newer. If your Go is older, Go downloads the right toolchain automatically. Run goclean doctor to check.

1. Create the project

bash
goclean new shop --router chi --database sqlite --module github.com/me/shop

This scaffolds the project, writes a .env file and runs go mod tidy. Move into it:

bash
cd shop

The 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

bash
goclean make:module Product name:string price:float stock:int

goclean creates nine files and registers the module for you:

output
✓ 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.go

Add --dry-run first if you want to preview the list without writing anything.

3. Run the migration

bash
make migrate

This applies every pending file in migrations, including the new products table.

4. Start the server

bash
make dev

make 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:

bash
curl localhost:8080/health
response
{"success":true,"data":{"database":"connected","status":"healthy"}}

5. Call the endpoints

The module registered five routes under /api/v1/products.

Create a product:

bash
curl -X POST localhost:8080/api/v1/products \
  -H 'Content-Type: application/json' \
  -d '{"name":"Keyboard","price":49.9,"stock":12}'
response
{"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:

bash
curl localhost:8080/api/v1/products
response (shortened)
{"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:

bash
curl localhost:8080/api/v1/products/95e47695-0407-42cc-9365-00af8e5f445d

Update it. Fields you leave out stay unchanged:

bash
curl -X PUT localhost:8080/api/v1/products/95e47695-0407-42cc-9365-00af8e5f445d \
  -H 'Content-Type: application/json' \
  -d '{"stock":7}'

Delete it:

bash
curl -X DELETE localhost:8080/api/v1/products/95e47695-0407-42cc-9365-00af8e5f445d
response
{"success":true,"data":{"deleted":true}}

Errors use the same envelope. Asking for the deleted product again returns 404:

response
{"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:

internal/domain/repository/product_repository.go
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:

internal/usecase/interface/product_usecase.go
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:

internal/delivery/http/handlers/product_handler.go
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:usecase or make:migration. See Available Commands.
  • Switch to PostgreSQL by creating the project with --database postgres, then run docker compose up -d before make migrate. See Docker Setup.
  • Ship it with the Deployment checklist.