Golang E-Commerce Project Skeleton: Clean Architecture from an SDD Spec
Build the Santekno Shop project skeleton with Clean Architecture. Domain entities, repository interfaces, usecase stubs, handler wiring — all derived from the SDD spec in Article 01.
A golang project skeleton with clean architecture is the bridge between spec and implementation: the entire code structure — entities, interfaces, and stubs — is created first so the team can work in parallel and CI can run immediately. The spec is written, the architecture is designed, the OpenAPI is created, and CI has been running since the previous article.
In this article we build the complete Santekno Shop framework: domain entities that represent the business objects from the spec, repository interfaces as data contracts, usecase and handler stubs, and centralized error handling. It all compiles, it is all consistent, and it is all ready to be fleshed out one domain per article.
05.01 Philosophy: Spec → Interface → Implementation
The order in which the skeleton is built is not arbitrary — it follows the flow from behavior (spec) to contract (interface) to implementation. The diagram below summarizes the correct order and explains why skeleton-first empowers parallel work.
1THE CORRECT ORDER:
2
31. Spec (Article 01) → defines behavior
42. Domain entities → represent business objects from the spec
53. Repository interfaces → define data contracts
64. UseCase interfaces + stubs → one file per usecase, from spec ACs
75. Handler stubs → wiring to the usecase
86. Implementation (Articles 06-10) → flesh out one by one
9
10Why it matters:
11 With the skeleton first, the whole team can be assigned parallel work.
12 "You implement UserRepo, I'll implement OrderUseCase."
13 Compile still OK because stubs return errors.New("not implemented").
14 CI pipeline runs immediately once the skeleton is done.Because interfaces are defined first, developers can work on different parts without waiting on each other — the contract is clear before a single line of logic is written.
05.02 Domain Entities: User
The domain layer is the business core, free of infrastructure dependencies. The User entity below defines the data structure, sentinel errors, and password validation rule directly inside the 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 + at least 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}The password rule lives in the entity, not in the usecase — so it can be tested without any infrastructure and stays consistent wherever it is called.
05.03 Domain Entities: Product
The Product entity holds the core catalog rules, including the notion of available stock that distinguishes physical stock from reserved stock. Note the use of int64 for all monetary values (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 that can be ordered (not yet reserved)
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}The AvailableStock() method encapsulates the calculation of orderable stock, so the ordering usecase does not need to know reservation details — it just calls one method.
05.04 Domain Entities: Order
Order is the entity richest in business rules: a status enum, a cancellation window, and total calculation. Below is its full definition complete with the CanBeCancelled() and CalculateTotal() methods.
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 at order time
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 + within the 30-minute window
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: recompute the total from 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}The 30-minute window rule is embedded in the entity, so the cancellation scenario can be verified with pure unit tests, without a database or a complicated fake clock.
05.05 Domain Entities: Payment
The payment domain handles interaction with the payment gateway, including idempotency via an external payment ID. The entity below defines the payment structure and the webhook payload.
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 // from the 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 from the 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}The ExternalPaymentID field and the ErrDuplicateWebhook sentinel are the foundation of idempotency — later ensuring the same webhook from the gateway is not processed twice.
05.06 Repository Interfaces
Each domain declares its own data-access contract via a Repository interface, which lives in the domain package so it is free of infrastructure dependencies. The user interface below defines the user and refresh-token CRUD operations.
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}Note the nil, nil if not found comment — it enforces the pattern that “not found” is not an error, per the constitution in CLAUDE.md. The product interface below follows the same pattern for the catalog and stock management.
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}The ReserveStock and RestoreStock methods hint at the optimistic locking that will be implemented in the postgres layer. The order interface below adds an atomic cancellation operation.
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}The comment on Cancel stresses that the operation must be atomic (status update + stock restore in a single transaction). Finally, the payment interface below provides an external-ID-based lookup for 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}These four interfaces define the project’s entire data contract before any implementation is written — so usecases can be developed and tested with nothing but mocks of these interfaces.
05.07 Event Publisher Interface
The event system needs an abstraction that does not tie the usecase directly to Kafka. The Publisher interface below, along with the topic constants and payload types, is the contract that a Kafka implementation will later fulfill.
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}With the topic constants centralized in one place, event naming stays consistent and the usecase only needs to know the Publisher interface without ever importing Kafka.
05.08 UseCase Stubs: Auth
Each usecase is a single file that maps one feature from the spec. The RegisterUserUseCase stub below declares the input, output, dependency, and a “not implemented” body that will be filled in Article 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") // will be fleshed out in Article 06
37}This stub already exposes the entire contract — input, output, and the userRepo dependency — so the handler and container can be wired first. The login stub below follows the same pattern.
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 // for 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 will be added in Article 06
28 // rateLimiter will be added in Article 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}The TODO comments on the not-yet-present dependencies (tokenSvc, rateLimiter) clearly mark what needs to be added at implementation time, without blocking compilation now.
05.09 UseCase Stubs: Order
The order domain has three main usecases. The PlaceOrderUseCase stub below shows a usecase with many dependencies — the order and product repositories and the event publisher — all injected via the 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") // will be fleshed out in Article 08
47}The explicit dependencies in the constructor make this usecase’s needs transparent from the start. The cancel-order stub below is far leaner because it only needs two dependencies.
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}The Execute signature taking both orderID and userID already forces order ownership to be verified — a user can only cancel their own order. The get-orders stub below completes this domain with a paginated output.
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}The GetOrdersOutput struct with its pagination fields already fixes the final response shape, so the handler and tests can be prepared without waiting for the query logic to be finished.
05.10 UseCase Stubs: Product & Payment
The product and payment domains round out the seven skeleton usecases. The ListProductsUseCase stub below accepts a SearchFilter and returns a paginated product list.
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}The get-product stub below demonstrates the import-alias pattern (domainProduct) to avoid a name clash between the usecase package and the domain package that are both named 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}The handle-webhook stub below is a payment usecase that holds many dependencies at once, including the secret used for signature verification.
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}The seven usecase stubs are now complete — each maps one spec feature with a clear contract, ready to have its logic filled in one by one in the implementation articles.
05.11 Handler Stubs: Auth
The handler is the thinnest layer that receives only the usecase (not the concrete implementation). The auth handler below provides three endpoints that all return 501 until implemented.
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}Because the handler only holds pointers to the usecase types, it can be wired straight into the router and its routes tested even before the business logic exists.
05.12 Handler Stubs: Order & Product
The order and product handlers complete the HTTP wiring. The order handler below holds all three order usecases at once.
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}The product handler below follows an identical pattern for the catalog endpoints.
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}This uniformity of handler pattern lets routing be fully assembled now, and implementing each endpoint just means replacing the 501 body with the real logic.
05.13 Outbox Event Table and Worker Stub
For reliable event publishing, we use the outbox pattern: the event is written to a table in the same transaction, then a background worker publishes it. The worker stub below sets up a polling loop that is ready to be extended.
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 will be added in Article 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 // will be implemented in Article 12
59 return nil
60}The worker skeleton with stopCh and a ticker already handles the lifecycle (start/stop) correctly, so the Article 12 implementation just needs to fill in processOnce without worrying about the basic concurrency.
05.14 Middleware Stubs
The auth and rate-limit middleware are prepared as stubs so the router can mount them first. The JWT stub below already handles parsing the Bearer header, though the full validation comes later in Article 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 in Article 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 in Article 06
25 return next(c)
26 }
27 }
28}
29
30// GetUserID from context — set by the JWT middleware
31func GetUserID(c echo.Context) (string, bool) {
32 uid, ok := c.Get(UserIDKey).(string)
33 return uid, ok
34}The rate-limit stub below deliberately always lets the request through, with the Redis implementation deferred to Article 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 in Article 06 with 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: always allow, Redis implementation in Article 06
14 return next(c)
15 }
16 }
17}With the middleware signatures already final, the router can mount the full chain right now — the real behavior just needs to be filled in without changing how it is mounted.
05.15 Error Handling Package
Instead of scattering error mapping across every handler, we centralize it in one place. The apierror package below translates each domain error into a consistent HTTP response.
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}Because errors.Is sees through wrapping, this centralized mapping still works even when the error is wrapped in layers in the usecase — every handler just calls apierror.Handle(c, err).
05.16 Pagination Helper
Many list endpoints need uniform pagination. The package below provides query-param parsing, metadata construction, and offset calculation.
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}This helper keeps the pagination rules (limit cap of 100, default 10) in a single source, so no endpoint silently uses a different rule.
05.17 Money Helper
Because all money is stored as IDR cents, we need a helper for conversion and formatting when displaying. The money package below provides it without ever touching a float.
1// pkg/money/money.go
2// Helper for 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 to rupiah (for display only)
27func ToRupiah(cents int64) int64 {
28 return cents / 100
29}
30
31// FromRupiah: rupiah to cents (for input conversion)
32func FromRupiah(rupiah int64) int64 {
33 return rupiah * 100
34}All operations use integer arithmetic, so there is no risk of float rounding that could throw the total off by a cent — the classic problem of financial systems.
05.18 Verify the Skeleton Compiles
The key invariant of the skeleton is: everything must compile even though all the logic is still “not implemented.” The command sequence below verifies build, vet, lint, mock generation, and test.
1# After all skeleton files are created, verify compile:
2
3go build ./...
4# Expected: SUCCESS (no "not implemented" blocks compilation)
5# "not implemented" is a runtime error, not a compile error
6
7go vet ./...
8# Expected: 0 issues
9
10golangci-lint run
11# Expected: clean (or only warnings for stubs)
12
13go generate ./...
14# Generate all mocks from the go:generate directives
15
16# Run the test suite (will fail because everything is "not implemented"):
17go test ./... 2>&1 | head -20
18# Expected: FAIL (this is OK — tests will pass after Articles 06-10)A passing go build with all stubs in place is proof that the architecture is solid and the interfaces are mutually consistent — the tests failing here are actually expected.
05.19 Specify Audit with the Skeleton
To measure the baseline compliance against the spec, we run specify audit. The command below shows the initial score along with the list of ACs not yet covered.
1# Run specify audit to see the 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 (with skeleton stubs):
11# {
12# "score": 5,
13# "total_acs": 29,
14# "covered": 1,
15# "uncovered": ["user-registration", "user-login", "place-order", ...]
16# }
17
18# Low score is normal — the skeleton is not implemented yet.
19# Each subsequent article will raise the score.
20# Target after Article 10: score >= 80A score of ~5/100 at this point is a healthy baseline: it gives a concrete number to measure progress against as each implementation article raises it.
05.20 Summary: Skeleton Ready, Implementation Can Begin
With the skeleton finished, the team can work in parallel on a clear foundation:
What exists:
- 4 domain packages with entities and interfaces
- 7 usecase stubs (1 file = 1 usecase, from spec ACs)
- 3 handler stubs ready to be wired to the router
- Centralized error handling in
pkg/apierror - Event publisher interface and topic constants
- Outbox worker stub ready to extend
Go build: PASS. The CI pipeline runs. Stubs return “not implemented” — which is exactly what is expected at this stage.
The 29 spec ACs are now mapped to 7 usecase files, 4 handlers, and 4 repository interfaces. Every dependency is explicit, every interface is declared, every stub compiles. The architecture is made visible before implementation begins.
In the next article, we flesh out the first domain: the User Service — auth, JWT, bcrypt, Redis rate limiting, and refresh token rotation, all derived from the spec.