Skip to main content

Tonica Architecture Overview

This document explains the internal architecture and design principles of the Tonica framework.

Design Philosophy

Tonica is built on several core principles:

  1. Proto-First: APIs are defined using Protocol Buffers, ensuring type safety and contract-first design
  2. Convention over Configuration: Sensible defaults with escape hatches for customization
  3. Modular: Use only what you need - run as a unified service or separate components
  4. Observable by Default: Built-in metrics, tracing, and logging
  5. Production-Ready: Graceful shutdown, health checks, and error handling out of the box

High-Level Architecture

Core Components

1. Application Core (App)

The App struct is the heart of Tonica. It manages the application lifecycle and coordinates all components.

Responsibilities:

  • Initialize and configure all subsystems
  • Manage component registry
  • Coordinate graceful shutdown
  • Provide access to shared resources (logger, metrics, etc.)

Key Fields:

type App struct {
Name string
cfg *config.Config
logger *log.Logger // Standard logger
router *gin.Engine // HTTP router
metricRouter *gin.Engine // Metrics endpoint
metricsManager *metrics.Manager
registry *Registry
shutdown *Shutdown
customRoutes []RouteMetadata // Custom route definitions
}

Lifecycle:

NewApp() → Configure() → RegisterComponents() → Run() → Shutdown()

2. Registry

The Registry is a central repository for all application components (services, workers, consumers).

Purpose:

  • Store and retrieve services by name
  • Store and retrieve workers by name
  • Store and retrieve consumers by name
  • Prevent duplicate registrations
  • Enable discovery of components during startup

Implementation:

type Registry struct {
services map[string]*Service
workers map[string]*Worker
consumers map[string]*Consumer
mu sync.RWMutex // Thread-safe access
}

Thread Safety: All registry operations are protected by a read-write mutex, allowing safe concurrent access.

3. Service Layer

Services handle gRPC and REST API requests.

Service Structure:

type Service struct {
name string
grpcServer *grpc.Server
db *DB
redis *Redis
}

Request Flow:

Features:

  • Automatic REST API generation via gRPC-Gateway
  • OpenAPI spec generation from proto annotations
  • Built-in middleware (CORS, logging, metrics)
  • Custom route support

4. Worker Layer

Workers process background tasks using Temporal.

Worker Structure:

type Worker struct {
name string
taskQueue string
client client.Client
worker worker.Worker
}

Execution Flow:

Features:

  • Activity registration
  • Workflow registration
  • Automatic retries and error handling
  • Distributed tracing integration

5. Consumer Layer

Consumers process messages from Kafka, PubSub, or other message queues.

Consumer Structure:

type Consumer struct {
name string
topic string
consumerGroup string
client storage.Client
handler func(context.Context, *pubsub.Message) error
}

Processing Flow:

Features:

  • Context-based cancellation
  • Automatic error handling and logging
  • Graceful shutdown (waits for message completion)
  • Consumer group support

6. Infrastructure Layer

Database (Bun ORM)

Tonica uses Bun as its ORM, supporting PostgreSQL, MySQL, and SQLite.

Connection Management:

type DB struct {
driver string
dsn string
db *bun.DB // Cached connection
}

func (d *DB) GetClient() *bun.DB {
if d.db != nil {
return d.db // Return cached connection
}
// Create new connection
// ...
}

Features:

  • Connection pooling
  • Automatic driver detection
  • Migration support (via Bun)
  • Query builder

Redis

Redis client wrapper with connection caching.

Connection Management:

type Redis struct {
addr string
password string
database int
conn *redis.Client // Cached connection
}

func (r *Redis) GetClient() *redis.Client {
if r.conn == nil {
r.conn = redis.NewClient(&redis.Options{
Addr: r.addr,
Password: r.password,
DB: r.database,
})
}
return r.conn
}

Use Cases:

  • Caching
  • Session storage
  • Rate limiting
  • Distributed locking

7. Observability Layer

OpenTelemetry Tracing

Automatic distributed tracing for all requests.

Instrumentation Points:

  • HTTP requests (incoming/outgoing)
  • gRPC calls (incoming/outgoing)
  • Database queries
  • Redis operations
  • Custom spans (user-defined)

Configuration:

OTEL_ENABLED=true
OTEL_ENDPOINT=localhost:4317
OTEL_SERVICE_NAME=myservice

Prometheus Metrics

Built-in metrics collection and export.

Default Metrics:

  • app_info - Application metadata
  • HTTP request metrics (duration, count, status)
  • gRPC request metrics
  • Go runtime metrics (goroutines, memory, GC)

Custom Metrics:

counter := app.GetMetricManager().NewCounter("orders_total", "Total orders")
gauge := app.GetMetricManager().NewGauge("active_connections", "Active connections")
histogram := app.GetMetricManager().NewHistogram("request_duration", "Request duration")

Metrics Endpoint:

http://localhost:9090/metrics

Structured Logging

Uses Go's standard log/slog for structured logging.

Log Levels:

  • DEBUG - Detailed debugging information
  • INFO - General informational messages
  • WARN - Warning messages
  • ERROR - Error messages

Usage:

import "log/slog"

