Skip to content
Santekno.com | Level Up Your Engineering Skills
ID
📖 0%
10 Nov 2026 · 22 mnt baca ·Artikel 85 / 208
Go

Golang E-Commerce Skeleton Project: Clean Architecture dari Spec SDD

Bangun skeleton project Santekno Shop dengan Clean Architecture. Domain entities, repository interfaces, usecase stubs, handler wiring — semua derive dari spec SDD di Artikel 01.

IH
Ihsan Arif
Penulis di Santekno · Backend Engineer

Skeleton project golang dengan clean architecture adalah jembatan antara spec dan implementasi: seluruh struktur kode — entity, interface, dan stub — dibuat lebih dulu sehingga tim bisa bekerja paralel dan CI langsung berjalan. Spec sudah ada, arsitektur sudah didesain, OpenAPI sudah dibuat, dan CI sudah berjalan sejak artikel sebelumnya.

Di artikel ini kita membangun kerangka lengkap Santekno Shop: domain entities yang merepresentasikan business object dari spec, repository interface sebagai data contract, usecase dan handler stubs, hingga error handling terpusat. Semuanya compile, semuanya konsisten, dan semuanya siap di-flesh-out satu domain per artikel berikutnya.


05.01 Filosofi: Spec → Interface → Implementation

Urutan pembuatan skeleton bukan sembarang — ia mengikuti alur dari behavior (spec) menuju kontrak (interface) baru implementasi. Diagram berikut merangkum urutan yang benar beserta alasan kenapa skeleton-first memberdayakan kerja paralel.

text
 1URUTAN YANG BENAR:
 2
 31. Spec (Artikel 01) → mendefinisikan behavior
 42. Domain entities → represent business objects dari spec
 53. Repository interfaces → mendefinisikan data contracts
 64. UseCase interfaces + stubs → satu file per usecase, dari spec AC
 75. Handler stubs → wiring ke usecase
 86. Implementation (Artikel 06-10) → flesh out satu per satu
 9
10Mengapa penting:
11  Dengan skeleton dulu, seluruh tim bisa assign pekerjaan paralel.
12  "Kamu implement UserRepo, saya implement OrderUseCase."
13  Compile masih OK karena stubs mengembalikan errors.New("not implemented").
14  CI pipeline langsung berjalan sejak skeleton selesai.

Karena interface didefinisikan lebih dulu, developer bisa mengerjakan bagian berbeda tanpa saling menunggu — kontraknya sudah jelas sebelum satu baris logika ditulis.


05.02 Domain Entities: User

Domain layer adalah inti bisnis yang bebas dari dependency infrastruktur. Entity User berikut mendefinisikan struktur data, sentinel error, dan aturan validasi password langsung di dalam domain.

go
 1// internal/domain/user/entity.go
 2
 3package user
 4
 5import (
 6    "errors"
 7    "time"
 8
 9    "github.com/google/uuid"
10)
11
12var (
13    ErrEmailAlreadyRegistered = errors.New("email already registered")
14    ErrWeakPassword           = errors.New("weak password: min 8 chars with digit")
15    ErrInvalidEmail           = errors.New("invalid email format")
16    ErrInvalidCredentials     = errors.New("invalid credentials")
17    ErrUserNotFound           = errors.New("user not found")
18    ErrTooManyAttempts        = errors.New("too many login attempts")
19)
20
21type User struct {
22    ID           uuid.UUID
23    Email        string
24    PasswordHash string
25    Name         string
26    IsActive     bool
27    CreatedAt    time.Time
28    UpdatedAt    time.Time
29}
30
31type RefreshToken struct {
32    ID        uuid.UUID
33    UserID    uuid.UUID
34    TokenHash string
35    ExpiresAt time.Time
36    CreatedAt time.Time
37}
38
39// ValidatePassword: minimum 8 chars + setidaknya 1 digit
40func ValidatePassword(password string) error {
41    if len(password) < 8 {
42        return ErrWeakPassword
43    }
44    hasDigit := false
45    for _, r := range password {
46        if r >= '0' && r <= '9' {
47            hasDigit = true
48            break
49        }
50    }
51    if !hasDigit {
52        return ErrWeakPassword
53    }
54    return nil
55}

Aturan password hidup di entity, bukan di usecase — sehingga bisa diuji tanpa infrastruktur apa pun dan konsisten di mana pun dipanggil.


05.03 Domain Entities: Product

Entity Product menyimpan aturan inti katalog, termasuk konsep stok tersedia yang membedakan stok fisik dengan stok yang sudah di-reserve. Perhatikan penggunaan int64 untuk semua nilai uang (IDR cents).

go
 1// internal/domain/product/entity.go
 2
 3package product
 4
 5import (
 6    "errors"
 7    "time"
 8
 9    "github.com/google/uuid"
10)
11
12var (
13    ErrProductNotFound    = errors.New("product not found")
14    ErrInsufficientStock  = errors.New("insufficient stock")
15    ErrProductInactive    = errors.New("product is not active")
16)
17
18type Product struct {
19    ID          uuid.UUID
20    CategoryID  *uuid.UUID
21    Name        string
22    Description string
23    PriceIDR    int64      // cents: Rp 50.000 = 5_000_000
24    Stock       int
25    ReservedStock int
26    IsActive    bool
27    CreatedAt   time.Time
28    UpdatedAt   time.Time
29}
30
31// AvailableStock: stock yang bisa dipesan (belum di-reserve)
32func (p *Product) AvailableStock() int {
33    return p.Stock - p.ReservedStock
34}
35
36type Category struct {
37    ID       uuid.UUID
38    Name     string
39    Slug     string
40    ParentID *uuid.UUID
41}
42
43type SearchFilter struct {
44    Query      string
45    CategoryID *uuid.UUID
46    MinPriceIDR *int64
47    MaxPriceIDR *int64
48    Page       int
49    Limit      int
50}

