Skip to main content

Best Practices for Tonica Applications

This guide covers production-ready patterns, anti-patterns, and recommendations for building robust applications with Tonica.

Table of Contents

Application Structure

myservice/
├── cmd/
│ └── server/
│ └── main.go # Application entrypoint
├── internal/ # Private application code
│ ├── domain/ # Domain models
│ │ └── user.go
│ ├── repository/ # Data access layer
│ │ ├── user_repository.go
│ │ └── user_repository_test.go
│ ├── service/ # Business logic
│ │ ├── user_service.go
│ │ └── user_service_test.go
│ └── handler/ # RPC handlers
│ ├── user_handler.go
│ └── user_handler_test.go
├── pkg/ # Public reusable code
│ └── validator/
│ └── email.go
├── proto/ # Protocol buffer definitions
│ └── user/
│ └── v1/
│ └── user.proto
├── openapi/ # Generated OpenAPI specs
├── migrations/ # Database migrations
│ ├── 001_create_users.sql
│ └── 002_add_users_email_index.sql
├── tests/
│ ├── integration/
│ └── e2e/
├── configs/ # Configuration files
│ ├── dev.env
│ └── prod.env
├── scripts/ # Build and deployment scripts
├── .env.example # Example environment variables
├── buf.gen.yaml # Buf configuration
├── docker-compose.yml
├── Dockerfile
├── Makefile
├── go.mod
└── go.sum

Package Organization

Good: Clear separation of concerns

// internal/repository/user_repository.go
type UserRepository interface {
Create(ctx context.Context, user *domain.User) error
GetByID(ctx context.Context, id string) (*domain.User, error)
}

// internal/service/user_service.go
type UserService struct {
repo UserRepository
logger *slog.Logger
}

// internal/handler/user_handler.go
type UserHandler struct {
service *UserService
}

Bad: Everything in one package

// main.go (5000 lines of mixed concerns)

Dependency Injection

Good: Constructor injection

import "log/slog"

type UserService struct {
repo UserRepository
cache *redis.Client
logger *slog.Logger // slog.Logger from standard library
}

func NewUserService(repo UserRepository, cache *redis.Client, logger *slog.Logger) *UserService {
return &UserService{
repo: repo,
cache: cache,
logger: logger,
}
}

Bad: Global variables

var globalDB *bun.DB
var globalCache *redis.Client

func GetUser(id string) (*User, error) {
// Uses globals
return globalDB.Query(...)
}

Error Handling

gRPC Errors

Good: Use proper status codes

import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func (h *UserHandler) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "user ID is required")
}

user, err := h.service.GetUser(ctx, req.Id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
h.logger.Error("failed to get user", "error", err, "id", req.Id)
return nil, status.Error(codes.Internal, "internal server error")
}

return &pb.GetUserResponse{User: user}, nil
}

Bad: Generic errors

func (h *UserHandler) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
user, err := h.service.GetUser(ctx, req.Id)
if err != nil {
return nil, err // ❌ No context, wrong code
}
return &pb.GetUserResponse{User: user}, nil
}

Error Types

Good: Define custom errors

package repository

import "errors"

var (
ErrNotFound = errors.New("entity not found")
ErrAlreadyExists = errors.New("entity already exists")
ErrInvalidInput = errors.New("invalid input")
)

func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := r.db.NewSelect().
Model(&user).
Where("email = ?", email).
Scan(ctx)

if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return &user, err
}

Error Wrapping

Good: Add context to errors

func (s *UserService) CreateUser(ctx context.Context, user *User) error {
if err := s.validate(user); err != nil {
return fmt.Errorf("validation failed: %w", err)
}

if err := s.repo.Create(ctx, user); err != nil {
return fmt.Errorf("failed to create user in database: %w", err)
}

return nil
}

Logging

Structured Logging

Good: Use structured fields

logger.Info("user created",
"user_id", user.ID,
"email", user.Email,
"duration_ms", elapsed.Milliseconds(),
)

Bad: String formatting

logger.Info(fmt.Sprintf("User %s created with email %s", user.ID, user.Email))

Log Levels

// DEBUG - Detailed information for debugging
logger.Debug("cache miss", "key", key)