// Use slog for structured logging
slog.Info("User created", "user_id", userID, "email", email)
slog.Error("Failed to process order", "order_id", orderID, "error", err)

// Or use app's logger (*log.Logger)
app.GetLogger().Printf("User created: %s", userID)

8. Shutdown Coordinator

The Shutdown coordinator ensures graceful termination of all components.

Shutdown Flow:

Registration:

// Register HTTP server
app.shutdown.RegisterHTTPServer(httpServer)

// Register gRPC server
app.shutdown.RegisterGRPCServer(grpcServer)

// Register cleanup function
app.shutdown.RegisterCleanup(func(ctx context.Context) error {
// Close database connections, flush metrics, etc.
return db.Close()
})

Features:

  • Parallel shutdown (fast)
  • Timeout protection (no hanging)
  • Error aggregation
  • Thread-safe registration

Run Modes

Tonica supports four run modes, each activating different components:

ModeAio (All-In-One)

Runs everything: gRPC + REST + Workers + Consumers

ModeService

Runs only gRPC and REST APIs

ModeWorker

Runs only Temporal workers (+ metrics)

ModeConsumer

Runs only message consumers (+ metrics)

See Run Modes for detailed comparison.

Request Flow Examples

gRPC Request

REST Request (Generated from Proto)

REST Request (Custom Route)

Worker Task Processing

Consumer Message Processing

Configuration Management

Configuration follows this priority order (highest to lowest):

  1. Code - Options passed to constructors
  2. Environment Variables - APP_NAME, DB_DSN, etc.
  3. Defaults - Built-in sensible defaults

Example:

// 1. Code (highest priority)
app := tonica.NewApp(tonica.WithName("myservice"))

// 2. Environment variable
// export APP_NAME="envservice"

// 3. Default (lowest priority)
// Falls back to "tonica-app" if nothing specified

See Configuration for all options.

Error Handling Strategy

Tonica uses a layered error handling approach:

Layer 1: Application Errors

if err != nil {
return nil, status.Error(codes.InvalidArgument, "invalid user ID")
}

Layer 2: Middleware Errors

Automatically handled by framework:

  • HTTP 500 for internal errors
  • HTTP 4xx for client errors
  • Logged with context

Layer 3: Infrastructure Errors

Database, Redis, etc.:

import "log/slog"

user, err := db.GetUser(ctx, userID)
if err != nil {
slog.Error("Failed to get user", "error", err, "user_id", userID)
return nil, status.Error(codes.Internal, "database error")
}

Layer 4: Panic Recovery

Automatic panic recovery in HTTP/gRPC handlers:

  • Logs panic with stack trace
  • Returns 500 error to client
  • Doesn't crash the application

Concurrency Model

Thread Safety Guarantees

App:

  • Safe to call getters concurrently
  • Registry access is mutex-protected
  • Metrics manager is thread-safe

Registry:

  • All operations use RWMutex
  • Multiple readers allowed
  • Exclusive writer lock

Services/Workers/Consumers:

  • Each request/task/message in separate goroutine
  • No shared mutable state (unless explicitly designed)
  • Context-based cancellation

Goroutine Management

HTTP Server:

  • One goroutine per request
  • Automatically cleaned up after response

gRPC Server:

  • One goroutine per stream
  • Cleaned up when stream closes

Workers:

  • Configurable number of concurrent activities
  • Managed by Temporal worker

Consumers:

  • One goroutine per consumer
  • Message processing can be sequential or parallel (user choice)

Performance Considerations

Connection Pooling

Database:

// Bun handles connection pooling internally
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)

Redis:

// Redis client has built-in connection pool
// Configured via redis.Options
&redis.Options{
PoolSize: 10,
MinIdleConns: 5,
}

Caching Strategy

  1. Application-level - In-memory caches in your code
  2. Redis - Distributed cache for multi-instance deployments
  3. Database - Query result caching via Bun

Metrics Overhead

Metrics collection is lightweight:

  • Counter increment: ~100ns
  • Histogram observation: ~500ns
  • Negligible impact on performance

Security Considerations

Authentication & Authorization

Not built-in - implement as middleware:

func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if !validateToken(token) {
c.AbortWithStatus(401)
return
}
c.Next()
}
}

app.GetRouter().Use(AuthMiddleware())

CORS

Built-in CORS support:

# Allow all origins (default)
# No configuration needed

# Restrict origins
export APP_CORS_ORIGINS="https://myapp.com,https://api.myapp.com"

TLS/HTTPS

Configure at deployment level:

  • Use reverse proxy (nginx, Traefik) with TLS termination
  • Or configure TLS in Gin router directly

Extending Tonica

Custom Middleware

HTTP Middleware:

app.GetRouter().Use(MyCustomMiddleware())

gRPC Interceptors:

grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(MyInterceptor),
)

Custom Metrics

myCounter := app.GetMetricManager().NewCounter("my_metric", "Description")
myCounter.Inc()

Custom Health Checks

tonica.NewRoute(app).
GET("/health/detailed").
Handle(func(c *gin.Context) {
health := map[string]string{
"database": checkDB(),
"redis": checkRedis(),
"workers": checkWorkers(),
}
c.JSON(200, health)
})

Next Steps

Further Reading