Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
10 Nov 2026 · 24 min read ·Article 85 / 208
Go

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.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

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.

text
 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.

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 + 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).

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 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.

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 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.

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   // 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.

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}

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.

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}

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.

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}

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.

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}

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.

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}

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.

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") // 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.

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 // 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.

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") // 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.

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}

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.

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}

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.

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}

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.

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}

The handle-webhook stub below is a payment usecase that holds many dependencies at once, including the secret used for signature verification.

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}

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.

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}

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.

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}

The product handler below follows an identical pattern for the catalog endpoints.

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}

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.

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 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.

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 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.

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 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.

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}

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.

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}

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.

go
 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.

bash
 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.

bash
 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 >= 80

A 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.

Related Articles

💬 Comments