Method AvailableStock() mengenkapsulasi perhitungan stok yang bisa dipesan, sehingga usecase pemesanan tidak perlu tahu detail reservasi — cukup memanggil satu method.


05.04 Domain Entities: Order

Order adalah entity paling kaya aturan bisnis: status enum, window pembatalan, dan perhitungan total. Berikut definisinya lengkap dengan method CanBeCancelled() dan CalculateTotal().

go
 1// internal/domain/order/entity.go
 2
 3package order
 4
 5import (
 6    "errors"
 7    "time"
 8
 9    "github.com/google/uuid"
10)
11
12var (
13    ErrOrderNotFound       = errors.New("order not found")
14    ErrOrderNotCancellable = errors.New("order cannot be cancelled")
15    ErrCancelWindowExpired = errors.New("cancel window has expired")
16    ErrEmptyOrder          = errors.New("order must have at least one item")
17    ErrMaxItemsExceeded    = errors.New("maximum 10 items per order")
18)
19
20type Status string
21const (
22    StatusPending   Status = "PENDING"
23    StatusConfirmed Status = "CONFIRMED"
24    StatusShipped   Status = "SHIPPED"
25    StatusDelivered Status = "DELIVERED"
26    StatusCancelled Status = "CANCELLED"
27)
28
29type PaymentMethod string
30const (
31    PaymentBankTransfer PaymentMethod = "BANK_TRANSFER"
32    PaymentQRIS         PaymentMethod = "QRIS"
33    PaymentCreditCard   PaymentMethod = "CREDIT_CARD"
34)
35
36type Order struct {
37    ID              uuid.UUID
38    UserID          uuid.UUID
39    Status          Status
40    Items           []OrderItem
41    TotalAmountIDR  int64      // cents
42    PaymentMethod   PaymentMethod
43    ShippingAddress ShippingAddress
44    PaymentURL      *string
45    PaymentExpiresAt *time.Time
46    CreatedAt       time.Time
47    UpdatedAt       time.Time
48}
49
50type OrderItem struct {
51    ID            uuid.UUID
52    OrderID       uuid.UUID
53    ProductID     uuid.UUID
54    ProductName   string   // snapshot saat order
55    UnitPriceIDR  int64    // snapshot cents
56    Quantity      int
57    SubtotalIDR   int64    // quantity * unit_price_idr
58}
59
60type ShippingAddress struct {
61    Street   string `json:"street"`
62    City     string `json:"city"`
63    Province string `json:"province"`
64    PostCode string `json:"post_code"`
65}
66
67// CanBeCancelled: PENDING + dalam window 30 menit
68func (o *Order) CanBeCancelled() error {
69    if o.Status != StatusPending {
70        return ErrOrderNotCancellable
71    }
72    if time.Since(o.CreatedAt) > 30*time.Minute {
73        return ErrCancelWindowExpired
74    }
75    return nil
76}
77
78// CalculateTotal: hitung ulang total dari items
79func (o *Order) CalculateTotal() {
80    var total int64
81    for _, item := range o.Items {
82        item.SubtotalIDR = item.UnitPriceIDR * int64(item.Quantity)
83        total += item.SubtotalIDR
84    }
85    o.TotalAmountIDR = total
86}

Aturan window 30 menit tertanam di entity, sehingga skenario pembatalan bisa diuji unit-test murni tanpa database maupun clock palsu yang rumit.


05.05 Domain Entities: Payment

Domain payment menangani interaksi dengan payment gateway, termasuk idempotency lewat external payment ID. Entity berikut mendefinisikan struktur payment dan payload webhook.

go
 1// internal/domain/payment/entity.go
 2
 3package payment
 4
 5import (
 6    "errors"
 7    "time"
 8
 9    "github.com/google/uuid"
10)
11
12var (
13    ErrInvalidSignature  = errors.New("invalid webhook signature")
14    ErrPaymentNotFound   = errors.New("payment not found")
15    ErrDuplicateWebhook  = errors.New("webhook already processed")
16)
17
18type Status string
19const (
20    StatusPending   Status = "PENDING"
21    StatusCompleted Status = "COMPLETED"
22    StatusFailed    Status = "FAILED"
23    StatusRefunded  Status = "REFUNDED"
24)
25
26type Payment struct {
27    ID                 uuid.UUID
28    OrderID            uuid.UUID
29    ExternalPaymentID  *string   // dari payment gateway
30    Status             Status
31    AmountIDR          int64     // cents
32    Method             string
33    WebhookReceivedAt  *time.Time
34    CreatedAt          time.Time
35    UpdatedAt          time.Time
36}
37
38// WebhookPayload: request dari payment gateway
39type WebhookPayload struct {
40    OrderID   uuid.UUID `json:"order_id"`
41    PaymentID string    `json:"payment_id"`
42    Status    string    `json:"status"` // "PAID" | "FAILED"
43    AmountIDR int64     `json:"amount_idr"`
44    Signature string    `json:"signature"`
45}

Field ExternalPaymentID dan sentinel ErrDuplicateWebhook adalah fondasi idempotency — nantinya memastikan webhook yang sama dari gateway tidak diproses dua kali.


