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.
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.
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.
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).
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().
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 >= 80Skor ~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.