Back to Journal
SaaS Engineering

Complete Guide to SaaS API Design with Go

A comprehensive guide to implementing SaaS API Design using Go, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 15 min read

Go's combination of performance, simplicity, and first-class concurrency makes it a natural fit for building SaaS APIs. This guide walks through the complete architecture of a production-grade SaaS API in Go, from project structure through authentication, multi-tenancy, and observability.

Every pattern shown here has been validated in production systems handling thousands of requests per second. The focus is on idiomatic Go—leveraging the standard library where possible and reaching for external packages only when they provide clear value.

Project Structure

A well-organized Go project separates concerns without over-abstracting:

1├── cmd/
2│ └── api/
3│ └── main.go # Entry point, dependency wiring
4├── internal/
5│ ├── config/
6│ │ └── config.go # Environment-based configuration
7│ ├── handler/
8│ │ ├── health.go
9│ │ ├── order.go
10│ │ └── user.go
11│ ├── middleware/
12│ │ ├── auth.go
13│ │ ├── cors.go
14│ │ ├── ratelimit.go
15│ │ ├── recovery.go
16│ │ └── tenant.go
17│ ├── model/
18│ │ ├── order.go
19│ │ └── user.go
20│ ├── repository/
21│ │ ├── order_repo.go
22│ │ └── user_repo.go
23│ ├── service/
24│ │ ├── order_service.go
25│ │ └── user_service.go
26│ └── platform/
27│ ├── database/
28│ │ └── postgres.go
29│ ├── cache/
30│ │ └── redis.go
31│ └── logger/
32│ └── logger.go
33├── migrations/
34│ ├── 001_create_users.sql
35│ └── 002_create_orders.sql
36├── go.mod
37└── go.sum
38 

Configuration and Startup

Load configuration from environment variables with validation:

go
1package config
2 
3import (
4 "fmt"
5 "os"
6 "strconv"
7 "time"
8)
9 
10type Config struct {
11 Server ServerConfig
12 Database DatabaseConfig
13 Redis RedisConfig
14 Auth AuthConfig
15}
16 
17type ServerConfig struct {
18 Port int
19 ReadTimeout time.Duration
20 WriteTimeout time.Duration
21 ShutdownTimeout time.Duration
22}
23 
24type DatabaseConfig struct {
25 URL string
26 MaxOpenConns int
27 MaxIdleConns int
28 ConnMaxLifetime time.Duration
29}
30 
31type AuthConfig struct {
32 JWTSecret string
33 TokenExpiry time.Duration
34}
35 
36func Load() (*Config, error) {
37 port, err := strconv.Atoi(getEnv("PORT", "8080"))
38 if err != nil {
39 return nil, fmt.Errorf("invalid PORT: %w", err)
40 }
41 
42 dbURL := os.Getenv("DATABASE_URL")
43 if dbURL == "" {
44 return nil, fmt.Errorf("DATABASE_URL is required")
45 }
46 
47 jwtSecret := os.Getenv("JWT_SECRET")
48 if jwtSecret == "" {
49 return nil, fmt.Errorf("JWT_SECRET is required")
50 }
51 
52 return &Config{
53 Server: ServerConfig{
54 Port: port,
55 ReadTimeout: 15 * time.Second,
56 WriteTimeout: 15 * time.Second,
57 ShutdownTimeout: 30 * time.Second,
58 },
59 Database: DatabaseConfig{
60 URL: dbURL,
61 MaxOpenConns: 25,
62 MaxIdleConns: 5,
63 ConnMaxLifetime: 5 * time.Minute,
64 },
65 Auth: AuthConfig{
66 JWTSecret: jwtSecret,
67 TokenExpiry: 15 * time.Minute,
68 },
69 }, nil
70}
71 
72func getEnv(key, fallback string) string {
73 if v := os.Getenv(key); v != "" {
74 return v
75 }
76 return fallback
77}
78 

Wire everything together in main.go:

go
1package main
2 
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "os"
9 "os/signal"
10 "syscall"
11 
12 "github.com/go-chi/chi/v5"
13 "yourapp/internal/config"
14 "yourapp/internal/handler"
15 "yourapp/internal/middleware"
16 "yourapp/internal/platform/database"
17 "yourapp/internal/repository"
18 "yourapp/internal/service"
19)
20 
21func main() {
22 logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
23 Level: slog.LevelInfo,
24 }))
25 slog.SetDefault(logger)
26 
27 cfg, err := config.Load()
28 if err != nil {
29 slog.Error("failed to load config", "error", err)
30 os.Exit(1)
31 }
32 
33 db, err := database.Connect(cfg.Database)
34 if err != nil {
35 slog.Error("failed to connect to database", "error", err)
36 os.Exit(1)
37 }
38 defer db.Close()
39 
40 // Wire dependencies
41 userRepo := repository.NewUserRepository(db)
42 orderRepo := repository.NewOrderRepository(db)
43 userService := service.NewUserService(userRepo)
44 orderService := service.NewOrderService(orderRepo, userRepo)
45 userHandler := handler.NewUserHandler(userService)
46 orderHandler := handler.NewOrderHandler(orderService)
47 
48 // Build router
49 r := chi.NewRouter()
50 r.Use(middleware.Recovery)
51 r.Use(middleware.RequestID)
52 r.Use(middleware.Logger)
53 r.Use(middleware.CORS(cfg.Server.AllowedOrigins))
54 
55 r.Get("/health", handler.Health)
56 
57 r.Route("/api/v1", func(r chi.Router) {
58 r.Use(middleware.Authenticate(cfg.Auth.JWTSecret))
59 r.Use(middleware.TenantContext)
60 
61 r.Route("/users", func(r chi.Router) {
62 r.Get("/", userHandler.List)
63 r.Post("/", userHandler.Create)
64 r.Get("/{id}", userHandler.Get)
65 r.Patch("/{id}", userHandler.Update)
66 })
67 
68 r.Route("/orders", func(r chi.Router) {
69 r.Get("/", orderHandler.List)
70 r.Post("/", orderHandler.Create)
71 r.Get("/{id}", orderHandler.Get)
72 })
73 })
74 
75 srv := &http.Server{
76 Addr: fmt.Sprintf(":%d", cfg.Server.Port),
77 Handler: r,
78 ReadTimeout: cfg.Server.ReadTimeout,
79 WriteTimeout: cfg.Server.WriteTimeout,
80 }
81 
82 // Graceful shutdown
83 go func() {
84 slog.Info("server starting", "port", cfg.Server.Port)
85 if err := srv.ListenAndServe(); err != http.ErrServerClosed {
86 slog.Error("server error", "error", err)
87 os.Exit(1)
88 }
89 }()
90 
91 quit := make(chan os.Signal, 1)
92 signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
93 <-quit
94 
95 ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
96 defer cancel()
97 
98 slog.Info("shutting down server")
99 if err := srv.Shutdown(ctx); err != nil {
100 slog.Error("server forced shutdown", "error", err)
101 }
102}
103 

Database Layer with pgx

Use pgx for PostgreSQL—it's the fastest Go PostgreSQL driver and supports connection pooling natively:

go
1package database
2 
3import (
4 "context"
5 "fmt"
6 
7 "github.com/jackc/pgx/v5/pgxpool"
8 "yourapp/internal/config"
9)
10 
11func Connect(cfg config.DatabaseConfig) (*pgxpool.Pool, error) {
12 poolCfg, err := pgxpool.ParseConfig(cfg.URL)
13 if err != nil {
14 return nil, fmt.Errorf("parsing database URL: %w", err)
15 }
16 
17 poolCfg.MaxConns = int32(cfg.MaxOpenConns)
18 poolCfg.MinConns = int32(cfg.MaxIdleConns)
19 poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime
20 
21 pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
22 if err != nil {
23 return nil, fmt.Errorf("creating connection pool: %w", err)
24 }
25 
26 if err := pool.Ping(context.Background()); err != nil {
27 return nil, fmt.Errorf("pinging database: %w", err)
28 }
29 
30 return pool, nil
31}
32 