05.06 Repository Interfaces

Setiap domain mendeklarasikan kontrak akses datanya sendiri lewat interface Repository, yang hidup di domain package agar bebas dependency infrastruktur. Interface user berikut mendefinisikan operasi CRUD user dan refresh token.

go
 1// internal/domain/user/repository.go
 2package user
 3
 4import (
 5    "context"
 6    "github.com/google/uuid"
 7)
 8
 9//go:generate mockgen -destination=../../../testutil/mock/user_repo_mock.go -package=mock -source=repository.go
10
11type Repository interface {
12    Create(ctx context.Context, u *User) error
13    GetByEmail(ctx context.Context, email string) (*User, error)   // nil, nil if not found
14    GetByID(ctx context.Context, id uuid.UUID) (*User, error)      // nil, nil if not found
15    SaveRefreshToken(ctx context.Context, rt *RefreshToken) error
16    GetRefreshToken(ctx context.Context, tokenHash string) (*RefreshToken, error)
17    DeleteRefreshToken(ctx context.Context, tokenHash string) error
18}

Perhatikan komentar nil, nil if not found — ini menegakkan pola bahwa “tidak ditemukan” bukan error, sesuai konstitusi di CLAUDE.md. Interface product berikut mengikuti pola yang sama untuk katalog dan manajemen stok.

go
 1// internal/domain/product/repository.go
 2package product
 3
 4import "context"
 5
 6//go:generate mockgen -destination=../../../testutil/mock/product_repo_mock.go -package=mock -source=repository.go
 7
 8type Repository interface {
 9    List(ctx context.Context, filter SearchFilter) ([]*Product, int, error)
10    GetByID(ctx context.Context, id string) (*Product, error)         // nil, nil if not found
11    ReserveStock(ctx context.Context, id string, qty int) error       // optimistic locking
12    RestoreStock(ctx context.Context, id string, qty int) error
13}

Method ReserveStock dan RestoreStock mengisyaratkan optimistic locking yang akan diimplementasikan di layer postgres. Interface order berikut menambahkan operasi pembatalan yang atomik.

go
 1// internal/domain/order/repository.go
 2package order
 3
 4import (
 5    "context"
 6    "github.com/google/uuid"
 7)
 8
 9//go:generate mockgen -destination=../../../testutil/mock/order_repo_mock.go -package=mock -source=repository.go
10
11type Repository interface {
12    Create(ctx context.Context, o *Order) error
13    GetByID(ctx context.Context, id uuid.UUID) (*Order, error)        // nil, nil if not found
14    GetByIDAndUserID(ctx context.Context, id, userID uuid.UUID) (*Order, error)
15    ListByUserID(ctx context.Context, userID uuid.UUID, page, limit int, status *Status) ([]*Order, int, error)
16    Cancel(ctx context.Context, id uuid.UUID) error  // set status CANCELLED + restore stock atomic
17}

Komentar pada Cancel menegaskan operasi tersebut harus atomik (update status + restore stok dalam satu transaksi). Terakhir, interface payment berikut menyediakan lookup berbasis external ID untuk idempotency.

go
 1// internal/domain/payment/repository.go
 2package payment
 3
 4import (
 5    "context"
 6    "github.com/google/uuid"
 7)
 8
 9//go:generate mockgen -destination=../../../testutil/mock/payment_repo_mock.go -package=mock -source=repository.go
10
11type Repository interface {
12    Create(ctx context.Context, p *Payment) error
13    GetByOrderID(ctx context.Context, orderID uuid.UUID) (*Payment, error)
14    GetByExternalID(ctx context.Context, externalID string) (*Payment, error) // idempotency
15    UpdateStatus(ctx context.Context, id uuid.UUID, status Status, externalID string) error
16}

Keempat interface ini mendefinisikan seluruh kontrak data proyek sebelum implementasi apa pun ditulis — sehingga usecase bisa dikembangkan dan diuji hanya berbekal mock dari interface ini.


05.07 Event Publisher Interface

Sistem event butuh abstraksi yang tidak mengikat usecase ke Kafka secara langsung. Interface Publisher berikut, beserta konstanta topic dan tipe payload, adalah kontrak yang nanti dipenuhi implementasi Kafka.

go
 1// internal/event/publisher.go
 2
 3package event
 4
 5import "context"
 6
 7//go:generate mockgen -destination=../../testutil/mock/event_publisher_mock.go -package=mock -source=publisher.go
 8
 9type Publisher interface {
10    Publish(ctx context.Context, e Event) error
11}
12
13type Event struct {
14    Topic   string
15    Key     string
16    Payload interface{}
17}
18
19// Kafka topic constants
20const (
21    TopicOrderPlaced      = "santekno.orders.placed"
22    TopicOrderConfirmed   = "santekno.orders.confirmed"
23    TopicOrderCancelled   = "santekno.orders.cancelled"
24    TopicOrderShipped     = "santekno.orders.shipped"
25    TopicPaymentCompleted = "santekno.payments.completed"
26    TopicPaymentFailed    = "santekno.payments.failed"
27)
28
29// Event payload types
30type OrderPlacedPayload struct {
31    OrderID       string `json:"order_id"`
32    UserID        string `json:"user_id"`
33    TotalAmountIDR int64 `json:"total_amount_idr"`
34    ItemCount     int    `json:"item_count"`
35}
36
37type OrderCancelledPayload struct {
38    OrderID string `json:"order_id"`
39    UserID  string `json:"user_id"`
40    Reason  string `json:"reason"`
41}
42
43type PaymentCompletedPayload struct {
44    PaymentID string `json:"payment_id"`
45    OrderID   string `json:"order_id"`
46    AmountIDR int64  `json:"amount_idr"`
47}