// INFO - General informational messages
logger.Info("user logged in", "user_id", userID)

// WARN - Warning messages (recoverable issues)
logger.Warn("rate limit approaching", "user_id", userID, "requests", count)

// ERROR - Error messages (something failed)
logger.Error("failed to send email", "error", err, "recipient", email)

What to Log

Log:

  • Request/response metadata (IDs, duration, status)
  • Business events (user created, order placed)
  • Errors with context
  • Performance metrics
  • Security events

Don't log:

  • Passwords or secrets
  • Personal data (in production)
  • Full request/response bodies (unless debugging)
  • Excessive debug info in production

Logging Example

import (
"context"
"log/slog"
"time"
)

func (s *UserService) CreateUser(ctx context.Context, user *User) error {
start := time.Now()

// slog uses key-value pairs for structured logging
s.logger.Info("creating user",
"email", user.Email,
)

if err := s.repo.Create(ctx, user); err != nil {
s.logger.Error("failed to create user",
"error", err,
"email", user.Email,
"duration_ms", time.Since(start).Milliseconds(),
)
return err
}

s.logger.Info("user created successfully",
"user_id", user.ID,
"email", user.Email,
"duration_ms", time.Since(start).Milliseconds(),
)

return nil
}

Configuration Management

Environment Variables

Good: Validate at startup

func loadConfig() (*Config, error) {
cfg := &Config{
DBHost: os.Getenv("DB_HOST"),
DBPort: os.Getenv("DB_PORT"),
DBName: os.Getenv("DB_NAME"),
RedisAddr: os.Getenv("REDIS_ADDR"),
}

if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}

return cfg, nil
}

func (c *Config) Validate() error {
if c.DBHost == "" {
return errors.New("DB_HOST is required")
}
if c.DBPort == "" {
return errors.New("DB_PORT is required")
}
return nil
}

func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
}
// ...
}

Secrets Management

Bad: Hardcoded secrets

redis := tonica.NewRedis(
tonica.WithRedisPassword("hardcoded-password"), // ❌
)

Good: Environment variables or secrets manager

// From environment
password := os.Getenv("REDIS_PASSWORD")
redis := tonica.NewRedis(tonica.WithRedisPassword(password))

// Or from secrets manager (AWS, GCP, etc.)
secret, err := secretsManager.GetSecret("redis-password")
redis := tonica.NewRedis(tonica.WithRedisPassword(secret))

Database

Connection Management

Good: Configure connection pool

db := service.NewDB(
service.WithDriver(service.Postgres),
service.WithDSN(dsn),
)

client := db.GetClient()

// Configure pool for API service
client.SetMaxOpenConns(25)
client.SetMaxIdleConns(10)
client.SetConnMaxLifetime(5 * time.Minute)
client.SetConnMaxIdleTime(10 * time.Minute)

Query Patterns

Good: Use context and timeouts

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
// Add timeout to context
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

var user User
err := r.db.NewSelect().
Model(&user).
Where("id = ?", id).
Scan(ctx) // Use context

return &user, err
}

Good: Use prepared statements (built into Bun)

// Bun automatically uses prepared statements
err := db.NewSelect().
Model(&user).
Where("email = ?", email). // Safe from SQL injection
Scan(ctx)

Bad: String concatenation

query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)  // ❌ SQL injection

Transactions

Good: Use transactions for multi-step operations

func (r *UserRepository) CreateUserWithProfile(ctx context.Context, user *User, profile *Profile) error {
return r.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Create user
if _, err := tx.NewInsert().Model(user).Exec(ctx); err != nil {
return err
}

// Create profile
profile.UserID = user.ID
if _, err := tx.NewInsert().Model(profile).Exec(ctx); err != nil {
return err
}

return nil
})
}

Migrations

Good: Version-controlled migrations

-- migrations/001_create_users.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);

Use migration tools like:

API Design

RESTful Principles

Good: Resource-based URLs

GET    /api/v1/users           # List users
GET /api/v1/users/:id # Get user
POST /api/v1/users # Create user
PUT /api/v1/users/:id # Update user
DELETE /api/v1/users/:id # Delete user

GET /api/v1/users/:id/orders # Get user's orders