Repository Pattern

Keep database queries isolated in repository structs:

go
1package repository
2 
3import (
4 "context"
5 "fmt"
6 
7 "github.com/jackc/pgx/v5"
8 "github.com/jackc/pgx/v5/pgxpool"
9 "yourapp/internal/model"
10)
11 
12type OrderRepository struct {
13 db *pgxpool.Pool
14}
15 
16func NewOrderRepository(db *pgxpool.Pool) *OrderRepository {
17 return &OrderRepository{db: db}
18}
19 
20func (r *OrderRepository) FindByID(ctx context.Context, tenantID, orderID string) (*model.Order, error) {
21 query := `
22 SELECT id, tenant_id, customer_id, status, total_amount, currency,
23 created_at, updated_at
24 FROM orders
25 WHERE id = $1 AND tenant_id = $2
26 `
27 
28 var order model.Order
29 err := r.db.QueryRow(ctx, query, orderID, tenantID).Scan(
30 &order.ID, &order.TenantID, &order.CustomerID, &order.Status,
31 &order.TotalAmount, &order.Currency, &order.CreatedAt, &order.UpdatedAt,
32 )
33 if err == pgx.ErrNoRows {
34 return nil, model.ErrNotFound
35 }
36 if err != nil {
37 return nil, fmt.Errorf("querying order: %w", err)
38 }
39 
40 return &order, nil
41}
42 
43func (r *OrderRepository) List(
44 ctx context.Context, tenantID string, cursor string, limit int,
45) ([]model.Order, string, error) {
46 query := `
47 SELECT id, tenant_id, customer_id, status, total_amount, currency,
48 created_at, updated_at
49 FROM orders
50 WHERE tenant_id = $1
51 `
52 args := []interface{}{tenantID}
53 
54 if cursor != "" {
55 query += " AND id < $2"
56 args = append(args, cursor)
57 }
58 
59 query += " ORDER BY id DESC LIMIT $" + fmt.Sprintf("%d", len(args)+1)
60 args = append(args, limit+1)
61 
62 rows, err := r.db.Query(ctx, query, args...)
63 if err != nil {
64 return nil, "", fmt.Errorf("querying orders: %w", err)
65 }
66 defer rows.Close()
67 
68 var orders []model.Order
69 for rows.Next() {
70 var o model.Order
71 if err := rows.Scan(
72 &o.ID, &o.TenantID, &o.CustomerID, &o.Status,
73 &o.TotalAmount, &o.Currency, &o.CreatedAt, &o.UpdatedAt,
74 ); err != nil {
75 return nil, "", fmt.Errorf("scanning order: %w", err)
76 }
77 orders = append(orders, o)
78 }
79 
80 var nextCursor string
81 if len(orders) > limit {
82 nextCursor = orders[limit-1].ID
83 orders = orders[:limit]
84 }
85 
86 return orders, nextCursor, nil
87}
88 
89func (r *OrderRepository) Create(ctx context.Context, order *model.Order) error {
90 query := `
91 INSERT INTO orders (id, tenant_id, customer_id, status, total_amount, currency)
92 VALUES ($1, $2, $3, $4, $5, $6)
93 RETURNING created_at, updated_at
94 `
95 
96 return r.db.QueryRow(ctx, query,
97 order.ID, order.TenantID, order.CustomerID,
98 order.Status, order.TotalAmount, order.Currency,
99 ).Scan(&order.CreatedAt, &order.UpdatedAt)
100}
101 

Authentication Middleware

JWT-based authentication with tenant extraction:

go
1package middleware
2 
3import (
4 "context"
5 "net/http"
6 "strings"
7 
8 "github.com/golang-jwt/jwt/v5"
9)
10 
11type contextKey string
12 
13const (
14 UserIDKey contextKey = "user_id"
15 TenantIDKey contextKey = "tenant_id"
16)
17 
18func Authenticate(secret string) func(http.Handler) http.Handler {
19 return func(next http.Handler) http.Handler {
20 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 authHeader := r.Header.Get("Authorization")
22 if authHeader == "" {
23 writeError(w, http.StatusUnauthorized, "Missing authorization header")
24 return
25 }
26 
27 tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
28 if tokenStr == authHeader {
29 writeError(w, http.StatusUnauthorized, "Invalid authorization format")
30 return
31 }
32 
33 token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
34 if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
35 return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
36 }
37 return []byte(secret), nil
38 })
39 
40 if err != nil || !token.Valid {
41 writeError(w, http.StatusUnauthorized, "Invalid or expired token")
42 return
43 }
44 
45 claims, ok := token.Claims.(jwt.MapClaims)
46 if !ok {
47 writeError(w, http.StatusUnauthorized, "Invalid token claims")
48 return
49 }
50 
51 ctx := r.Context()
52 ctx = context.WithValue(ctx, UserIDKey, claims["sub"])
53 ctx = context.WithValue(ctx, TenantIDKey, claims["tenant_id"])
54 
55 next.ServeHTTP(w, r.WithContext(ctx))
56 })
57 }
58}
59 
60func GetUserID(ctx context.Context) string {
61 return ctx.Value(UserIDKey).(string)
62}
63 
64func GetTenantID(ctx context.Context) string {
65 return ctx.Value(TenantIDKey).(string)
66}
67 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Handler Layer

Handlers translate HTTP requests into service calls and format responses:

go
1package handler
2 
3import (
4 "encoding/json"
5 "net/http"
6 
7 "github.com/go-chi/chi/v5"
8 "github.com/go-playground/validator/v10"
9 "yourapp/internal/middleware"
10 "yourapp/internal/model"
11 "yourapp/internal/service"
12)
13 
14var validate = validator.New()
15 
16type OrderHandler struct {
17 service *service.OrderService
18}
19 
20func NewOrderHandler(s *service.OrderService) *OrderHandler {
21 return &OrderHandler{service: s}
22}
23 
24type CreateOrderRequest struct {
25 CustomerID string `json:"customer_id" validate:"required,uuid"`
26 Amount float64 `json:"amount" validate:"required,gt=0"`
27 Currency string `json:"currency" validate:"required,len=3"`
28}
29 
30func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
31 var req CreateOrderRequest
32 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
33 writeError(w, http.StatusBadRequest, "Invalid request body")
34 return
35 }
36 
37 if err := validate.Struct(req); err != nil {
38 writeValidationError(w, err)
39 return
40 }
41 
42 tenantID := middleware.GetTenantID(r.Context())
43 
44 order, err := h.service.CreateOrder(r.Context(), tenantID, service.CreateOrderInput{
45 CustomerID: req.CustomerID,
46 Amount: req.Amount,
47 Currency: req.Currency,
48 })
49 if err != nil {
50 handleServiceError(w, err)
51 return
52 }
53 
54 writeJSON(w, http.StatusCreated, order)
55}
56 
57func (h *OrderHandler) Get(w http.ResponseWriter, r *http.Request) {
58 tenantID := middleware.GetTenantID(r.Context())
59 orderID := chi.URLParam(r, "id")
60 
61 order, err := h.service.GetOrder(r.Context(), tenantID, orderID)
62 if err != nil {
63 handleServiceError(w, err)
64 return
65 }
66 
67 writeJSON(w, http.StatusOK, order)
68}
69 
70func (h *OrderHandler) List(w http.ResponseWriter, r *http.Request) {
71 tenantID := middleware.GetTenantID(r.Context())
72 cursor := r.URL.Query().Get("cursor")
73 limit := parseLimit(r.URL.Query().Get("limit"), 20, 100)
74 
75 orders, nextCursor, err := h.service.ListOrders(r.Context(), tenantID, cursor, limit)
76 if err != nil {
77 handleServiceError(w, err)
78 return
79 }
80 
81 writeJSON(w, http.StatusOK, map[string]interface{}{
82 "data": orders,
83 "next_cursor": nextCursor,
84 "has_more": nextCursor != "",
85 })
86}
87 
88// Response helpers
89func writeJSON(w http.ResponseWriter, status int, data interface{}) {
90 w.Header().Set("Content-Type", "application/json")
91 w.WriteHeader(status)
92 json.NewEncoder(w).Encode(map[string]interface{}{"data": data})
93}
94 
95func writeError(w http.ResponseWriter, status int, message string) {
96 w.Header().Set("Content-Type", "application/json")
97 w.WriteHeader(status)
98 json.NewEncoder(w).Encode(map[string]interface{}{
99 "type": http.StatusText(status),
100 "status": status,
101 "detail": message,
102 })
103}
104 