Dengan konstanta topic terpusat di satu tempat, penamaan event konsisten dan usecase cukup mengenal interface Publisher tanpa pernah mengimpor Kafka.


05.08 UseCase Stubs: Auth

Setiap usecase adalah satu file yang memetakan satu fitur dari spec. Stub RegisterUserUseCase berikut mendeklarasikan input, output, dependency, dan body “not implemented” yang akan diisi di Artikel 06.

go
 1// internal/usecase/auth/register.go
 2
 3package auth
 4
 5import (
 6    "context"
 7    "fmt"
 8    "time"
 9
10    "github.com/google/uuid"
11    "github.com/santekno/santekno-shop/internal/domain/user"
12)
13
14type RegisterInput struct {
15    Email    string
16    Password string
17    Name     string
18}
19
20type RegisterOutput struct {
21    ID        uuid.UUID
22    Email     string
23    Name      string
24    CreatedAt time.Time
25}
26
27type RegisterUserUseCase struct {
28    userRepo user.Repository
29}
30
31func NewRegisterUserUseCase(userRepo user.Repository) *RegisterUserUseCase {
32    return &RegisterUserUseCase{userRepo: userRepo}
33}
34
35func (uc *RegisterUserUseCase) Execute(ctx context.Context, input RegisterInput) (*RegisterOutput, error) {
36    return nil, fmt.Errorf("not implemented") // akan di-flesh-out Artikel 06
37}

Stub ini sudah mengekspos seluruh kontrak — input, output, dan dependency userRepo — sehingga handler dan container bisa di-wire lebih dulu. Stub login berikut mengikuti pola yang sama.

go
 1// internal/usecase/auth/login.go
 2
 3package auth
 4
 5import (
 6    "context"
 7    "fmt"
 8
 9    "github.com/santekno/santekno-shop/internal/domain/user"
10)
11
12type LoginInput struct {
13    Email    string
14    Password string
15    IP       string // untuk rate limiting
16}
17
18type LoginOutput struct {
19    AccessToken  string
20    RefreshToken string
21    ExpiresIn    int
22    TokenType    string
23}
24
25type LoginUserUseCase struct {
26    userRepo user.Repository
27    // tokenSvc  akan ditambah Artikel 06
28    // rateLimiter akan ditambah Artikel 06
29}
30
31func NewLoginUserUseCase(userRepo user.Repository) *LoginUserUseCase {
32    return &LoginUserUseCase{userRepo: userRepo}
33}
34
35func (uc *LoginUserUseCase) Execute(ctx context.Context, input LoginInput) (*LoginOutput, error) {
36    return nil, fmt.Errorf("not implemented")
37}

Komentar TODO pada dependency yang belum ada (tokenSvc, rateLimiter) menandai dengan jelas apa yang perlu ditambahkan saat implementasi, tanpa memblok compile sekarang.


05.09 UseCase Stubs: Order

Domain order punya tiga usecase utama. Stub PlaceOrderUseCase berikut menunjukkan usecase dengan banyak dependency — repository order, product, dan event publisher — semuanya di-inject via constructor.

go
 1// internal/usecase/order/place_order.go
 2
 3package order
 4
 5import (
 6    "context"
 7    "fmt"
 8
 9    "github.com/google/uuid"
10    "github.com/santekno/santekno-shop/internal/domain/order"
11    "github.com/santekno/santekno-shop/internal/domain/product"
12    "github.com/santekno/santekno-shop/internal/event"
13)
14
15type PlaceOrderInput struct {
16    UserID            uuid.UUID
17    Items             []PlaceOrderItem
18    ShippingAddressID uuid.UUID
19    PaymentMethod     order.PaymentMethod
20}
21
22type PlaceOrderItem struct {
23    ProductID uuid.UUID
24    Quantity  int
25}
26
27type PlaceOrderUseCase struct {
28    orderRepo   order.Repository
29    productRepo product.Repository
30    publisher   event.Publisher
31}
32
33func NewPlaceOrderUseCase(
34    orderRepo order.Repository,
35    productRepo product.Repository,
36    publisher event.Publisher,
37) *PlaceOrderUseCase {
38    return &PlaceOrderUseCase{
39        orderRepo:   orderRepo,
40        productRepo: productRepo,
41        publisher:   publisher,
42    }
43}
44
45func (uc *PlaceOrderUseCase) Execute(ctx context.Context, input PlaceOrderInput) (*order.Order, error) {
46    return nil, fmt.Errorf("not implemented") // akan di-flesh-out Artikel 08
47}

Dependency yang eksplisit di constructor membuat kebutuhan usecase ini transparan sejak awal. Stub cancel order berikut jauh lebih ramping karena hanya butuh dua dependency.

go
 1// internal/usecase/order/cancel_order.go
 2
 3package order
 4
 5import (
 6    "context"
 7    "fmt"
 8
 9    "github.com/google/uuid"
10    "github.com/santekno/santekno-shop/internal/domain/order"
11    "github.com/santekno/santekno-shop/internal/event"
12)
13
14type CancelOrderUseCase struct {
15    orderRepo order.Repository
16    publisher event.Publisher
17}
18
19func NewCancelOrderUseCase(orderRepo order.Repository, publisher event.Publisher) *CancelOrderUseCase {
20    return &CancelOrderUseCase{orderRepo: orderRepo, publisher: publisher}
21}
22
23func (uc *CancelOrderUseCase) Execute(ctx context.Context, orderID, userID uuid.UUID) error {
24    return fmt.Errorf("not implemented")
25}