Bad: Action-based URLs

POST   /api/v1/getUser
POST /api/v1/createUser
POST /api/v1/deleteUser

API Versioning

Good: Version in path

/api/v1/users
/api/v2/users

Input Validation

Good: Validate early

func (h *UserHandler) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
// Validate input
if req.Email == "" {
return nil, status.Error(codes.InvalidArgument, "email is required")
}
if !isValidEmail(req.Email) {
return nil, status.Error(codes.InvalidArgument, "invalid email format")
}
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}

// Process request
user, err := h.service.CreateUser(ctx, req)
// ...
}

Pagination

Good: Always paginate list endpoints

message ListUsersRequest {
int32 page = 1; // Page number (default: 1)
int32 limit = 2; // Items per page (default: 10, max: 100)
}

message ListUsersResponse {
repeated User users = 1;
int32 total = 2;
int32 page = 3;
int32 limit = 4;
}

Performance

Caching Strategy

Good: Cache expensive operations

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// Try cache first
cacheKey := "user:" + id
if cached, err := s.cache.Get(ctx, cacheKey).Bytes(); err == nil {
var user User
if err := json.Unmarshal(cached, &user); err == nil {
return &user, nil
}
}

// Cache miss - fetch from database
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, err
}

// Update cache
if data, err := json.Marshal(user); err == nil {
s.cache.Set(ctx, cacheKey, data, 10*time.Minute)
}

return user, nil
}

N+1 Query Problem

Bad: N+1 queries

users, _ := repo.GetUsers(ctx)
for _, user := range users {
orders, _ := repo.GetUserOrders(ctx, user.ID) // ❌ Query in loop
// ...
}

Good: Eager loading

var users []User
err := db.NewSelect().
Model(&users).
Relation("Orders"). // Load orders in single query
Scan(ctx)

Database Indexes

Good: Index frequently queried columns

-- Index on email for lookups
CREATE INDEX idx_users_email ON users(email);

-- Composite index for multi-column queries
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- Partial index for specific conditions
CREATE INDEX idx_active_users ON users(id) WHERE active = true;

Connection Pooling

Good: Tune for your workload

// API service (high concurrency)
db.SetMaxOpenConns(100)
db.SetMaxIdleConns(25)

// Worker (low concurrency, CPU-bound)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)

Security

Authentication

Good: Validate tokens

func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}

// Validate token
claims, err := validateJWT(token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
return
}

// Store user info in context
c.Set("user_id", claims.UserID)
c.Next()
}
}

Input Sanitization

Good: Sanitize and validate

func (s *UserService) CreateUser(ctx context.Context, email, name string) error {
// Validate
email = strings.TrimSpace(strings.ToLower(email))
name = strings.TrimSpace(name)

if !isValidEmail(email) {
return ErrInvalidEmail
}

if len(name) < 2 || len(name) > 100 {
return ErrInvalidName
}

// Proceed...
}

Rate Limiting

Good: Implement rate limiting

func RateLimitMiddleware(limiter *rate.Limiter) gin.HandlerFunc {
return func(c *gin.Context) {
if !limiter.Allow() {
c.AbortWithStatusJSON(429, gin.H{
"error": "rate limit exceeded",
})
return
}
c.Next()
}
}

// Usage
limiter := rate.NewLimiter(rate.Every(time.Second), 10) // 10 req/sec
app.GetRouter().Use(RateLimitMiddleware(limiter))

HTTPS Only

Good: Enforce HTTPS in production

// In production, use TLS
if os.Getenv("ENV") == "production" {
router.Use(func(c *gin.Context) {
if c.Request.Header.Get("X-Forwarded-Proto") != "https" {
c.Redirect(301, "https://"+c.Request.Host+c.Request.RequestURI)
return
}
c.Next()
})
}

Observability

Metrics

Good: Instrument critical paths

var (
requestsTotal = app.GetMetricManager().NewCounter(
"http_requests_total",
"Total HTTP requests",
)
requestDuration = app.GetMetricManager().NewHistogram(
"http_request_duration_seconds",
"HTTP request duration",
)
)

func (h *UserHandler) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
start := time.Now()
requestsTotal.Inc()

user, err := h.service.CreateUser(ctx, req)

