34 KiB
Technical Specifications & Architecture
This document defines the technical stack, architecture patterns, code standards, and implementation details for the Terminal Coffee Shop project. Read this before writing any code.
Table of Contents
- Development Workflow
- Technology Stack
- Database Schema
- API Specifications
- Code Organization
- Code Style Guide
- Error Handling
- Testing Standards
- Common Patterns
Development Workflow
Documentation-First Approach
IMPORTANT: Before implementing features using any library or technology, always fetch and read the official documentation first.
Process for Using New Libraries
- Identify the library/technology you need to use (e.g., Chi router, GORM, Stripe SDK)
- Fetch the documentation using WebFetch:
WebFetch the official documentation URL for the library Read through relevant sections (getting started, API reference, examples) - Understand the current best practices from the docs (not from memory/assumptions)
- Implement following the documented patterns
- Reference the docs if you encounter issues
Why This Matters
- Libraries evolve - documentation ensures you use current patterns
- Prevents deprecated patterns or outdated approaches
- Helps discover features you might not know about
- Ensures compatibility with the version in go.mod
Example Workflow
Task: Implement Chi router middleware
Step 1: Check go.mod for version
Step 2: WebFetch https://go-chi.io/#/pages/middleware
Step 3: Read middleware documentation
Step 4: Implement following documented patterns
Step 5: Test implementation
Documentation Sources
- Chi Router: https://go-chi.io/
- GORM: https://gorm.io/docs/
- Stripe Go: https://stripe.com/docs/api?lang=go
- Bubbletea: https://github.com/charmbracelet/bubbletea
- Lipgloss: https://github.com/charmbracelet/lipgloss
Technology Stack
TUI (Terminal User Interface)
| Package | Version | Purpose | Documentation |
|---|---|---|---|
github.com/charmbracelet/bubbletea |
v1.3.10+ | Main TUI framework (The Elm Architecture) | https://github.com/charmbracelet/bubbletea |
github.com/charmbracelet/lipgloss |
v1.1.0+ | Styling and layout | https://github.com/charmbracelet/lipgloss |
github.com/charmbracelet/bubbles |
v0.21.0+ | Pre-built UI components (viewport, spinner, etc.) | https://github.com/charmbracelet/bubbles |
github.com/charmbracelet/huh |
v0.8.0+ | Forms and input validation | https://github.com/charmbracelet/huh |
github.com/charmbracelet/wish |
v1.4.7+ | SSH server framework | https://github.com/charmbracelet/wish |
Backend API
| Package | Version | Purpose | Documentation |
|---|---|---|---|
github.com/go-chi/chi/v5 |
v5.0.0+ | HTTP router | https://go-chi.io/ |
gorm.io/gorm |
v1.25.0+ | ORM for database operations | https://gorm.io/docs/ |
gorm.io/driver/postgres |
v1.5.0+ | PostgreSQL driver | https://gorm.io/docs/connecting_to_the_database.html |
github.com/golang-jwt/jwt/v5 |
v5.0.0+ | JWT token handling | https://github.com/golang-jwt/jwt |
github.com/go-playground/validator/v10 |
v10.0.0+ | Struct validation | https://github.com/go-playground/validator |
github.com/joho/godotenv |
v1.5.0+ | Environment variables | https://github.com/joho/godotenv |
Payments & Services
| Package | Version | Purpose | Documentation |
|---|---|---|---|
github.com/stripe/stripe-go/v78 |
v78.12.0+ | Payment processing | https://stripe.com/docs/api?lang=go |
rsc.io/qr |
v0.2.0 | QR code generation | https://pkg.go.dev/rsc.io/qr |
Utilities
| Package | Version | Purpose | Documentation |
|---|---|---|---|
github.com/google/uuid |
v1.6.0+ | UUID generation | https://github.com/google/uuid |
golang.org/x/crypto |
latest | Cryptography utilities | https://pkg.go.dev/golang.org/x/crypto |
Database Schema
Schema Design Principles
- Use UUIDs for primary keys (not auto-incrementing integers)
- Always include
created_atandupdated_attimestamps - Use soft deletes (
deleted_at) for important records - Normalize data appropriately (3NF where possible)
- Add indexes on foreign keys and frequently queried columns
Complete Schema (PostgreSQL)
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fingerprint VARCHAR(255) UNIQUE, -- SSH public key fingerprint
email VARCHAR(255) UNIQUE,
name VARCHAR(255),
stripe_customer_id VARCHAR(255), -- Stripe customer ID
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
-- Products table
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
roast_type VARCHAR(100), -- "Dark Roast", "Medium Roast", etc.
ounces INTEGER, -- Size in ounces
bean_type VARCHAR(100), -- "Arabica", "Robusta", etc.
price INTEGER NOT NULL, -- Price in cents (e.g., 350 = $3.50)
color VARCHAR(7), -- Hex color for UI (e.g., "#8B4513")
image_url VARCHAR(500),
stock INTEGER DEFAULT 0,
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
-- Carts table
CREATE TABLE carts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Cart items table
CREATE TABLE cart_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cart_id UUID REFERENCES carts(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
quantity INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cart_id, product_id) -- One entry per product per cart
);
-- Addresses table
CREATE TABLE addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
name VARCHAR(255), -- Recipient name
street VARCHAR(255) NOT NULL,
street2 VARCHAR(255), -- Apt, suite, etc.
city VARCHAR(100) NOT NULL,
state VARCHAR(2) NOT NULL, -- US state code
zip VARCHAR(10) NOT NULL,
country VARCHAR(2) DEFAULT 'US',
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Payment methods table (stores Stripe tokens, not raw card data)
CREATE TABLE payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
stripe_payment_method_id VARCHAR(255) NOT NULL, -- Stripe PM ID
brand VARCHAR(50), -- "Visa", "Mastercard", etc.
last4 VARCHAR(4), -- Last 4 digits
exp_month INTEGER,
exp_year INTEGER,
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Orders table
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
address_id UUID REFERENCES addresses(id),
payment_method_id UUID REFERENCES payment_methods(id),
-- Amounts in cents
subtotal INTEGER NOT NULL,
shipping INTEGER NOT NULL DEFAULT 0,
tax INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL,
status VARCHAR(50) DEFAULT 'pending', -- pending, paid, shipped, delivered, cancelled
stripe_payment_intent_id VARCHAR(255), -- Stripe Payment Intent ID
tracking_number VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Order items table (snapshot of products at time of order)
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
-- Snapshot product details (in case product changes later)
product_name VARCHAR(255),
product_description TEXT,
price INTEGER NOT NULL, -- Price at time of order
quantity INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_fingerprint ON users(fingerprint);
CREATE INDEX idx_carts_user_id ON carts(user_id);
CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_payment_methods_user_id ON payment_methods(user_id);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
GORM Model Examples
// models/user.go
package models
import (
"time"
"gorm.io/gorm"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
Fingerprint string `gorm:"uniqueIndex;size:255"`
Email string `gorm:"uniqueIndex;size:255"`
Name string `gorm:"size:255"`
StripeCustomerID string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// models/product.go
type Product struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
RoastType string `gorm:"size:100" json:"roast_type"`
Ounces int `json:"ounces"`
BeanType string `gorm:"size:100" json:"bean_type"`
Price int `gorm:"not null" json:"price"` // cents
Color string `gorm:"size:7" json:"color"`
ImageURL string `gorm:"size:500" json:"image_url"`
Stock int `gorm:"default:0" json:"stock"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// models/cart.go
type Cart struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
UserID uuid.UUID `gorm:"type:uuid;not null"`
User User `gorm:"foreignKey:UserID"`
Items []CartItem `gorm:"foreignKey:CartID"`
CreatedAt time.Time
UpdatedAt time.Time
}
type CartItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
CartID uuid.UUID `gorm:"type:uuid;not null"`
ProductID uuid.UUID `gorm:"type:uuid;not null"`
Product Product `gorm:"foreignKey:ProductID"`
Quantity int `gorm:"not null;default:1"`
CreatedAt time.Time
UpdatedAt time.Time
}
API Specifications
API Design Principles
- RESTful routes - Use standard HTTP methods (GET, POST, PUT, DELETE)
- JSON responses - All responses in JSON format
- Consistent error format - See Error Handling section
- JWT authentication - Bearer token in Authorization header
- Versioning - All routes prefixed with
/api/v1/ - Pagination - Use
pageandlimitquery params
API Routes
Authentication
POST /api/v1/auth/register - Create new user
POST /api/v1/auth/login - Get JWT token
POST /api/v1/auth/refresh - Refresh token
GET /api/v1/auth/me - Get current user (requires auth)
PUT /api/v1/auth/me - Update current user (requires auth)
Products
GET /api/v1/products - List all products (paginated)
GET /api/v1/products/:id - Get single product
POST /api/v1/products - Create product (admin only)
PUT /api/v1/products/:id - Update product (admin only)
DELETE /api/v1/products/:id - Delete product (admin only)
Cart
GET /api/v1/cart - Get current user's cart
POST /api/v1/cart/items - Add item to cart
PUT /api/v1/cart/items/:id - Update item quantity
DELETE /api/v1/cart/items/:id - Remove item from cart
DELETE /api/v1/cart - Clear entire cart
Payment Methods
GET /api/v1/payment-methods - List saved payment methods
POST /api/v1/payment-methods - Save new payment method
DELETE /api/v1/payment-methods/:id - Remove payment method
Addresses
GET /api/v1/addresses - List user's addresses
POST /api/v1/addresses - Create new address
PUT /api/v1/addresses/:id - Update address
DELETE /api/v1/addresses/:id - Delete address
Orders
GET /api/v1/orders - List user's orders
GET /api/v1/orders/:id - Get order details
POST /api/v1/orders - Create new order (checkout)
Request/Response Examples
GET /api/v1/products
Response (200 OK):
{
"success": true,
"data": {
"products": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "Espresso",
"description": "Bold coffee...",
"roast_type": "Dark Roast",
"ounces": 2,
"bean_type": "Arabica",
"price": 350,
"color": "#8B4513",
"stock": 100,
"created_at": "2024-01-01T00:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 6,
"total_pages": 1
}
}
}
POST /api/v1/cart/items
Request:
{
"product_id": "123e4567-e89b-12d3-a456-426614174000",
"quantity": 2
}
Response (201 Created):
{
"success": true,
"data": {
"cart": {
"id": "cart-uuid",
"items": [
{
"id": "item-uuid",
"product": { /* full product object */ },
"quantity": 2
}
],
"subtotal": 700
}
}
}
POST /api/v1/orders (Checkout)
Request:
{
"address_id": "address-uuid",
"payment_method_id": "pm-uuid"
}
Response (201 Created):
{
"success": true,
"data": {
"order": {
"id": "order-uuid",
"subtotal": 700,
"shipping": 500,
"tax": 0,
"total": 1200,
"status": "pending",
"items": [ /* order items */ ]
}
}
}
Code Organization
Directory Structure
terminalShop/
├── main.go # Entry point (SSH server)
├── server.go # Current TUI entry point
├── model.go # Bubbletea TUI models and views
│
├── api/ # Backend API server
│ ├── main.go # API server entry point
│ ├── routes/
│ │ ├── auth.go # Auth routes
│ │ ├── products.go # Product routes
│ │ ├── cart.go # Cart routes
│ │ └── orders.go # Order routes
│ ├── middleware/
│ │ ├── auth.go # JWT auth middleware
│ │ ├── logger.go # Request logging
│ │ └── cors.go # CORS middleware
│ └── handlers/
│ ├── auth.go # Auth handler functions
│ ├── products.go # Product handlers
│ └── cart.go # Cart handlers
│
├── pkg/ # Shared packages
│ ├── models/ # Database models (GORM)
│ │ ├── user.go
│ │ ├── product.go
│ │ ├── cart.go
│ │ └── order.go
│ ├── database/ # Database connection
│ │ └── db.go
│ ├── apiclient/ # API client for TUI
│ │ └── client.go
│ ├── auth/ # Auth utilities
│ │ ├── jwt.go # JWT generation/validation
│ │ └── password.go # Password hashing
│ └── utils/ # Helper functions
│ ├── response.go # Standard response helpers
│ └── validation.go # Validation helpers
│
├── migrations/ # Database migrations
│ ├── 001_initial.sql
│ └── migrate.go
│
├── config/ # Configuration
│ └── config.go # Load env vars
│
├── tests/ # Tests
│ ├── api/
│ └── integration/
│
├── .env # Environment variables (not in git)
├── .env.example # Example env file
├── go.mod
├── go.sum
├── README.md
└── MODELS.md # This file
Package Organization Rules
- One responsibility per package - Each package should have a clear, single purpose
- No circular dependencies - Organize imports in a clear hierarchy
- Shared code in pkg/ - Anything used by multiple packages goes in
pkg/ - Keep handlers thin - Business logic goes in separate service functions
- Models package is database-only - No business logic in model structs
Code Style Guide
General Go Style
Follow the official Go Code Review Comments and Effective Go.
Specific Rules for This Project
1. Imports Organization
Always organize imports in three groups, separated by blank lines:
import (
// Standard library
"context"
"fmt"
"net/http"
// External dependencies
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
// Internal packages
"github.com/yourusername/terminalshop/pkg/models"
"github.com/yourusername/terminalshop/pkg/utils"
)
2. Naming Conventions
- Variables:
camelCasefor local variables,PascalCasefor exported - Constants:
PascalCaseorSCREAMING_SNAKE_CASEfor groups - Functions:
PascalCasefor exported,camelCasefor private - Interfaces: Suffix with
-erif single method (e.g.,Reader,Writer) - Structs:
PascalCase, descriptive nouns - Files:
snake_case.go
// Good
type UserService struct {
db *gorm.DB
}
func (s *UserService) CreateUser(ctx context.Context, name string) (*models.User, error) {
// ...
}
// Bad
type userservice struct { } // should be PascalCase if exported
func (s *UserService) create_user() { } // should be camelCase if private
3. Error Messages
- Start with lowercase
- No punctuation at the end
- Include context about what failed
// Good
return nil, fmt.Errorf("failed to create user: %w", err)
return nil, errors.New("user not found")
// Bad
return nil, fmt.Errorf("Error creating user: %v", err) // no caps, use %w
return nil, errors.New("User not found.") // no caps, no period
4. Function Organization
Order functions in files logically:
- Exported types/constants
- Constructor functions (
New...) - Public methods (alphabetically)
- Private methods (alphabetically)
- Helper functions
// 1. Types
type UserHandler struct {
db *gorm.DB
}
// 2. Constructor
func NewUserHandler(db *gorm.DB) *UserHandler {
return &UserHandler{db: db}
}
// 3. Public methods
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
// ...
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
// ...
}
// 4. Private methods
func (h *UserHandler) validateUser(user *models.User) error {
// ...
}
// 5. Helper functions
func hashPassword(password string) (string, error) {
// ...
}
5. Comment Style
- Use complete sentences for package and function comments
- Start comments with the name of the thing being described
- Use
//for single-line,/* */for multi-line only when needed
// UserHandler handles HTTP requests related to users.
type UserHandler struct {
db *gorm.DB
}
// CreateUser creates a new user in the database and returns it.
// Returns an error if validation fails or database operation fails.
func (h *UserHandler) CreateUser(ctx context.Context, user *models.User) error {
// Validate user input
if err := h.validateUser(user); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Create user in database
if err := h.db.Create(user).Error; err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
6. Struct Tags
Always include JSON tags and use snake_case for JSON field names:
type Product struct {
ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`
Name string `gorm:"size:255" json:"name"`
Price int `gorm:"not null" json:"price"`
RoastType string `gorm:"size:100" json:"roast_type"` // snake_case!
CreatedAt time.Time `json:"created_at"`
}
7. Context Usage
Always pass context.Context as the first parameter:
// Good
func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) {
return s.db.WithContext(ctx).First(&user, id).Error
}
// Bad
func (s *UserService) GetUser(id uuid.UUID) (*models.User, error) {
// No context!
}
Error Handling
Principles
- Always handle errors - Never ignore errors with
_ - Wrap errors - Use
fmt.Errorf("context: %w", err)to add context - Don't panic - Only panic in
main()orinit()for unrecoverable errors - Return early - Use guard clauses and early returns
Standard Error Response Format (API)
All API errors should follow this format:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input data",
"details": {
"email": "must be a valid email address",
"password": "must be at least 8 characters"
}
}
}
Error Response Helper
// pkg/utils/response.go
package utils
import (
"encoding/json"
"net/http"
)
type ErrorResponse struct {
Success bool `json:"success"`
Error Error `json:"error"`
}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
}
func RespondError(w http.ResponseWriter, code int, errCode, message string, details map[string]interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(ErrorResponse{
Success: false,
Error: Error{
Code: errCode,
Message: message,
Details: details,
},
})
}
func RespondSuccess(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": data,
})
}
Common Error Codes
const (
ErrCodeValidation = "VALIDATION_ERROR"
ErrCodeUnauthorized = "UNAUTHORIZED"
ErrCodeForbidden = "FORBIDDEN"
ErrCodeNotFound = "NOT_FOUND"
ErrCodeConflict = "CONFLICT"
ErrCodeInternal = "INTERNAL_ERROR"
ErrCodeBadRequest = "BAD_REQUEST"
ErrCodePaymentFailed = "PAYMENT_FAILED"
)
Error Handling Pattern (API Handlers)
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
// Extract ID from URL
productID := chi.URLParam(r, "id")
if productID == "" {
utils.RespondError(w, http.StatusBadRequest, ErrCodeBadRequest, "product ID required", nil)
return
}
// Parse UUID
id, err := uuid.Parse(productID)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, ErrCodeValidation, "invalid product ID", nil)
return
}
// Get product from database
product, err := h.productService.GetProduct(r.Context(), id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.RespondError(w, http.StatusNotFound, ErrCodeNotFound, "product not found", nil)
return
}
// Log the actual error internally
log.Printf("failed to get product: %v", err)
utils.RespondError(w, http.StatusInternalServerError, ErrCodeInternal, "internal server error", nil)
return
}
// Success response
utils.RespondSuccess(w, http.StatusOK, product)
}
Error Handling in TUI
For the TUI, errors should be displayed to the user in a friendly way:
// In model.go Update() function
switch msg := msg.(type) {
case error:
m.errorMessage = api.GetErrorMessage(msg)
m.showError = true
return m, nil
}
// In View() function
func (m model) View() string {
if m.showError {
errorBox := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Bold(true).
Render("Error: " + m.errorMessage)
return errorBox + "\n" + m.normalView()
}
return m.normalView()
}
Testing Standards
Testing Strategy
- Unit tests for business logic and utilities
- Integration tests for API endpoints with database
- No tests for TUI initially (focus on API)
Test File Organization
- Test files live next to the code they test
- Name:
<file>_test.go - Use table-driven tests for multiple cases
Example Test Structure
// api/handlers/product_test.go
package handlers
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProductHandler_GetProduct(t *testing.T) {
tests := []struct {
name string
productID string
setupMock func(*mockDB)
expectedStatus int
expectedError string
}{
{
name: "success",
productID: "valid-uuid",
setupMock: func(m *mockDB) { /* setup */ },
expectedStatus: 200,
},
{
name: "not found",
productID: "nonexistent-uuid",
setupMock: func(m *mockDB) { /* setup */ },
expectedStatus: 404,
expectedError: "NOT_FOUND",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test implementation
// Use require for setup assertions
// Use assert for test assertions
})
}
}
Testing Libraries
testing- Standard librarygithub.com/stretchr/testify- Assertions and mocking- Test database: Use in-memory SQLite or Docker PostgreSQL
Common Patterns
Pattern 1: Service Layer
Separate business logic from HTTP handlers:
// pkg/services/user_service.go
type UserService struct {
db *gorm.DB
}
func NewUserService(db *gorm.DB) *UserService {
return &UserService{db: db}
}
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*models.User, error) {
user := &models.User{
Name: name,
Email: email,
}
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
return user, nil
}
// api/handlers/user_handler.go
type UserHandler struct {
userService *services.UserService
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
// Parse request
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, 400, ErrCodeBadRequest, "invalid request body", nil)
return
}
// Call service
user, err := h.userService.CreateUser(r.Context(), req.Name, req.Email)
if err != nil {
// Handle error
return
}
utils.RespondSuccess(w, 201, user)
}
Pattern 2: Middleware for Auth
// api/middleware/auth.go
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
utils.RespondError(w, 401, ErrCodeUnauthorized, "missing auth token", nil)
return
}
// Validate token
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
utils.RespondError(w, 401, ErrCodeUnauthorized, "invalid token", nil)
return
}
// Extract user ID from claims
claims := token.Claims.(jwt.MapClaims)
userID := claims["user_id"].(string)
// Add to context
ctx := context.WithValue(r.Context(), "user_id", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Usage in routes
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(jwtSecret))
r.Get("/api/v1/cart", handlers.GetCart)
r.Post("/api/v1/cart/items", handlers.AddCartItem)
})
Pattern 3: Repository Pattern (Optional)
For more complex queries, separate database access:
// pkg/repositories/product_repository.go
type ProductRepository struct {
db *gorm.DB
}
func (r *ProductRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Product, error) {
var product models.Product
err := r.db.WithContext(ctx).Where("id = ? AND active = ?", id, true).First(&product).Error
return &product, err
}
func (r *ProductRepository) List(ctx context.Context, page, limit int) ([]models.Product, int64, error) {
var products []models.Product
var total int64
offset := (page - 1) * limit
r.db.Model(&models.Product{}).Where("active = ?", true).Count(&total)
err := r.db.WithContext(ctx).
Where("active = ?", true).
Limit(limit).
Offset(offset).
Find(&products).Error
return products, total, err
}
Pattern 4: Bubbletea View Switching
// In model.go
type page int
const (
shopPage page = iota
cartPage
accountPage
paymentPage
)
type model struct {
currentPage page
// ... other fields
}
func (m model) SwitchToPage(p page) (model, tea.Cmd) {
m.currentPage = p
// Reset page-specific state
switch p {
case shopPage:
m.cursor = 0
case cartPage:
m.cartCursor = 0
// ...
}
return m, nil
}
func (m model) View() string {
header := m.buildHeader()
footer := m.buildFooter()
var content string
switch m.currentPage {
case shopPage:
content = m.buildShopView()
case cartPage:
content = m.buildCartView()
case accountPage:
content = m.buildAccountView()
default:
content = "Unknown page"
}
return lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
}
Configuration Management
Environment Variables
Use a .env file for local development:
# .env (DO NOT COMMIT)
DATABASE_URL=postgresql://user:password@localhost:5432/terminalshop
JWT_SECRET=your-super-secret-jwt-key-change-this
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_...
API_PORT=8080
SSH_PORT=2222
ENVIRONMENT=development
Config Package
// config/config.go
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
APIPort string
SSHPort string
Environment string
}
func Load() (*Config, error) {
// Load .env file in development
if os.Getenv("ENVIRONMENT") != "production" {
godotenv.Load()
}
return &Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
APIPort: getEnvOrDefault("API_PORT", "8080"),
SSHPort: getEnvOrDefault("SSH_PORT", "2222"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
}, nil
}
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
Security Best Practices
- Never commit secrets - Use
.envand add to.gitignore - Hash passwords - Use
bcryptwith cost >= 12 - Validate all inputs - Never trust user input
- Use parameterized queries - GORM does this automatically
- HTTPS in production - Always use TLS
- Rate limiting - Add to API endpoints
- CORS configuration - Only allow trusted origins
Reference Implementation
The terminal-shop-source/ directory contains the complete terminal.shop codebase. When in doubt:
- Check similar functionality in their code
- Copy patterns, not code verbatim - adapt to our stack
- Understand before implementing - don't blindly copy
Key files to reference:
packages/go/pkg/tui/*.go- TUI views and componentspackages/go/pkg/api/api.go- API client integrationpackages/core/src/- Backend business logic (TypeScript, but concepts apply)
Quick Reference Checklist
Before writing new code, ask:
- Does this follow the file organization structure?
- Are imports organized correctly?
- Are errors wrapped with context?
- Are all errors handled?
- Does this need a test?
- Are naming conventions followed?
- Is the API response in the standard format?
- Are database queries using context?
- Is sensitive data in environment variables?
- Does this match patterns from terminal.shop reference code?
Remember: Consistency is more important than perfection. Follow these patterns throughout the codebase, and reference this document frequently!