Signature Execute yang menerima orderID dan userID sudah memaksa kepemilikan order diverifikasi — user hanya bisa membatalkan ordernya sendiri. Stub get orders berikut melengkapi domain ini dengan output berpaginasi.

go
 1// internal/usecase/order/get_orders.go
 2
 3package order
 4
 5import (
 6    "context"
 7    "fmt"
 8
 9    "github.com/google/uuid"
10    "github.com/santekno/santekno-shop/internal/domain/order"
11)
12
13type GetOrdersInput struct {
14    UserID uuid.UUID
15    Page   int
16    Limit  int
17    Status *order.Status
18}
19
20type GetOrdersOutput struct {
21    Orders     []*order.Order
22    Total      int
23    Page       int
24    Limit      int
25    TotalPages int
26}
27
28type GetOrdersUseCase struct {
29    orderRepo order.Repository
30}
31
32func NewGetOrdersUseCase(orderRepo order.Repository) *GetOrdersUseCase {
33    return &GetOrdersUseCase{orderRepo: orderRepo}
34}
35
36func (uc *GetOrdersUseCase) Execute(ctx context.Context, input GetOrdersInput) (*GetOrdersOutput, error) {
37    return nil, fmt.Errorf("not implemented")
38}

Struktur GetOrdersOutput dengan field pagination sudah menetapkan bentuk respons final, sehingga handler dan test bisa disiapkan tanpa menunggu logika query selesai.


05.10 UseCase Stubs: Product & Payment

Domain product dan payment melengkapi ketujuh usecase skeleton. Stub ListProductsUseCase berikut menerima SearchFilter dan mengembalikan daftar produk berpaginasi.

go
 1// internal/usecase/product/list_products.go
 2
 3package product
 4
 5import (
 6    "context"
 7    "fmt"
 8
 9    "github.com/santekno/santekno-shop/internal/domain/product"
10)
11
12type ListProductsUseCase struct {
13    productRepo product.Repository
14}
15
16func NewListProductsUseCase(repo product.Repository) *ListProductsUseCase {
17    return &ListProductsUseCase{productRepo: repo}
18}
19
20func (uc *ListProductsUseCase) Execute(ctx context.Context, filter product.SearchFilter) ([]*product.Product, int, error) {
21    return nil, 0, fmt.Errorf("not implemented")
22}

Stub get product berikut menunjukkan pola alias import (domainProduct) untuk menghindari tabrakan nama antara package usecase dan domain yang sama-sama bernama product.

go
 1// internal/usecase/product/get_product.go
 2
 3package product
 4
 5import (
 6    "context"
 7    "fmt"
 8
 9    domainProduct "github.com/santekno/santekno-shop/internal/domain/product"
10)
11
12type GetProductUseCase struct {
13    productRepo domainProduct.Repository
14}
15
16func NewGetProductUseCase(repo domainProduct.Repository) *GetProductUseCase {
17    return &GetProductUseCase{productRepo: repo}
18}
19
20func (uc *GetProductUseCase) Execute(ctx context.Context, id string) (*domainProduct.Product, error) {
21    return nil, fmt.Errorf("not implemented")
22}

Stub handle webhook berikut adalah usecase payment yang menampung banyak dependency sekaligus, termasuk secret untuk verifikasi signature.

go
 1// internal/usecase/payment/handle_webhook.go
 2
 3package payment
 4
 5import (
 6    "context"
 7    "fmt"
 8
 9    "github.com/santekno/santekno-shop/internal/domain/order"
10    "github.com/santekno/santekno-shop/internal/domain/payment"
11    "github.com/santekno/santekno-shop/internal/event"
12)
13
14type HandleWebhookUseCase struct {
15    paymentRepo payment.Repository
16    orderRepo   order.Repository
17    publisher   event.Publisher
18    webhookSecret string
19}
20
21func NewHandleWebhookUseCase(
22    paymentRepo payment.Repository,
23    orderRepo order.Repository,
24    publisher event.Publisher,
25    secret string,
26) *HandleWebhookUseCase {
27    return &HandleWebhookUseCase{
28        paymentRepo:   paymentRepo,
29        orderRepo:     orderRepo,
30        publisher:     publisher,
31        webhookSecret: secret,
32    }
33}
34
35func (uc *HandleWebhookUseCase) Execute(ctx context.Context, payload payment.WebhookPayload) error {
36    return fmt.Errorf("not implemented")
37}

Ketujuh usecase stub kini lengkap — masing-masing memetakan satu fitur spec dengan kontrak yang jelas, siap diisi logikanya satu per satu di artikel implementasi.


05.11 Handler Stubs: Auth

Handler adalah lapisan tertipis yang hanya menerima usecase (bukan implementasi konkret). Handler auth berikut menyediakan tiga endpoint yang semuanya mengembalikan 501 hingga diimplementasikan.

