product-api.yaml dari Part 6 sudah ditulis dan lolos lint. Sekarang kita generate jadi kode, lalu bangun seluruh lapis hexagonal architecture di baliknya — dari domain sampai database.
Generate
$ make openapi
Generating code from OpenAPI spec...
Generating healthapi from health-api...
✓ Generated: internal/adapter/inbound/http/generated/healthapi/server.gen.go
Generating productapi from product-api...
✓ Generated: internal/adapter/inbound/http/generated/productapi/server.gen.go
Generating userapi from user-api...
✓ Generated: internal/adapter/inbound/http/generated/userapi/server.gen.go
✅ OpenAPI code generation complete!
Generated 3 package(s) from api/openapi/*.yaml
Script generate-openapi.sh yang sudah ada di boilerplate otomatis mendeteksi product-api.yaml yang baru ditambahkan di Part 6, tanpa perlu diubah sama sekali. Hasilnya, interface yang harus kita implementasikan:
// ServerInterface represents all server handlers.
type ServerInterface interface {
// List products
// (GET /products)
ListProducts(c *fiber.Ctx, params ListProductsParams) error
// Create product
// (POST /products)
CreateProduct(c *fiber.Ctx) error
// Delete product
// (DELETE /products/{id})
DeleteProduct(c *fiber.Ctx, id openapi_types.UUID) error
// Get product by ID
// (GET /products/{id})
GetProductById(c *fiber.Ctx, id openapi_types.UUID) error
// Update product
// (PUT /products/{id})
UpdateProduct(c *fiber.Ctx, id openapi_types.UUID) error
}
Sekarang kita bangun dari dalam ke luar: domain dulu, baru port, service, repository, dan terakhir handler yang mengimplementasikan interface di atas.
1. Domain
Entity murni, tidak tahu apa-apa soal HTTP atau OpenAPI:
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Product struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Name string `gorm:"not null;size:255"`
Price float64 `gorm:"not null"`
Stock int `gorm:"not null;default:0"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (Product) TableName() string {
return "products"
}
func NewProduct(name string, price float64, stock int) *Product {
return &Product{ID: uuid.New(), Name: name, Price: price, Stock: stock}
}
func (p *Product) UpdateDetails(name string, price float64, stock int) {
p.Name = name
p.Price = price
p.Stock = stock
}
Ditambah satu domain error baru di internal/domain/errors.go: ErrProductNotFound = errors.New("product not found").
2. Port — Outbound (Repository Interface)
Ini kontrak yang harus dipenuhi oleh adapter database:
package repository
import (
"context"
"github.com/gieart87/gohexaclean/internal/domain"
"github.com/google/uuid"
)
type ProductRepository interface {
Create(ctx context.Context, product *domain.Product) error
FindByID(ctx context.Context, id uuid.UUID) (*domain.Product, error)
Update(ctx context.Context, product *domain.Product) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, offset, limit int) ([]*domain.Product, error)
Count(ctx context.Context) (int64, error)
}
3. Port — Inbound (Service Interface)
Ini kontrak use case yang dipanggil oleh handler HTTP:
package inbound
import (
"context"
"github.com/gieart87/gohexaclean/internal/dto/request"
"github.com/gieart87/gohexaclean/internal/dto/response"
"github.com/google/uuid"
)
type ProductServicePort interface {
CreateProduct(ctx context.Context, req *request.CreateProductRequest) (*response.ProductResponse, error)
GetProductByID(ctx context.Context, id uuid.UUID) (*response.ProductResponse, error)
UpdateProduct(ctx context.Context, id uuid.UUID, req *request.UpdateProductRequest) (*response.ProductResponse, error)
DeleteProduct(ctx context.Context, id uuid.UUID) error
ListProducts(ctx context.Context, page, limit int) ([]*response.ProductResponse, int64, error)
}
4. Service — Business Logic
Implementasi ProductServicePort, satu-satunya tempat business logic hidup — tidak tahu soal HTTP ataupun SQL:
type ProductService struct {
productRepo repository.ProductRepository
}
func NewProductService(productRepo repository.ProductRepository) inbound.ProductServicePort {
return &ProductService{productRepo: productRepo}
}
func (s *ProductService) UpdateProduct(ctx context.Context, id uuid.UUID, req *request.UpdateProductRequest) (*response.ProductResponse, error) {
product, err := s.productRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if product == nil {
return nil, domain.ErrProductNotFound
}
product.UpdateDetails(req.Name, req.Price, req.Stock)
if err := s.productRepo.Update(ctx, product); err != nil {
return nil, fmt.Errorf("failed to update product: %w", err)
}
return response.NewProductResponse(product), nil
}
(Method CreateProduct, GetProductByID, DeleteProduct, ListProducts mengikuti pola yang sama: panggil repository, terjemahkan domain error ke caller, bungkus hasil jadi response DTO.)
5. Repository — Adapter PostgreSQL (GORM)
Implementasi nyata ProductRepository, satu-satunya tempat yang tahu soal SQL:
type ProductRepositoryPG struct {
db *gorm.DB
}
func NewProductRepositoryPG(db *gorm.DB) repository.ProductRepository {
return &ProductRepositoryPG{db: db}
}
func (r *ProductRepositoryPG) FindByID(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
var product domain.Product
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&product).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrProductNotFound
}
return nil, err
}
return &product, nil
}
Plus migration SQL baru di internal/infra/db/migrations/00002_create_products_table.sql:
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
price NUMERIC(14,2) NOT NULL DEFAULT 0,
stock INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_products_deleted_at ON products(deleted_at);
-- +goose StatementEnd
6. Handler — Implement ServerInterface
Ini titik temu antara hexagonal architecture dan hasil generate oapi-codegen:
package product
import (
"errors"
"github.com/gieart87/gohexaclean/internal/adapter/inbound/http/generated/productapi"
"github.com/gieart87/gohexaclean/internal/domain"
"github.com/gieart87/gohexaclean/internal/dto/request"
"github.com/gieart87/gohexaclean/internal/port/inbound"
"github.com/gofiber/fiber/v2"
openapitypes "github.com/oapi-codegen/runtime/types"
)
type Handler struct {
productService inbound.ProductServicePort
}
func NewHandler(productService inbound.ProductServicePort) *Handler {
return &Handler{productService: productService}
}
// Ensure Handler implements ServerInterface at compile time
var _ productapi.ServerInterface = (*Handler)(nil)
func (h *Handler) GetProductById(c *fiber.Ctx, id openapitypes.UUID) error {
product, err := h.productService.GetProductByID(c.Context(), id)
if err != nil {
status := fiber.StatusInternalServerError
if errors.Is(err, domain.ErrProductNotFound) {
status = fiber.StatusNotFound
}
return c.Status(status).JSON(fiber.Map{
"success": false,
"message": "Failed to retrieve product",
"error": err.Error(),
})
}
return c.JSON(fiber.Map{
"success": true,
"message": "Product retrieved successfully",
"data": toAPIProduct(product),
})
}
Handler hanya bergantung pada inbound.ProductServicePort — sebuah interface, bukan implementasi konkret. Handler ini tidak tahu (dan tidak peduli) apakah di baliknya ada PostgreSQL, in-memory store, atau mock untuk testing. Inilah inti hexagonal architecture: setiap lapis hanya bergantung pada interface lapis di bawahnya.
Baris var _ productapi.ServerInterface = (*Handler)(nil) — sama seperti di Notes API (Part 4) — adalah jaminan compile-time bahwa Handler benar-benar memenuhi seluruh kontrak ServerInterface yang digenerate dari product-api.yaml.
7. Menyambungkan ke Container dan Router
Di internal/bootstrap/container.go, repository dan service Product didaftarkan persis seperti User, hanya lebih sederhana (tidak butuh cache service, event publisher, atau JWT config):
container.ProductRepository = pgsql.NewProductRepositoryPG(database)
// ...
container.ProductService = app.NewProductService(container.ProductRepository)
Dan di internal/adapter/inbound/http/router/router.go:
// Create product handler that implements productapi.ServerInterface
productHandler := product.NewHandler(productService)
// Auto-register product routes from OpenAPI spec (api/openapi/product-api.yaml)
// This will create: GET/POST /products, GET/PUT/DELETE /products/{id}
productapi.RegisterHandlers(api, productHandler)
Semua lapis sudah tersambung: spec → generated interface → handler → service → repository → database. Di Part 8, kita jalankan seluruh rangkaian ini sungguhan — migrate database, nyalakan server, dan test setiap endpoint lewat curl terhadap PostgreSQL yang benar-benar jalan.
Bagian dari Series: OpenAPI Spec-First untuk Go Developer
Seri 8 bagian: belajar mendesain REST API dengan pendekatan spec-first di Go - menulis kontrak OpenAPI dulu, generate kode, lalu terapkan ke project h...
Lihat Series Lengkap