**Main Changes:** 1. **Package Restructuring** - Separated mixed concerns into focused packages: - Created `internal/models/cv/` for CV domain logic (CV, Personal, Experience, etc.) - Created `internal/models/ui/` for UI presentation logic (InfoModal, ShortcutsModal, etc.) - Removed monolithic `internal/models/cv.go` (300+ lines → organized packages) 2. **Testing** - Added comprehensive unit tests: - `internal/models/cv/loader_test.go` - CV data loading and validation - `internal/models/ui/loader_test.go` - UI translations loading - All tests passing ✅ 3. **Documentation** - Added Go learning knowledge base: - `_go-learning/architecture/server-design.md` - Goroutines, graceful shutdown explained - `_go-learning/refactorings/001-cv-model-separation.md` - This refactoring documented - Public documentation showcasing Go expertise (README.md kept private) 4. **Handler Updates** - Updated imports to use new package structure: - `internal/handlers/cv.go` - Uses `cvmodel` and `uimodel` aliases **Benefits:** - ✅ Clear separation of concerns (domain vs presentation) - ✅ Better testability (isolated package testing) - ✅ Improved maintainability (smaller, focused files) - ✅ Scalability (each domain can evolve independently) - ✅ Follows Go best practices (small, cohesive packages) **Other Updates:** - Updated middleware security checks - Template improvements - Organized completed prompts **Testing:** - All Go unit tests pass (cv, ui, handlers) - Server verified with curl tests (English, Spanish, Health endpoints) - Frontend functionality unchanged (refactoring is transparent to UI)
16 KiB
Go Server Architecture: Why Goroutines and Graceful Shutdown
Last Updated: 2025-11-20 Learning Value: ⭐⭐⭐⭐⭐
📋 Table of Contents
- Server Startup Flow
- Why Start Server in a Goroutine?
- Graceful Shutdown Pattern
- Channel Communication
- Context and Timeouts
- Production Best Practices
🚀 Server Startup Flow
The Code (main.go:60-101)
// Start server in goroutine
serverErrors := make(chan error, 1)
go func() {
log.Printf("✓ Server listening on http://%s:%s", cfg.Server.Host, cfg.Server.Port)
serverErrors <- server.ListenAndServe() // Blocks until server stops
}()
// Setup graceful shutdown
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
// Wait for shutdown signal or server error
select {
case err := <-serverErrors:
// Server stopped unexpectedly
if !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("❌ Server error: %v", err)
}
case sig := <-shutdown:
// User pressed Ctrl+C or system sent SIGTERM
log.Printf("🛑 Shutdown signal received: %v", sig)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("⚠️ Graceful shutdown failed, forcing: %v", err)
server.Close() // Force close if graceful fails
}
}
Visual Flow
main() starts
│
├─→ Load config
├─→ Initialize templates
├─→ Create handlers
├─→ Setup routes
└─→ Create HTTP server
│
├─→ Create error channel (serverErrors)
│
├─→ Launch GOROUTINE ──────────────┐
│ │
│ (main thread continues) │ (goroutine: runs in parallel)
│ │
│ ├─→ Log startup messages
│ │
│ └─→ server.ListenAndServe()
│ (BLOCKS here, handling HTTP requests)
│
├─→ Create shutdown channel
│
├─→ Setup signal handler (Ctrl+C, SIGTERM)
│
└─→ SELECT statement (BLOCKS here)
┌───────────────────────────────┐
│ │
├─→ Case 1: serverErrors │ Case 2: shutdown signal
│ (server crashed) │ (user pressed Ctrl+C)
│ → Log error │ → Initiate graceful shutdown
│ → Exit │ → Wait max 30s for requests to finish
│ │ → Force close if timeout
└───────────────────────────────┘
🤔 Why Start Server in a Goroutine?
The Question
"Why do we do
go func() { server.ListenAndServe() }()instead of justserver.ListenAndServe()directly?"
The Answer: Blocking vs. Non-Blocking
Without Goroutine (WRONG):
func main() {
server := &http.Server{Addr: ":1999"}
log.Println("Starting server...")
server.ListenAndServe() // ← BLOCKS HERE FOREVER
// This code NEVER runs!
setupGracefulShutdown() // ❌ Never reached
waitForSignals() // ❌ Never reached
}
Problem: ListenAndServe() blocks (waits forever) handling HTTP requests. The function never returns unless the server crashes. Any code after it is unreachable!
With Goroutine (CORRECT):
func main() {
server := &http.Server{Addr: ":1999"}
// Launch server in separate goroutine
serverErrors := make(chan error, 1)
go func() {
log.Println("Starting server...")
serverErrors <- server.ListenAndServe() // Runs in parallel
}()
// Main thread continues immediately!
setupGracefulShutdown() // ✅ Runs
waitForSignals() // ✅ Runs
}
Solution: The goroutine runs in parallel with the main thread. The server handles requests in the goroutine while the main thread sets up shutdown logic.
What is a Goroutine?
Goroutine = Lightweight thread managed by Go runtime
// Regular function call (synchronous)
doWork() // Wait for doWork to finish before continuing
// Goroutine (asynchronous)
go doWork() // Start doWork in parallel, continue immediately
Key Characteristics:
- ⚡ Lightweight: ~2KB memory (OS threads: ~2MB)
- 🚀 Fast: Cheap to create, Go can run thousands simultaneously
- 🎯 Scheduled by Go: Runtime multiplexes goroutines onto OS threads
- 📡 Communicate via channels: Don't share memory, share by communicating
📡 Channel Communication
What is a Channel?
Channel = Typed conduit through which goroutines communicate
// Create a channel of ints
messages := make(chan int)
// Send value to channel (blocks until someone receives)
messages <- 42
// Receive value from channel (blocks until someone sends)
value := <-messages
Why Channels?
Problem: How does the main thread know when the server goroutine encounters an error?
Solution: Use a channel to communicate errors from goroutine → main thread
// Main thread
serverErrors := make(chan error, 1) // Buffered channel (capacity 1)
// Goroutine (different thread)
go func() {
err := server.ListenAndServe()
serverErrors <- err // Send error to channel
}()
// Main thread
select {
case err := <-serverErrors: // Receive error from channel
log.Fatalf("Server failed: %v", err)
}
Buffered vs. Unbuffered Channels
// Unbuffered (capacity 0) - default
ch := make(chan int)
// Sender blocks until receiver is ready
// Receiver blocks until sender sends
// Buffered (capacity > 0)
ch := make(chan int, 1)
// Sender doesn't block if buffer has space
// Receiver blocks only if buffer is empty
Why buffered for serverErrors?
serverErrors := make(chan error, 1) // Buffer size 1
If the server crashes before we reach the select statement, the error can be stored in the buffer. Without buffering, the send would block forever (deadlock).
🛑 Graceful Shutdown Pattern
The Problem: Abrupt Shutdown
// BAD: Immediate shutdown
func main() {
server.ListenAndServe()
// User presses Ctrl+C
// → Server IMMEDIATELY stops
// → Ongoing requests are KILLED mid-flight
// → Data loss, corrupted responses
}
Consequences:
- User uploading a file → Upload lost
- Database transaction → Data inconsistent
- API call → Client gets network error
The Solution: Graceful Shutdown
// GOOD: Graceful shutdown
server.Shutdown(ctx) // Waits for ongoing requests to finish
Process:
- Stop accepting new requests
- Wait for ongoing requests to complete
- Close idle connections
- Shut down cleanly
The Code Breakdown
Step 1: Setup Signal Handler
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
What this does:
- Creates a channel to receive OS signals
- Tells Go: "When user presses Ctrl+C (
SIGINT) or system sendsSIGTERM, send signal to this channel"
Why SIGTERM?
- Docker uses
SIGTERMto stop containers - Kubernetes uses
SIGTERMbefore killing pods - Systemd uses
SIGTERMto stop services
Step 2: Wait for Signal
select {
case err := <-serverErrors:
// Server crashed
case sig := <-shutdown:
// Shutdown requested
}
select statement: Waits for whichever happens first:
- Server crashes → handle error
- User presses Ctrl+C → initiate shutdown
Step 3: Graceful Shutdown with Timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("⚠️ Graceful shutdown failed, forcing: %v", err)
server.Close() // Force close
}
What happens:
- Create context with 30-second timeout
- Call
server.Shutdown(ctx):- Stop accepting new connections
- Wait for active requests to finish (max 30s)
- Close idle connections
- If timeout expires:
- Graceful shutdown fails
- Force close the server (kill all connections)
🕒 Context and Timeouts
What is a Context?
Context = Carries deadlines, cancellation signals, and request-scoped values across API boundaries
// Create context with 5-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // Always call cancel to release resources
// Use context in operation
err := longRunningOperation(ctx)
if errors.Is(err, context.DeadlineExceeded) {
log.Println("Operation timed out!")
}
Why Context for Shutdown?
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
Benefits:
- Prevents infinite waiting: If requests don't finish in 30s, proceed anyway
- Resource cleanup:
defer cancel()ensures context resources are freed - Cancellation propagation: All handlers get notified to wrap up
Context Hierarchy
context.Background() ← Root context
│
└─→ context.WithTimeout(30s) ← Child context
│
└─→ HTTP request handlers use this context
(when timeout expires, all get cancelled)
🎯 Why This Pattern?
Comparison
| Approach | Pro | Con |
|---|---|---|
| No goroutine | Simple | Can't handle shutdown, server blocks forever |
| Goroutine without channels | Server runs in background | Can't detect errors, no communication |
| Our pattern | Clean shutdown, error handling, production-ready | Slightly more complex |
Real-World Scenarios
Scenario 1: Server Crash
1. Server goroutine encounters error (port already in use)
2. Error sent to serverErrors channel
3. Main thread receives error via select
4. Log error and exit gracefully
Scenario 2: Graceful Deployment (Kubernetes)
1. Kubernetes sends SIGTERM (wants to update pod)
2. Signal handler receives SIGTERM
3. Server stops accepting new requests
4. Waits up to 30s for active requests to finish
5. Closes cleanly
6. Kubernetes starts new pod
→ Zero downtime deployment! ✨
Scenario 3: Developer Stops Server (Ctrl+C)
1. Developer presses Ctrl+C (SIGINT)
2. Signal handler receives SIGINT
3. Ongoing PDF generation continues for up to 30s
4. Server shuts down cleanly
5. No corrupted files or broken requests
💼 Production Best Practices
1. Always Use Timeouts
server := &http.Server{
Addr: ":1999",
Handler: handler,
ReadTimeout: 15 * time.Second, // Max time to read request
WriteTimeout: 15 * time.Second, // Max time to write response
IdleTimeout: 120 * time.Second, // Max time for keep-alive connections
}
Why?
- Prevents slow clients from tying up server resources
- Protects against slowloris attacks
- Ensures predictable performance
2. Use Buffered Channels for Errors
serverErrors := make(chan error, 1) // Buffer size 1
Why?
- Prevents goroutine deadlock if error occurs before select
- Allows error to be queued even if no receiver yet
3. Always defer cancel() with Contexts
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() // ← CRITICAL: Release resources
Why?
- Prevents context leaks
- Frees timers and goroutines associated with context
- Go vet will warn if you forget
4. Handle Both SIGINT and SIGTERM
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
Why?
SIGINT(Ctrl+C): Developer stopping server locallySIGTERM: System/orchestrator (Docker, K8s) stopping server- Ensures shutdown works in all environments
5. Have a Force-Close Fallback
if err := server.Shutdown(ctx); err != nil {
log.Printf("⚠️ Graceful shutdown failed, forcing: %v", err)
server.Close() // ← Force close if graceful fails
}
Why?
- If requests don't finish in timeout, force close
- Prevents server hanging indefinitely
- Ensures server always stops eventually
🧪 Testing Shutdown Logic
Manual Test
# Terminal 1: Start server
go run main.go
# Terminal 2: Send request that takes 5 seconds
curl "http://localhost:1999/slow-endpoint"
# Terminal 1: Press Ctrl+C while request is active
# → Server waits for request to finish (up to 30s)
# → Then shuts down cleanly
Simulating SIGTERM (Production)
# Get process ID
ps aux | grep "go run main.go"
# Send SIGTERM (like Kubernetes would)
kill -TERM <PID>
# Server should shut down gracefully
📚 Key Concepts Summary
Goroutines
- Lightweight threads managed by Go runtime
- Use
go func()to run functions concurrently - Cheap to create (2KB vs. 2MB for OS threads)
Channels
- Communication pipes between goroutines
- Use
<-to send/receive values - Buffered channels can store values when no receiver ready
Select
- Multiplex on multiple channel operations
- Blocks until one case can proceed
- Used to wait for first of multiple events
Context
- Carries deadlines and cancellation signals
- Use
WithTimeoutto set operation deadlines - Always
defer cancel()to prevent leaks
Graceful Shutdown
- Stop accepting new requests
- Wait for active requests to finish (with timeout)
- Force close if timeout expires
🎓 Interview Talking Points
"Why do you use goroutines for the HTTP server?"
"I use a goroutine to run
ListenAndServe()because it's a blocking call—it runs forever handling requests. By launching it in a goroutine, the main thread remains free to set up graceful shutdown logic. This pattern allows me to handle OS signals like SIGTERM (from Kubernetes) and SIGINT (Ctrl+C) to shut down cleanly, ensuring ongoing requests finish before the server stops."
"How do you handle server errors in a goroutine?"
"I use a buffered channel (
make(chan error, 1)) to communicate errors from the server goroutine back to the main thread. The goroutine sends any error fromListenAndServe()to this channel, and the main thread uses aselectstatement to wait for either an error or a shutdown signal, whichever comes first."
"What is graceful shutdown and why is it important?"
"Graceful shutdown means stopping the server without killing active requests. When I receive a shutdown signal, I call
server.Shutdown()with a context that has a 30-second timeout. This stops accepting new connections but waits for ongoing requests to complete naturally. If the timeout expires, I force-close as a fallback. This prevents data loss and gives clients a clean response instead of a broken connection."
"Why use context with timeout?"
"Context with timeout ensures graceful shutdown doesn't wait forever. If some requests are hanging, the 30-second timeout ensures the server shuts down eventually. The context also propagates cancellation to all handlers, signaling them to wrap up. Without timeout, a misbehaving request could prevent the server from ever shutting down."
🔗 Further Reading
Official Go Documentation
Server Patterns
Books
- "Concurrency in Go" - Katherine Cox-Buday
- "Go in Practice" - Matt Butcher & Matt Farina
Last Updated: 2025-11-20 Practice Project: CV Server (github.com/juanatsap/cv-site)