go
 1// delivery/http/handler/auth_handler.go
 2
 3package handler
 4
 5import (
 6    "net/http"
 7
 8    "github.com/labstack/echo/v4"
 9    "github.com/santekno/santekno-shop/internal/usecase/auth"
10)
11
12type AuthHandler struct {
13    registerUC *auth.RegisterUserUseCase
14    loginUC    *auth.LoginUserUseCase
15}
16
17func NewAuthHandler(registerUC *auth.RegisterUserUseCase, loginUC *auth.LoginUserUseCase) *AuthHandler {
18    return &AuthHandler{registerUC: registerUC, loginUC: loginUC}
19}
20
21func (h *AuthHandler) Register(c echo.Context) error {
22    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
23}
24
25func (h *AuthHandler) Login(c echo.Context) error {
26    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
27}
28
29func (h *AuthHandler) RefreshToken(c echo.Context) error {
30    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
31}

Karena handler hanya menyimpan pointer ke tipe usecase, ia bisa langsung di-wire ke router dan diuji rutenya bahkan sebelum logika bisnis ada.


05.12 Handler Stubs: Order & Product

Handler order dan product melengkapi wiring HTTP. Handler order berikut menampung tiga usecase order sekaligus.

go
 1// delivery/http/handler/order_handler.go
 2
 3package handler
 4
 5import (
 6    "net/http"
 7    "github.com/labstack/echo/v4"
 8    "github.com/santekno/santekno-shop/internal/usecase/order"
 9)
10
11type OrderHandler struct {
12    placeOrderUC  *order.PlaceOrderUseCase
13    cancelOrderUC *order.CancelOrderUseCase
14    getOrdersUC   *order.GetOrdersUseCase
15}
16
17func NewOrderHandler(place *order.PlaceOrderUseCase, cancel *order.CancelOrderUseCase, get *order.GetOrdersUseCase) *OrderHandler {
18    return &OrderHandler{placeOrderUC: place, cancelOrderUC: cancel, getOrdersUC: get}
19}
20
21func (h *OrderHandler) PlaceOrder(c echo.Context) error {
22    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
23}
24
25func (h *OrderHandler) CancelOrder(c echo.Context) error {
26    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
27}
28
29func (h *OrderHandler) GetOrders(c echo.Context) error {
30    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
31}

Handler product berikut mengikuti pola identik untuk endpoint katalog.

go
 1// delivery/http/handler/product_handler.go
 2
 3package handler
 4
 5import (
 6    "net/http"
 7    "github.com/labstack/echo/v4"
 8    "github.com/santekno/santekno-shop/internal/usecase/product"
 9)
10
11type ProductHandler struct {
12    listUC *product.ListProductsUseCase
13    getUC  *product.GetProductUseCase
14}
15
16func NewProductHandler(list *product.ListProductsUseCase, get *product.GetProductUseCase) *ProductHandler {
17    return &ProductHandler{listUC: list, getUC: get}
18}
19
20func (h *ProductHandler) List(c echo.Context) error {
21    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
22}
23
24func (h *ProductHandler) Get(c echo.Context) error {
25    return c.JSON(http.StatusNotImplemented, map[string]string{"error": "NOT_IMPLEMENTED"})
26}

Keseragaman pola handler ini membuat routing bisa disusun penuh sekarang, dan implementasi tiap endpoint tinggal mengganti body 501 dengan logika sesungguhnya.


05.13 Outbox Event Table dan Worker Stub

Untuk publikasi event yang andal, kita memakai pola outbox: event ditulis ke tabel dalam transaksi yang sama, lalu worker background mempublikasikannya. Stub worker berikut menyiapkan loop polling yang siap di-extend.

go
 1// internal/event/outbox/worker.go
 2
 3package outbox
 4
 5import (
 6    "context"
 7    "encoding/json"
 8    "fmt"
 9    "time"
10
11    "github.com/jackc/pgx/v5/pgxpool"
12)
13
14type OutboxEvent struct {
15    ID        string
16    EventType string
17    Topic     string
18    Payload   json.RawMessage
19    CreatedAt time.Time
20}
21
22type Worker struct {
23    db      *pgxpool.Pool
24    // publisher akan ditambah Artikel 12 (Kafka)
25    stopCh  chan struct{}
26}
27
28func NewWorker(db *pgxpool.Pool) *Worker {
29    return &Worker{
30        db:     db,
31        stopCh: make(chan struct{}),
32    }
33}
34
35func (w *Worker) Run(ctx context.Context) {
36    ticker := time.NewTicker(5 * time.Second)
37    defer ticker.Stop()
38
39    for {
40        select {
41        case <-ctx.Done():
42            return
43        case <-w.stopCh:
44            return
45        case <-ticker.C:
46            if err := w.processOnce(ctx); err != nil {
47                fmt.Printf("outbox worker error: %v\n", err)
48            }
49        }
50    }
51}
52
53func (w *Worker) Stop() {
54    close(w.stopCh)
55}
56
57func (w *Worker) processOnce(ctx context.Context) error {
58    // akan di-implement Artikel 12
59    return nil
60}

Kerangka worker dengan stopCh dan ticker sudah menangani lifecycle (start/stop) dengan benar, sehingga implementasi Artikel 12 cukup mengisi processOnce tanpa memikirkan concurrency dasar.


05.14 Middleware Stubs

Middleware auth dan rate limit disiapkan sebagai stub agar router bisa memasangnya lebih dulu. Stub JWT berikut sudah menangani parsing header Bearer, meski validasi penuh menyusul di Artikel 06.

