Architecture & Project Structure
Multi-Service Monorepo
RestartiX is a Go monorepo containing two services deployed as separate AWS App Runner services:
| Service | Port | Framework | Databases | Purpose |
|---|---|---|---|---|
Core API (cmd/api) | 9000 | chi (net/http) | PostgreSQL + Redis | Core platform: appointments, patients, forms, RBAC, RLS |
Telemetry API (cmd/telemetry) | 4000 | chi (net/http) | PostgreSQL + ClickHouse + Redis | Audit enrichment, analytics, media/rehab tracking |
Why chi for both: chi builds on Go's net/http standard library, which means middleware and handlers are portable across services. The monorepo shares types via pkg/ — audit event types are defined once and imported by both services. Using two different frameworks (e.g., chi + Fiber) would prevent middleware sharing.
Why two services, not one: Telemetry ingests high-frequency data (pose tracking, video bandwidth samples, analytics events) that would compete for resources with the Core API's CRUD operations. Separate services allow independent scaling — rehab sessions spike at different times than admin operations. ClickHouse (columnar, analytics-optimized) doesn't belong in the Core API's request path.
Communication: The Core API forwards audit events to Telemetry via the TELEMETRY_INTERNAL_URL environment variable (configured in AWS Secrets Manager, pointing to the Telemetry App Runner service URL over HTTPS). Both services connect to RDS and ElastiCache through a VPC Connector. Frontend client SDK talks to Telemetry directly for analytics and media tracking. See AWS infrastructure for networking details.
AWS App Runner + VPC
├── Core API (App Runner, chi, :8080)
│ └── RDS PostgreSQL (Core DB — appointments, patients, forms, audit_log)
│ └── ElastiCache Redis (shared — rate limiting, sessions, webhook idempotency)
│
├── Telemetry API (App Runner, chi, :4000)
│ └── RDS PostgreSQL (Telemetry DB — enriched audit, security events, staff activity)
│ └── ClickHouse (analytics, media sessions, bandwidth, staff metrics)
│ └── ElastiCache Redis (shared)
│
├── Core API → Telemetry (HTTPS via TELEMETRY_INTERNAL_URL)
│
└── Frontend → Core API (mutations, queries)
Frontend → Telemetry API (analytics/track, media/events)Go Project Layout
restartix/
├── go.mod # Single module
├── go.sum
│
├── cmd/
│ ├── api/
│ │ └── main.go # Core API entrypoint (port 9000)
│ └── telemetry/
│ └── main.go # Telemetry API entrypoint (port 4000)
│
├── pkg/ # Shared types between services
│ ├── auditevt/ # Audit event types (Core API produces, Telemetry consumes)
│ │ ├── event.go # Event struct, action types
│ │ └── forward.go # Async forwarder (used by Core API)
│ └── httputil/ # Shared HTTP response helpers
│ └── response.go
│
├── internal/ # Private application code
│ ├── core/ # Core API code
│ │ ├── config/
│ │ │ └── config.go # Core API environment configuration
│ │ ├── server/
│ │ │ ├── server.go # HTTP server setup, graceful shutdown
│ │ │ └── routes.go # Route registration
│ │ ├── middleware/
│ │ │ ├── auth.go # Clerk session validation → user context
│ │ │ ├── organization.go # Organization context (RLS session var)
│ │ │ ├── rbac.go # Role-based access control
│ │ │ ├── audit.go # Audit: sync local INSERT + async forward to Telemetry
│ │ │ ├── ratelimit.go # Per-endpoint rate limiting (Redis)
│ │ │ ├── security.go # Security headers, attack blocking
│ │ │ ├── requestid.go # Request ID propagation
│ │ │ ├── logging.go # Request/response structured logging
│ │ │ └── recovery.go # Panic recovery
│ │
│ │ ├── domain/ # Core business domain (entities + logic)
│ │ │ ├── user/
│ │ │ │ ├── model.go # User, UserRole types
│ │ │ │ ├── repository.go # Database queries
│ │ │ │ ├── service.go # Business logic
│ │ │ │ ├── handler.go # HTTP handlers
│ │ │ │ └── errors.go # Domain-specific errors
│ │ │ ├── organization/
│ │ │ ├── appointment/ # CRUD + workflows (createFromTemplate, book)
│ │ │ ├── patient/ # Onboarding, profile management
│ │ │ ├── specialist/
│ │ │ ├── specialty/
│ │ │ ├── form/ # Forms feature: instance lifecycle, value management, signing
│ │ │ ├── formtemplate/ # Forms feature: template CRUD, versioning, publishing
│ │ │ ├── document/ # Reports + prescriptions
│ │ │ ├── customfield/ # Versioned field library (referenced by forms, segments)
│ │ │ ├── segment/ # Rules-based patient groups
│ │ │ └── export/ # CSV export with security filters
│ │ │
│ │ ├── integration/ # Core API's external service clients
│ │ │ ├── dailyco/ # Daily.co videocall API
│ │ │ ├── s3/ # AWS S3 file operations
│ │ │ └── webhook/ # Webhook emitter + delivery worker
│ │ │
│ │ └── database/
│ │ ├── postgres.go # Connection pool setup, RLS session config
│ │ ├── tx.go # Transaction helpers
│ │ └── querier.go # Querier interface (ConnFromContext)
│ │
│ ├── telemetry/ # Telemetry-specific code
│ │ ├── config/
│ │ │ └── config.go # Telemetry environment configuration
│ │ ├── server/
│ │ │ ├── server.go
│ │ │ └── routes.go # /v1/audit/ingest, /v1/analytics/track, /v1/media/events, /v1/pose/frames
│ │ ├── middleware/
│ │ │ └── compliance.go # IP extraction, actor hashing, consent, geo enrichment
│ │ ├── audit/ # Audit ingestion + enrichment
│ │ ├── analytics/ # General analytics tracking
│ │ ├── media/ # Video/rehab tracking with bandwidth
│ │ ├── staff/ # Staff activity tracking
│ │ ├── storage/
│ │ │ ├── postgres/ # Telemetry PostgreSQL client (audit.audit_logs, security.security_events)
│ │ │ └── clickhouse/ # ClickHouse client (analytics_events, media_sessions)
│ │ └── privacy/ # CCPA/privacy exclusions
│ │
│ └── shared/ # Shared internal utilities
│ ├── crypto/
│ │ └── encryption.go # AES-256-GCM encrypt/decrypt for PHI
│ ├── httputil/
│ │ ├── response.go # JSON response helpers
│ │ ├── request.go # Request parsing, pagination
│ │ └── errors.go # HTTP error types and rendering
│ ├── validate/
│ │ └── validate.go # Validation setup and helpers
│ └── slug/
│ └── slug.go # Slug generation utility
│
├── migrations/
│ ├── core/ # Core API PostgreSQL migrations
│ ├── telemetry-pg/ # Telemetry PostgreSQL migrations (5 schemas)
│ └── telemetry-ch/ # Telemetry ClickHouse migrations
│
├── Dockerfile
├── docker-compose.yml
├── Makefile # Build, test, migrate, lint commands
├── go.mod
├── go.sum
├── .env.example
└── README.mdCore Patterns
1. Dependency Injection via Constructors
No DI framework. Services receive their dependencies through constructors.
// internal/domain/appointment/service.go
type Service struct {
repo *Repository
formService *form.Service
schedService *SchedulingService
auditClient *audit.Client
}
func NewService(
repo *Repository,
formService *form.Service,
schedService *SchedulingService,
auditClient *audit.Client,
) *Service {
return &Service{
repo: repo,
formService: formService,
schedService: schedService,
auditClient: auditClient,
}
}2. Repository Pattern
Each domain has a repository that owns all SQL queries. No SQL leaks into services or handlers.
Repositories use the RLS-configured connection from context, not the pool directly. The OrganizationContext middleware acquires a connection, sets RLS session variables, and stores it in the request context. Repositories extract this connection to ensure all queries run with the correct tenant scope.
// internal/domain/appointment/repository.go
type Repository struct {
pool *pgxpool.Pool // fallback for background jobs (superadmin, no RLS)
}
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// connFromContext extracts the RLS-configured connection set by OrganizationContext middleware.
// Falls back to pool for background jobs where no request context exists.
func (r *Repository) connFromContext(ctx context.Context) database.Querier {
if conn := database.ConnFromContext(ctx); conn != nil {
return conn // RLS session variables already set on this connection
}
return r.pool // Background job — no RLS (must be superadmin/migration context)
}
// RLS handles organization scoping automatically.
// The connection already has app.current_org_id set via middleware.
func (r *Repository) FindByID(ctx context.Context, id int64) (*Appointment, error) {
conn := r.connFromContext(ctx)
query := `
SELECT id, title, uid, status, started_at, ended_at,
specialist_id, organization_id, user_id,
appointment_template_id, specialty_id,
created_at, updated_at
FROM appointments
WHERE id = $1
`
// RLS will automatically filter by organization
var a Appointment
err := conn.QueryRow(ctx, query, id).Scan(...)
if err != nil {
return nil, err
}
return &a, nil
}3. Handler Pattern (Thin HTTP Layer)
Handlers only do: parse request → call service → render response.
// internal/domain/appointment/handler.go
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) CreateFromTemplate(w http.ResponseWriter, r *http.Request) {
var req CreateFromTemplateRequest
if err := httputil.DecodeJSON(r, &req); err != nil {
httputil.BadRequest(w, err)
return
}
if err := httputil.Validate(&req); err != nil {
httputil.ValidationError(w, err)
return
}
userCtx := middleware.UserFromContext(r.Context())
appointment, err := h.service.CreateFromTemplate(r.Context(), userCtx, &req)
if err != nil {
httputil.HandleError(w, err)
return
}
httputil.JSON(w, http.StatusCreated, appointment)
}4. Error Handling
Domain errors are typed. The HTTP layer maps them to status codes.
// internal/domain/appointment/errors.go
package appointment
import "restartix-api/internal/pkg/httputil"
var (
ErrNotFound = httputil.NewNotFoundError("appointment", "Appointment not found")
ErrTemplateNotFound = httputil.NewNotFoundError("appointment_template", "Appointment template not found")
ErrAlreadyCancelled = httputil.NewConflictError("already_cancelled", "Appointment is already cancelled")
)
// internal/pkg/httputil/errors.go
type AppError struct {
Code string `json:"code"` // Machine-readable: "appointment_not_found"
Message string `json:"message"` // Human-readable
StatusCode int `json:"-"` // HTTP status
Details any `json:"details,omitempty"` // Optional extra data
}
func (e *AppError) Error() string { return e.Message }
func NewNotFoundError(code, message string) *AppError {
return &AppError{Code: code, Message: message, StatusCode: 404}
}
func NewBadRequestError(code, message string) *AppError {
return &AppError{Code: code, Message: message, StatusCode: 400}
}
func NewConflictError(code, message string) *AppError {
return &AppError{Code: code, Message: message, StatusCode: 409}
}
func NewForbiddenError(code, message string) *AppError {
return &AppError{Code: code, Message: message, StatusCode: 403}
}
func NewServiceError(code, message string) *AppError {
return &AppError{Code: code, Message: message, StatusCode: 502}
}
// HandleError maps domain errors to HTTP responses
func HandleError(w http.ResponseWriter, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
JSON(w, appErr.StatusCode, map[string]any{
"error": appErr,
})
return
}
// Unknown error → 500, don't leak internals
slog.Error("unexpected error", "error", err)
JSON(w, 500, map[string]any{
"error": &AppError{
Code: "internal_error",
Message: "An unexpected error occurred",
},
})
}5. Middleware Pipeline
// internal/server/routes.go
func (s *Server) routes() http.Handler {
r := chi.NewRouter()
// Global middleware (order matters)
r.Use(middleware.RequestID)
r.Use(middleware.Recovery)
r.Use(middleware.SecurityHeaders)
r.Use(middleware.Logging)
r.Use(middleware.AttackBlocker) // Block /.env, /wp-admin, etc.
// Health check (no auth required)
r.Get("/health", s.healthHandler.Check)
// Clerk webhook (verified by Clerk signature, not session)
r.Post("/webhooks/clerk", s.userHandler.ClerkWebhook)
// Authenticated routes
r.Group(func(r chi.Router) {
r.Use(middleware.ClerkAuth(s.clerkClient)) // Validate Clerk session → user context
r.Use(middleware.OrganizationContext(s.adminPool, s.appPool)) // Route to pool, set RLS session vars
r.Use(middleware.AuditLog(s.auditClient)) // Audit all mutations
// User / Auth
r.Get("/v1/me", s.userHandler.Me)
r.Put("/v1/me/switch-organization", s.userHandler.SwitchOrganization)
// Organizations
r.Route("/v1/organizations", func(r chi.Router) {
r.Get("/", s.orgHandler.List)
r.Post("/", s.orgHandler.Create) // superadmin only
r.Get("/slug/{slug}", s.orgHandler.FindBySlug)
r.Route("/{orgID}", func(r chi.Router) {
r.Use(middleware.RequireOrgMember)
r.Get("/", s.orgHandler.Get)
r.Put("/", s.orgHandler.Update)
r.Get("/api-keys", s.orgHandler.GetAPIKeys)
r.Post("/connect-user", s.orgHandler.ConnectUser)
})
})
// Appointments
r.Route("/v1/appointments", func(r chi.Router) {
r.Get("/", s.appointmentHandler.List)
r.Post("/", s.appointmentHandler.Create)
r.Post("/from-template", s.appointmentHandler.CreateFromTemplate)
r.Route("/{appointmentID}", func(r chi.Router) {
r.Get("/", s.appointmentHandler.Get)
r.Put("/", s.appointmentHandler.Update)
r.Delete("/", s.appointmentHandler.Delete)
r.Post("/attach-forms", s.appointmentHandler.AttachForms)
r.Post("/reschedule", s.appointmentHandler.Reschedule)
r.Post("/cancel", s.appointmentHandler.Cancel)
})
r.Get("/uid/{uid}", s.appointmentHandler.FindByUID)
})
// Calendar (separate path for clarity)
r.Get("/v1/calendar", s.appointmentHandler.CalendarView)
r.Post("/v1/calendar", s.appointmentHandler.CalendarView)
// Patients
r.Route("/v1/patients", func(r chi.Router) {
r.Get("/", s.patientHandler.List)
r.Post("/", s.patientHandler.Create)
r.Post("/onboard", s.patientHandler.Onboard)
r.Route("/{patientID}", func(r chi.Router) {
r.Get("/", s.patientHandler.Get)
r.Put("/", s.patientHandler.Update)
r.Delete("/", s.patientHandler.Delete)
r.Post("/impersonate", s.patientHandler.Impersonate) // admin only
r.Get("/profile", s.customFieldHandler.GetProfile) // custom field values
r.Put("/profile", s.customFieldHandler.UpdateProfile)
r.Get("/prefill", s.customFieldHandler.GetPrefill) // form auto-fill
r.Get("/segments", s.segmentHandler.ListForPatient)
})
})
// Specialists
r.Route("/v1/specialists", func(r chi.Router) {
r.Get("/", s.specialistHandler.List)
r.Post("/", s.specialistHandler.Create)
r.Route("/{specialistID}", func(r chi.Router) {
r.Get("/", s.specialistHandler.Get)
r.Put("/", s.specialistHandler.Update)
r.Delete("/", s.specialistHandler.Delete)
r.Get("/profile", s.customFieldHandler.GetProfile) // custom field values
r.Put("/profile", s.customFieldHandler.UpdateProfile)
})
})
// Specialties
r.Route("/v1/specialties", func(r chi.Router) {
r.Get("/", s.specialtyHandler.List)
r.Post("/", s.specialtyHandler.Create)
r.Route("/{specialtyID}", func(r chi.Router) {
r.Get("/", s.specialtyHandler.Get)
r.Put("/", s.specialtyHandler.Update)
r.Delete("/", s.specialtyHandler.Delete)
})
})
// Appointment Templates
r.Route("/v1/appointment-templates", func(r chi.Router) {
r.Get("/", s.templateHandler.List)
r.Post("/", s.templateHandler.Create)
r.Get("/slug/{slug}", s.templateHandler.FindBySlug)
r.Route("/{templateID}", func(r chi.Router) {
r.Get("/", s.templateHandler.Get)
r.Put("/", s.templateHandler.Update)
r.Delete("/", s.templateHandler.Delete)
})
})
// Forms (includes templates and instances - unified feature)
r.Route("/v1/forms", func(r chi.Router) {
r.Get("/", s.formHandler.List)
r.Post("/", s.formHandler.Create)
r.Route("/{formID}", func(r chi.Router) {
r.Get("/", s.formHandler.Get)
r.Put("/", s.formHandler.Update)
r.Delete("/", s.formHandler.Delete)
r.Post("/sign", s.formHandler.Sign)
})
})
// Form Templates (part of forms feature - design-time templates)
r.Route("/v1/form-templates", func(r chi.Router) {
r.Get("/", s.formTemplateHandler.List)
r.Post("/", s.formTemplateHandler.Create)
r.Route("/{templateID}", func(r chi.Router) {
r.Get("/", s.formTemplateHandler.Get)
r.Put("/", s.formTemplateHandler.Update)
r.Delete("/", s.formTemplateHandler.Delete)
r.Post("/publish", s.formTemplateHandler.Publish)
r.Get("/versions", s.formTemplateHandler.ListVersions)
})
})
// Reports (appointment_documents where type=report)
r.Route("/v1/reports", func(r chi.Router) {
r.Get("/", s.documentHandler.ListReports)
r.Post("/", s.documentHandler.CreateReport)
r.Route("/{documentID}", func(r chi.Router) {
r.Get("/", s.documentHandler.Get)
r.Put("/", s.documentHandler.Update)
r.Delete("/", s.documentHandler.Delete)
})
})
// Prescriptions (appointment_documents where type=prescription)
r.Route("/v1/prescriptions", func(r chi.Router) {
r.Get("/", s.documentHandler.ListPrescriptions)
r.Post("/", s.documentHandler.CreatePrescription)
r.Route("/{documentID}", func(r chi.Router) {
r.Get("/", s.documentHandler.Get)
r.Put("/", s.documentHandler.Update)
r.Delete("/", s.documentHandler.Delete)
})
})
// Custom Fields
r.Route("/v1/custom-fields", func(r chi.Router) {
r.Get("/", s.customFieldHandler.List)
r.Post("/", s.customFieldHandler.Create)
r.Route("/{fieldID}", func(r chi.Router) {
r.Get("/", s.customFieldHandler.Get)
r.Put("/", s.customFieldHandler.Update)
r.Delete("/", s.customFieldHandler.Delete)
})
})
// Segments
r.Route("/v1/segments", func(r chi.Router) {
r.Get("/", s.segmentHandler.List)
r.Post("/", s.segmentHandler.Create)
r.Route("/{segmentID}", func(r chi.Router) {
r.Get("/", s.segmentHandler.Get)
r.Put("/", s.segmentHandler.Update)
r.Delete("/", s.segmentHandler.Delete)
r.Get("/members", s.segmentHandler.ListMembers)
r.Post("/evaluate", s.segmentHandler.Evaluate)
})
})
// Export
r.With(middleware.RateLimit(5, 5*time.Minute)).
Post("/v1/export/csv", s.exportHandler.CSV)
// GDPR endpoints
r.Route("/v1/gdpr", func(r chi.Router) {
r.Use(middleware.RequireRole("superadmin", "admin"))
r.Get("/users/{userID}/export", s.userHandler.GDPRExport)
r.Delete("/users/{userID}", s.userHandler.GDPRDelete)
r.Post("/users/{userID}/anonymize", s.userHandler.GDPRAnonymize)
})
})
return r
}6. Configuration
// internal/config/config.go
type Config struct {
// Server
Host string `envconfig:"HOST" default:"0.0.0.0"`
Port int `envconfig:"PORT" default:"8080"`
// Database — Two-pool architecture for defense-in-depth:
// AdminPool (DATABASE_URL): owner role, bypasses RLS. Used by auth middleware, superadmins, system queries.
// AppPool (DATABASE_APP_URL): restricted role, RLS enforced. Used by public, staff, patient requests.
// Pool sizing: each request holds a connection for its full duration (RLS requires it).
// DB_POOL_MAX must be >= expected peak concurrent requests.
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
DatabaseAppURL string `envconfig:"DATABASE_APP_URL"` // Falls back to DATABASE_URL if empty
DBPoolMin int `envconfig:"DB_POOL_MIN" default:"5"`
DBPoolMax int `envconfig:"DB_POOL_MAX" default:"25"`
DBPoolMaxConnLifetime time.Duration `envconfig:"DB_POOL_MAX_CONN_LIFETIME" default:"1h"`
DBPoolMaxConnIdleTime time.Duration `envconfig:"DB_POOL_MAX_CONN_IDLE_TIME" default:"30m"`
// Clerk Auth
ClerkSecretKey string `envconfig:"CLERK_SECRET_KEY" required:"true"`
ClerkWebhookSecret string `envconfig:"CLERK_WEBHOOK_SECRET" required:"true"`
// Redis (rate limiting, session tracking)
RedisURL string `envconfig:"REDIS_URL" required:"true"`
// Encryption
EncryptionKey string `envconfig:"ENCRYPTION_KEY" required:"true"` // 64 hex chars (32 bytes)
// AWS S3
AWSAccessKeyID string `envconfig:"AWS_ACCESS_KEY_ID" required:"true"`
AWSSecretAccessKey string `envconfig:"AWS_SECRET_ACCESS_KEY" required:"true"`
AWSRegion string `envconfig:"AWS_REGION" required:"true"`
AWSBucketName string `envconfig:"AWS_BUCKET_NAME" required:"true"`
// Daily.co
DailyAPIKey string `envconfig:"DAILY_API_KEY" required:"true"`
DailyRESTDomain string `envconfig:"DAILY_REST_DOMAIN" default:"https://api.daily.co/v1"`
// Telemetry Service (sibling App Runner service — audit forwarding)
// In production, set via AWS Secrets Manager to the Telemetry App Runner service URL
TelemetryInternalURL string `envconfig:"TELEMETRY_INTERNAL_URL" required:"true"`
// App
AppDomain string `envconfig:"APP_DOMAIN" required:"true"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
}7. Database Connection with RLS (Two-Pool Architecture)
The Core API uses two connection pools:
- AdminPool (
DATABASE_URL): Owner role (restartix), bypasses RLS. Used by auth middleware (user lookup), superadmin requests, and system queries. - AppPool (
DATABASE_APP_URL): Restricted role (restartix_app), RLS enforced. Used by all org-scoped, patient, and public requests.
// internal/database/postgres.go
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewPool(ctx context.Context, databaseURL string, minConns, maxConns int) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database config: %w", err)
}
config.MinConns = int32(minConns)
config.MaxConns = int32(maxConns)
// After each connection is acquired from pool, set RLS session variables.
// This is handled per-request in the middleware, not here.
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("create connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return pool, nil
}
// SetRLSContext sets session variables for Row-Level Security.
// Called at the start of every authenticated request.
func SetRLSContext(ctx context.Context, conn interface{ Exec(ctx context.Context, sql string, args ...any) (any, error) }, userID int64, orgID int64, role string) error {
// These variables are read by RLS policies
_, err := conn.Exec(ctx, `
SELECT set_config('app.current_user_id', $1::text, true),
set_config('app.current_org_id', $2::text, true),
set_config('app.current_role', $3, true)
`, userID, orgID, role)
return err
}8. Server Entrypoint
// cmd/server/main.go
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"restartix-api/internal/config"
"restartix-api/internal/database"
"restartix-api/internal/server"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
setupLogging(cfg.LogLevel)
db, err := database.NewPool(ctx, cfg.DatabaseURL, cfg.DBPoolMin, cfg.DBPoolMax)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.Close()
srv := server.New(cfg, db)
if err := srv.Run(ctx); err != nil {
slog.Error("server error", "error", err)
os.Exit(1)
}
}
func setupLogging(level string) {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})))
}9. Request/Response Utilities
// internal/pkg/httputil/response.go
package httputil
import (
"encoding/json"
"net/http"
)
func JSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
// Paginated response wrapper
type PaginatedResponse[T any] struct {
Data []T `json:"data"`
Meta Meta `json:"meta"`
}
type Meta struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
}
// internal/pkg/httputil/request.go
func DecodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func PaginationFromRequest(r *http.Request) (page, pageSize int) {
// Parse ?page=1&page_size=25 with sane defaults
page = intQueryParam(r, "page", 1)
pageSize = intQueryParam(r, "page_size", 25)
if pageSize > 100 {
pageSize = 100 // Hard cap
}
if page < 1 {
page = 1
}
return page, pageSize
}10. Makefile
.PHONY: build-api build-telemetry run-api run-telemetry test lint migrate-core-up migrate-telemetry-up
# Core API
build-api:
go build -o bin/api ./cmd/api
run-api:
go run ./cmd/api
# Telemetry API
build-telemetry:
go build -o bin/telemetry ./cmd/telemetry
run-telemetry:
go run ./cmd/telemetry
# Shared
test:
go test ./... -v -race -count=1
test-cover:
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
lint:
golangci-lint run ./...
# Migrations
migrate-core-up:
migrate -path migrations/core -database "$(DATABASE_URL)" up
migrate-core-down:
migrate -path migrations/core -database "$(DATABASE_URL)" down 1
migrate-telemetry-pg-up:
migrate -path migrations/telemetry-pg -database "$(TELEMETRY_DATABASE_URL)" up
migrate-telemetry-ch-up:
migrate -path migrations/telemetry-ch -database "$(CLICKHOUSE_URL)" up
migrate-create-core:
migrate create -ext sql -dir migrations/core -seq $(name)
docker-build-api:
docker build -f deploy/Dockerfile.api -t restartix-api .
docker-build-telemetry:
docker build -f deploy/Dockerfile.telemetry -t restartix-telemetry .11. Dockerfiles
Each service has its own Dockerfile. Both build from the monorepo root.
deploy/Dockerfile.api
# Build stage
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api ./cmd/api
# Runtime stage
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
RUN adduser -D -g '' appuser
COPY --from=builder /bin/api /bin/api
COPY --from=builder /app/migrations/core /migrations
USER appuser
EXPOSE 9000
ENTRYPOINT ["/bin/api"]deploy/Dockerfile.telemetry
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/telemetry-api ./cmd/telemetry-api
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
RUN adduser -D -g '' appuser
COPY --from=builder /bin/telemetry-api /bin/telemetry-api
COPY --from=builder /app/migrations/telemetry-pg /migrations/pg
COPY --from=builder /app/migrations/telemetry-ch /migrations/ch
USER appuser
EXPOSE 4000
ENTRYPOINT ["/bin/telemetry-api"]