Skip to main content

Production Go Service — How It All Works

Notes from walking through Wealthy's LAMF (Loans Against Mutual Funds) lending service. A real production Go backend.


Entry Point​

Every Go program starts at main(). In production apps, main.go is deliberately tiny — it just boots up and hands off control.

func main() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}

The _ import trick​

_ "github.com/joho/godotenv/autoload"

The _ means: import this package only for its side effects, don't use it directly. godotenv/autoload automatically reads .env and loads all variables into the process environment before anything else runs. That's how DB_HOST, API keys etc. are available at startup without you explicitly loading them.

init() vs main()​

init() runs automatically before main() in Go. Used for setup — registering subcommands, initialising package-level state etc.


Why Production Backends Are CLI Apps​

Hobby projects: one thing — start the server, done.

Production backends need multiple distinct operations that share the same codebase:

  • server — long-running HTTP process
  • cli migrate — short-lived task, runs once and exits
  • cli create-partner — admin utility
  • webhooks subscribe — one-time infrastructure setup

If these were separate programs, they'd all duplicate DB connection logic, config loading, models etc. One CLI app with subcommands means one binary, shared codebase.

Same pattern in other ecosystems:

  • Django: python manage.py runserver, python manage.py migrate — same app, different commands
  • Rails: rails server, rails db:migrate — same thing

The library that makes this work in Go is Cobra — same library used by kubectl, docker, hugo.


DB Migrations​

Your database schema evolves over time — new columns, new tables, renamed fields. Migrations are versioned, recorded changes to the schema.

The problem they solve: 3 engineers, 3 laptops, 1 production DB. Engineer A adds a column locally. How does everyone else's DB know to add it? Migrations solve this — each change is recorded, and the app checks what version the DB is at and runs only what's missing.

GORM AutoMigrate: In this codebase, GORM looks at your Go structs and automatically figures out what SQL to run to make the DB match them. Runs at server startup before accepting requests. No manual SQL needed for additive changes (new columns, new tables).

Lesson learned the hard way: Adding not null to a new column on a table with existing rows will fail — existing rows got NULL when the column was added. Use default:0 without not null for new numeric columns on existing tables.


Kubernetes Concepts (via Proxmox analogy)​

ProxmoxKubernetes
Physical PC/serverNode
Proxmox cluster (multiple PCs)Cluster
LXC containerPod
OS templateContainer Image

Key difference: Proxmox LXCs often run multiple services. Kubernetes pods run one focused process. More fine-grained.

Types of workloads​

Deployment — keep N pods running forever, restart on crash. Your HTTP server. Like an LXC with auto-restart.

Job — run once, complete, exit. DB migrations. Like spinning up an LXC, running a script, shutting it down.

CronJob — Job on a schedule. "Run every night at 2am."

Worker — a long-running Deployment that processes a queue instead of HTTP requests. Pulls tasks from Redis/Pub-Sub, processes them in the background. No HTTP involved.

Sidecar — a helper container running alongside your main container in the same pod, sharing its network and storage. Examples:

  • Log collector — reads your app's logs, ships them to centralised logging
  • Proxy (Envoy/Istio) — intercepts traffic for security/observability
  • Secrets agent — fetches credentials and writes to shared volume

Your app doesn't know or care about sidecars. They handle cross-cutting concerns invisibly.

Typical deploy flow​

Code merged → CI builds binary → new pod starts
→ AutoMigrate runs (schema updated)
→ server starts listening
→ old pods terminated

cmd/server.go — What Happens at Startup​

The full startup sequence in order:

1. Migrations​

database.InitDB()      // connect to PostgreSQL
database.AutoMigrate() // sync core tables
database.DB.AutoMigrate(&quicklend.QuicklendPartner{}, ...) // provider-specific tables
database.DB.AutoMigrate(&voltmoney.VoltmoneyCustomer{}, ...)

Runs before any requests are accepted.

2. Clients (HTTP clients for internal services)​

hydraClient    // agent permissions & customer relationships
novaClient // MF portfolio data (Wealthy's own data)
hagridClient // customer profile/KYC
phaserClient // MF tracker data (third-party)
voltmoneyClient // the loan provider
notificationClient // alerts/campaigns

These are just structs that know how to make HTTP calls to other internal microservices. Each service in the company (Hydra, Nova, Hagrid, Phaser) is its own microservice — this app talks to them over HTTP.

3. Service + Provider registration​

mfLoanService := service.NewMFLoanService(novaClient, phaserClient)
mfLoanService.RegisterProvider("quicklend", quicklendProvider)
mfLoanService.RegisterProvider("voltmoney", voltmoneyProvider)

The service layer is the business logic brain. It doesn't care which loan provider is being used — it talks to a common interface. Providers are registered by name so the right one gets called based on the request.

4. Dependency injection into handlers​

handler := api.NewHandler(mfLoanService, hydraClient, ...)
opsHandler := api.NewOperationsHandler(mfLoanService, hagridClient)

Handlers are what respond to HTTP requests. They're given everything they need upfront (service, clients) — this is dependency injection. No global variables, no reaching out to grab things. Everything is explicit and testable.

5. Router setup (Gin)​

router := gin.Default()
router.GET("/health/", ...) // health check for Kubernetes liveness probes

Gin is the HTTP framework (like Express in Node.js). gin.Default() creates a router with logging and panic recovery middleware built in.

6. Route groups​

clientGroup    → /customers/api/v0/loans/...   (requires X-W-User-Id header)
partnerGroup → /partners/api/v0/loans/... (requires X-W-Agent-Id header)
operationsGroup → /operations/api/v0/loans/... (no auth — internal only)
webhookGroup → /webhooks/:provider/:key/ (secret key verification)

Different route groups = different auth requirements. Same handler logic underneath, different access controls layered on top via middleware.

7. Graceful shutdown​

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit // block here until signal received

server.Shutdown(ctx) // 30 second timeout to finish in-flight requests

When Kubernetes wants to terminate a pod, it sends SIGTERM. The server catches it, stops accepting new requests, waits up to 30 seconds for in-flight requests to finish, then exits cleanly. This is graceful shutdown — no requests get cut off mid-flight.

8. OTel (OpenTelemetry) metrics​

When enabled, every request gets counted with its status code, path, and method. This feeds into dashboards/alerting so the team can see traffic, error rates, latency etc. Standard observability tooling in production.


Layering Convention (important for this codebase)​

LayerLocationPurposeTags
DB modelsinternal/database/models.goGORM structs — talk to PostgreSQLgorm:"..." only
Domain modelsinternal/models/Business logic structs — returned from services, serialised in API responsesjson:"..." only
Serviceinternal/service/Business logic, orchestration. Returns domain models. DB save is internal detail.none
Handlersinternal/api/HTTP request/response. Calls service, returns domain models as JSON.none
Providersinternal/providers/External loan provider integrations (VoltMoney, Quicklend)none
Clientsinternal/client/Internal microservice HTTP clientsnone

Key rule: Never add json tags to DB models. Never add gorm tags to domain models. Each layer has one job.

JSONB fields in DB models are write-only from the app's perspective — data gets marshalled and stored, never unmarshalled back in the API layer. The service returns the domain model it already has in memory.


Health Check​

router.GET("/health/", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})

Every production service has this. Kubernetes hits it periodically — if it returns anything other than 2xx, Kubernetes considers the pod unhealthy and restarts it. Called a liveness probe.