duration := time.Since(start).Seconds()
requestDuration.Observe(duration)

if err != nil {
return nil, err
}
return &pb.CreateUserResponse{User: user}, nil
}

Tracing

Good: Add custom spans

import "go.opentelemetry.io/otel"

func (s *UserService) CreateUser(ctx context.Context, user *User) error {
tracer := otel.Tracer("user-service")
ctx, span := tracer.Start(ctx, "CreateUser")
defer span.End()

// Add attributes
span.SetAttributes(
attribute.String("user.email", user.Email),
)

// Your logic
if err := s.repo.Create(ctx, user); err != nil {
span.RecordError(err)
return err
}

return nil
}

Health Checks

Good: Comprehensive health checks

tonica.NewRoute(app).
GET("/health").
Handle(func(c *gin.Context) {
health := gin.H{
"status": "healthy",
}

// Check database
if err := db.PingContext(c.Request.Context()); err != nil {
health["database"] = "unhealthy"
health["status"] = "unhealthy"
c.JSON(503, health)
return
}
health["database"] = "healthy"

// Check Redis
if err := redis.Ping(c.Request.Context()).Err(); err != nil {
health["redis"] = "unhealthy"
health["status"] = "degraded"
} else {
health["redis"] = "healthy"
}

statusCode := 200
if health["status"] == "unhealthy" {
statusCode = 503
}
c.JSON(statusCode, health)
})

Deployment

Docker Best Practices

Good: Multi-stage build

# Builder stage
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /myservice ./cmd/server

# Runtime stage
FROM alpine:latest

RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /myservice .
COPY openapi/ ./openapi/

EXPOSE 8080 50051 9090
CMD ["./myservice"]

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
name: myservice
spec:
replicas: 3
selector:
matchLabels:
app: myservice
template:
metadata:
labels:
app: myservice
spec:
containers:
- name: myservice
image: myservice:latest
ports:
- containerPort: 8080
name: http
- containerPort: 50051
name: grpc
- containerPort: 9090
name: metrics

# Health checks
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10

readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5

# Resources
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "1000m"
memory: "1Gi"

# Environment
envFrom:
- configMapRef:
name: myservice-config
- secretRef:
name: myservice-secrets

Graceful Shutdown

Good: Handle signals properly

func main() {
app := tonica.NewApp()

// Run app (signal handling is automatic)
if err := app.Run(); err != nil {
app.GetLogger().Fatal(err)
}

log.Println("Application shutdown complete")
}

Testing

See Testing Guide for comprehensive testing best practices.

Key Testing Principles

Do:

  • Write tests before fixing bugs
  • Test error cases and edge cases
  • Use table-driven tests
  • Mock external dependencies
  • Run tests in CI/CD

Don't:

  • Test implementation details
  • Write flaky tests
  • Skip integration tests
  • Ignore test failures
  • Have slow tests in unit test suite

Common Anti-Patterns

1. God Objects

Bad:

type Manager struct {
// Does everything
}

func (m *Manager) CreateUser() {}
func (m *Manager) SendEmail() {}
func (m *Manager) ProcessPayment() {}
func (m *Manager) GenerateReport() {}

Good: Single responsibility

type UserService struct { /* user-related */ }
type EmailService struct { /* email-related */ }
type PaymentService struct { /* payment-related */ }

2. Premature Optimization

Bad: Optimizing before measuring

// Complex caching without knowing if it's needed

Good: Measure first, optimize based on data

// Profile, identify bottleneck, then optimize

3. No Error Handling

Bad:

user, _ := repo.GetUser(ctx, id)  // Ignoring error

Good:

user, err := repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}

Checklist for Production

Before deploying to production:

  • All secrets in environment variables or secrets manager
  • Database connection pooling configured
  • Proper error handling and logging
  • Health check endpoints implemented
  • Metrics and tracing enabled
  • Rate limiting on public endpoints
  • Input validation on all endpoints
  • TLS/HTTPS enabled
  • Database migrations tested
  • Integration tests passing
  • Load testing performed
  • Graceful shutdown implemented
  • Resource limits configured (CPU, memory)
  • Monitoring and alerting set up
  • Documentation updated

Next Steps