Multi-Tenant Data Isolation

Enforce tenant boundaries at the repository level using PostgreSQL Row Level Security:

sql
1-- Migration: Enable RLS on orders table
2ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
3 
4CREATE POLICY tenant_isolation ON orders
5 USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
6 
7-- Force RLS even for table owners
8ALTER TABLE orders FORCE ROW LEVEL SECURITY;
9 
go
1// Set tenant context on each request's database connection
2func (r *OrderRepository) withTenant(ctx context.Context, tenantID string) (*pgxpool.Conn, error) {
3 conn, err := r.db.Acquire(ctx)
4 if err != nil {
5 return nil, err
6 }
7 
8 _, err = conn.Exec(ctx, "SET app.current_tenant_id = $1", tenantID)
9 if err != nil {
10 conn.Release()
11 return nil, fmt.Errorf("setting tenant context: %w", err)
12 }
13 
14 return conn, nil
15}
16 

Observability with OpenTelemetry

Instrument your API for tracing and metrics:

go
1package middleware
2 
3import (
4 "net/http"
5 "time"
6 
7 "go.opentelemetry.io/otel"
8 "go.opentelemetry.io/otel/attribute"
9 "go.opentelemetry.io/otel/metric"
10)
11 
12var (
13 tracer = otel.Tracer("api")
14 requestCount metric.Int64Counter
15 requestDuration metric.Float64Histogram
16)
17 
18func init() {
19 meter := otel.Meter("api")
20 requestCount, _ = meter.Int64Counter("http.request.count")
21 requestDuration, _ = meter.Float64Histogram("http.request.duration",
22 metric.WithUnit("ms"),
23 )
24}
25 
26func Metrics(next http.Handler) http.Handler {
27 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 start := time.Now()
29 
30 ctx, span := tracer.Start(r.Context(), r.Method+" "+r.URL.Path)
31 defer span.End()
32 
33 rec := &statusRecorder{ResponseWriter: w, statusCode: 200}
34 next.ServeHTTP(rec, r.WithContext(ctx))
35 
36 duration := time.Since(start).Milliseconds()
37 
38 attrs := []attribute.KeyValue{
39 attribute.String("http.method", r.Method),
40 attribute.String("http.route", r.URL.Path),
41 attribute.Int("http.status_code", rec.statusCode),
42 }
43 
44 requestCount.Add(ctx, 1, metric.WithAttributes(attrs...))
45 requestDuration.Record(ctx, float64(duration), metric.WithAttributes(attrs...))
46 })
47}
48 

Conclusion

Building a SaaS API in Go rewards simplicity. The patterns shown here—explicit dependency injection, repository-based data access, middleware-driven cross-cutting concerns, and structured error handling—leverage Go's strengths without fighting the language.

The key insight is that Go's standard library handles most of what you need. net/http provides the HTTP server, encoding/json handles serialization, and log/slog gives structured logging. External libraries like chi for routing and pgx for PostgreSQL add capabilities where the standard library falls short, but the core architecture remains idiomatic Go.

Resist the temptation to build framework-like abstractions. Go's power comes from its explicitness. Every request flows through a clear path from handler to service to repository, with no hidden magic. This transparency makes debugging straightforward and onboarding new team members fast.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026