go
 1// delivery/http/middleware/auth.go
 2
 3package middleware
 4
 5import (
 6    "net/http"
 7    "strings"
 8
 9    "github.com/labstack/echo/v4"
10)
11
12const UserIDKey = "user_id"
13
14// JWT middleware — full implementation di Artikel 06
15func JWT(secret string) echo.MiddlewareFunc {
16    return func(next echo.HandlerFunc) echo.HandlerFunc {
17        return func(c echo.Context) error {
18            auth := c.Request().Header.Get("Authorization")
19            if !strings.HasPrefix(auth, "Bearer ") {
20                return c.JSON(http.StatusUnauthorized, map[string]string{
21                    "error": "UNAUTHORIZED",
22                })
23            }
24            // Full JWT validation di Artikel 06
25            return next(c)
26        }
27    }
28}
29
30// GetUserID dari context — set oleh JWT middleware
31func GetUserID(c echo.Context) (string, bool) {
32    uid, ok := c.Get(UserIDKey).(string)
33    return uid, ok
34}

Stub rate limit berikut sengaja selalu meloloskan request, dengan implementasi Redis ditunda ke Artikel 06.

go
 1// delivery/http/middleware/rate_limit.go
 2
 3package middleware
 4
 5import (
 6    "github.com/labstack/echo/v4"
 7)
 8
 9// RateLimit middleware — full implementation di Artikel 06 dengan Redis
10func RateLimit(maxAttempts int, windowSecs int) echo.MiddlewareFunc {
11    return func(next echo.HandlerFunc) echo.HandlerFunc {
12        return func(c echo.Context) error {
13            // stub: selalu allow, implementasi Redis di Artikel 06
14            return next(c)
15        }
16    }
17}

Dengan signature middleware sudah final, router bisa memasang chain lengkap sejak sekarang — perilaku sesungguhnya tinggal diisi tanpa mengubah cara pemasangannya.


05.15 Error Handling Package

Alih-alih menyebar mapping error ke setiap handler, kita pusatkan di satu tempat. Package apierror berikut menerjemahkan setiap domain error menjadi respons HTTP yang konsisten.

go
 1// pkg/apierror/handler.go
 2
 3package apierror
 4
 5import (
 6    "errors"
 7    "net/http"
 8
 9    "github.com/labstack/echo/v4"
10    "github.com/santekno/santekno-shop/internal/domain/order"
11    "github.com/santekno/santekno-shop/internal/domain/payment"
12    "github.com/santekno/santekno-shop/internal/domain/product"
13    "github.com/santekno/santekno-shop/internal/domain/user"
14)
15
16type Response struct {
17    Error  string `json:"error"`
18    Detail string `json:"detail,omitempty"`
19}
20
21func Handle(c echo.Context, err error) error {
22    switch {
23    // User errors
24    case errors.Is(err, user.ErrEmailAlreadyRegistered):
25        return c.JSON(http.StatusConflict, Response{Error: "EMAIL_ALREADY_REGISTERED"})
26    case errors.Is(err, user.ErrWeakPassword):
27        return c.JSON(http.StatusBadRequest, Response{Error: "WEAK_PASSWORD"})
28    case errors.Is(err, user.ErrInvalidCredentials):
29        return c.JSON(http.StatusUnauthorized, Response{Error: "INVALID_CREDENTIALS"})
30    case errors.Is(err, user.ErrTooManyAttempts):
31        return c.JSON(http.StatusTooManyRequests, Response{Error: "TOO_MANY_ATTEMPTS"})
32
33    // Order errors
34    case errors.Is(err, order.ErrOrderNotFound):
35        return c.JSON(http.StatusNotFound, Response{Error: "ORDER_NOT_FOUND"})
36    case errors.Is(err, order.ErrOrderNotCancellable):
37        return c.JSON(http.StatusConflict, Response{Error: "ORDER_NOT_CANCELLABLE"})
38    case errors.Is(err, order.ErrCancelWindowExpired):
39        return c.JSON(http.StatusConflict, Response{Error: "CANCEL_WINDOW_EXPIRED"})
40    case errors.Is(err, order.ErrEmptyOrder):
41        return c.JSON(http.StatusBadRequest, Response{Error: "EMPTY_ORDER"})
42
43    // Product errors
44    case errors.Is(err, product.ErrProductNotFound):
45        return c.JSON(http.StatusNotFound, Response{Error: "PRODUCT_NOT_FOUND"})
46    case errors.Is(err, product.ErrInsufficientStock):
47        return c.JSON(http.StatusUnprocessableEntity, Response{Error: "INSUFFICIENT_STOCK"})
48
49    // Payment errors
50    case errors.Is(err, payment.ErrInvalidSignature):
51        return c.JSON(http.StatusUnauthorized, Response{Error: "INVALID_SIGNATURE"})
52
53    default:
54        c.Logger().Errorf("unhandled error: %v", err)
55        return c.JSON(http.StatusInternalServerError, Response{Error: "INTERNAL_ERROR"})
56    }
57}

Karena errors.Is menembus wrapping, mapping terpusat ini tetap bekerja meski error dibungkus berlapis di usecase — setiap handler cukup memanggil apierror.Handle(c, err).


05.16 Pagination Helper

Banyak endpoint list membutuhkan pagination yang seragam. Package berikut menyediakan parsing query param, penyusunan metadata, dan perhitungan offset.

