M
Mr Sugiarto
Developer
20 Oct 2026 7 min read

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.

M
Mr Sugiarto

Developer

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