go
 1// pkg/pagination/pagination.go
 2
 3package pagination
 4
 5import (
 6    "math"
 7    "strconv"
 8
 9    "github.com/labstack/echo/v4"
10)
11
12type Params struct {
13    Page  int
14    Limit int
15}
16
17type Meta struct {
18    Page       int `json:"page"`
19    Limit      int `json:"limit"`
20    Total      int `json:"total"`
21    TotalPages int `json:"total_pages"`
22}
23
24func ParseParams(c echo.Context) Params {
25    page, _ := strconv.Atoi(c.QueryParam("page"))
26    if page < 1 { page = 1 }
27
28    limit, _ := strconv.Atoi(c.QueryParam("limit"))
29    if limit < 1 { limit = 10 }
30    if limit > 100 { limit = 100 }
31
32    return Params{Page: page, Limit: limit}
33}
34
35func NewMeta(page, limit, total int) Meta {
36    totalPages := int(math.Ceil(float64(total) / float64(limit)))
37    if totalPages < 1 { totalPages = 1 }
38    return Meta{Page: page, Limit: limit, Total: total, TotalPages: totalPages}
39}
40
41func Offset(page, limit int) int {
42    return (page - 1) * limit
43}

Helper ini menjaga aturan pagination (batas limit 100, default 10) tetap satu sumber, sehingga tidak ada endpoint yang diam-diam memakai aturan berbeda.


05.17 Money Helper

Karena semua uang disimpan sebagai IDR cents, kita butuh helper untuk konversi dan formatting saat display. Package money berikut menyediakannya tanpa pernah menyentuh float.

go
 1// pkg/money/money.go
 2// Helper untuk monetary values (IDR cents)
 3
 4package money
 5
 6import "fmt"
 7
 8// FormatIDR: 5_000_000 cents → "Rp 50.000"
 9func FormatIDR(cents int64) string {
10    rupiah := cents / 100
11    return fmt.Sprintf("Rp %s", formatThousands(rupiah))
12}
13
14func formatThousands(n int64) string {
15    s := fmt.Sprintf("%d", n)
16    result := ""
17    for i, r := range s {
18        if i > 0 && (len(s)-i)%3 == 0 {
19            result += "."
20        }
21        result += string(r)
22    }
23    return result
24}
25
26// ToRupiah: cents ke rupiah (untuk display saja)
27func ToRupiah(cents int64) int64 {
28    return cents / 100
29}
30
31// FromRupiah: rupiah ke cents (untuk input konversi)
32func FromRupiah(rupiah int64) int64 {
33    return rupiah * 100
34}

Semua operasi memakai aritmetika integer, sehingga tidak ada risiko pembulatan float yang bisa membuat total uang meleset satu sen — masalah klasik pada sistem finansial.


05.18 Verify Skeleton Compiles

Invariant kunci skeleton adalah: seluruhnya harus compile meski semua logika masih “not implemented”. Rangkaian perintah berikut memverifikasi build, vet, lint, generate mock, hingga test.

bash
 1# Setelah semua file skeleton dibuat, verify compile:
 2
 3go build ./...
 4# Expected: SUCCESS (tidak ada "not implemented" yang memblok compile)
 5# "not implemented" adalah runtime error, bukan compile error
 6
 7go vet ./...
 8# Expected: 0 issues
 9
10golangci-lint run
11# Expected: clean (atau hanya warnings untuk stubs)
12
13go generate ./...
14# Generate semua mocks dari go:generate directives
15
16# Run test suite (akan fail karena semua "not implemented"):
17go test ./... 2>&1 | head -20
18# Expected: FAIL (ini OK - tests akan pass setelah Artikel 06-10)

go build yang lulus dengan seluruh stub adalah bukti bahwa arsitektur sudah solid dan interface saling konsisten — test yang gagal di sini justru diharapkan.


05.19 Specify Audit dengan Skeleton

Untuk mengukur baseline compliance terhadap spec, kita jalankan specify audit. Perintah berikut menampilkan skor awal beserta daftar AC yang belum ter-cover.

bash
 1# Jalankan specify audit untuk melihat baseline compliance
 2
 3specify audit --output json | jq '{
 4  score: .score,
 5  total_acs: .total,
 6  covered: .covered,
 7  uncovered: [.features[] | select(.status == "uncovered") | .feature]
 8}'
 9
10# Expected output (dengan skeleton stubs):
11# {
12#   "score": 5,
13#   "total_acs": 29,
14#   "covered": 1,
15#   "uncovered": ["user-registration", "user-login", "place-order", ...]
16# }
17
18# Score rendah wajar — skeleton belum implement.
19# Setiap artikel berikutnya akan naikan score.
20# Target setelah Artikel 10: score >= 80

Skor ~5/100 di titik ini adalah baseline yang sehat: ia memberi angka konkret untuk mengukur progres saat setiap artikel implementasi menaikkannya.


05.20 Ringkasan: Skeleton Siap, Implementasi Bisa Mulai

Dengan skeleton yang selesai, tim bisa bekerja paralel di atas fondasi yang jelas:

Struktur yang ada:

  • 4 domain packages dengan entities dan interfaces
  • 7 usecase stubs (1 file = 1 usecase, dari spec ACs)
  • 3 handler stubs siap di-wire ke router
  • Error handling terpusat di pkg/apierror
  • Event publisher interface dan topic constants
  • Outbox worker stub siap di-extend

Go build: PASS. CI pipeline berjalan. Stubs mengembalikan “not implemented” — itu memang yang diharapkan pada tahap ini.

Ke-29 spec AC kini terpetakan ke 7 file usecase, 4 handler, dan 4 repository interface. Setiap dependency eksplisit, setiap interface terdeklarasi, setiap stub compile. Arsitektur dibuat terlihat sebelum implementasi dimulai.

Di artikel berikutnya, kita flesh out domain pertama: User Service — auth, JWT, bcrypt, Redis rate limiting, dan refresh token rotation, semuanya diturunkan dari spec.

Artikel Terkait

💬 Komentar