Day 22 — Caching, Real-time, TLS & Stage VI Gate
Day 22 — Caching, Real-time, TLS & Stage VI Gate
Day 58 — caching
Stage VI · ~3h
Goal: Implement a cache-aside layer behind a small interface—in-memory with TTL, optional Redis—plus invalidation rules that won’t lie to users.
Why this day exists
Databases and upstream HTTP are expensive. Caching helps when:
- Reads dominate
- Slightly stale data is acceptable or invalidation is correct
- You measure hit rate later (Stage VII metrics)
Caching also hurts when:
- You cache without TTL or invalidation
- You cache user-specific data under global keys
- Stampede thunders on expiry
- You treat cache as source of truth
Today you build a correct-by-default cache-aside path you can disable with a Noop implementation.
Theory 1 — Cache-aside (lazy loading)
read(key):
if val, ok := cache.Get(key); ok { return val }
val = db.Load(key)
cache.Set(key, val, ttl)
return val
write(key, val):
db.Save(key, val)
cache.Delete(key) // or Set updated
Why delete on write? Avoid dual-write inconsistency when DB succeeds and cache set fails (or vice versa). Re-populate on next read. Write-through (update cache in the write path) is valid but harder to keep consistent under partial failures.
Theory 2 — Interface first
type Cache interface {
Get(ctx context.Context, key string) (string, bool, error)
Set(ctx context.Context, key, val string, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}Implementations:
MemoryCache—map+sync.RWMutex+ expiry
RedisCache— optional; skip if no Redis
NoopCache— always miss (tests/feature flag)
type NoopCache struct{}
func (NoopCache) Get(context.Context, string) (string, bool, error) {
return "", false, nil
}
func (NoopCache) Set(context.Context, string, string, time.Duration) error { return nil }
func (NoopCache) Delete(context.Context, string) error { return nil }Theory 3 — Memory implementation sketch
type entry struct {
val string
exp time.Time
}
type MemoryCache struct {
mu sync.RWMutex
data map[string]entry
}
func NewMemoryCache() *MemoryCache {
return &MemoryCache{data: make(map[string]entry)}
}
func (c *MemoryCache) Get(_ context.Context, key string) (string, bool, error) {
c.mu.RLock()
e, ok := c.data[key]
c.mu.RUnlock()
if !ok || time.Now().After(e.exp) {
if ok {
_ = c.Delete(context.Background(), key)
}
return "", false, nil
}
return e.val, true, nil
}
func (c *MemoryCache) Set(_ context.Context, key, val string, ttl time.Duration) error {
c.mu.Lock()
c.data[key] = entry{val: val, exp: time.Now().Add(ttl)}
c.mu.Unlock()
return nil
}
func (c *MemoryCache) Delete(_ context.Context, key string) error {
c.mu.Lock()
delete(c.data, key)
c.mu.Unlock()
return nil
}Janitor
Optional goroutine ticking every minute to drop expired keys (or lazy delete only). Bound map size (max entries + eviction) for production; lab can document unbounded risk.
func (c *MemoryCache) Janitor(stop <-chan struct{}, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
now := time.Now()
c.mu.Lock()
for k, e := range c.data {
if now.After(e.exp) {
delete(c.data, k)
}
}
c.mu.Unlock()
}
}
}Theory 4 — Key design
item:v1:{id}
user:v1:{id}:items
Include version prefix so you can bust all keys by deploying a new version. Include tenant/user when data is private:
item:v1:{userID}:{id}
Never use raw user input without sanitizing separators. Prefer fixed prefixes + opaque ids.
Theory 5 — Stampede control (awareness)
When popular key expires, N requests hit DB. Mitigations:
- Singleflight (
golang.org/x/sync/singleflight)
- Probabilistic early expiration
- Lock per key
var g singleflight.Group
func (r *CachedItemRepo) Get(ctx context.Context, id string) (Item, error) {
key := "item:v1:" + id
if raw, ok, err := r.cache.Get(ctx, key); err != nil {
return Item{}, err
} else if ok {
var it Item
if err := json.Unmarshal([]byte(raw), &it); err == nil {
return it, nil
}
}
v, err, _ := g.Do(key, func() (any, error) {
it, err := r.inner.Get(ctx, id)
if err != nil {
return Item{}, err
}
b, _ := json.Marshal(it)
_ = r.cache.Set(ctx, key, string(b), r.ttl)
return it, nil
})
if err != nil {
return Item{}, err
}
return v.(Item), nil
}Lab stretch: prove concurrent Gets coalesce to one load.
Theory 6 — HTTP caching vs app cache
| Layer | Mechanism |
|---|---|
| Client/CDN | Cache-Control, ETag (Day 56 R) |
| App memory/Redis | Cache interface today |
| DB | Materialized views / query cache (DBA) |
Do not confuse Cache-Control: private personal data with public CDN caching. App caches are not a substitute for HTTP cache headers when browsers/CDNs are the consumers.
Negative caching
Caching “not found” can protect the DB from hammering missing keys—use a short TTL and never confuse it with a real item payload (store a sentinel or separate negative map).
Worked example — repo with cache-aside
type CachedItemRepo struct {
inner ItemReaderWriter // interface to real store
cache Cache
ttl time.Duration
log *slog.Logger
}
func (r *CachedItemRepo) Get(ctx context.Context, id string) (Item, error) {
key := "item:v1:" + id
if raw, ok, err := r.cache.Get(ctx, key); err != nil {
return Item{}, err
} else if ok {
var it Item
if err := json.Unmarshal([]byte(raw), &it); err == nil {
r.log.Debug("cache hit", "key", key)
return it, nil
}
}
it, err := r.inner.Get(ctx, id)
if err != nil {
return it, err
}
b, err := json.Marshal(it)
if err == nil {
_ = r.cache.Set(ctx, key, string(b), r.ttl)
}
return it, nil
}
func (r *CachedItemRepo) Update(ctx context.Context, it Item) error {
if err := r.inner.Update(ctx, it); err != nil {
return err
}
return r.cache.Delete(ctx, "item:v1:"+it.ID)
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day58
cd ~/lab/90daysofx/01-go/day58
go mod init example.com/day58Package layout: cache/, store/, optional api/ wiring.
Lab 2 — MemoryCache tests
- Set/Get hit
- Expiry miss after TTL
- Delete removes key
- Concurrent Set/Get with
-race
Use short TTLs (20 * time.Millisecond) + time.Sleep carefully or inject a clock interface for purity.
go test ./cache/ -race -count=1Lab 3 — Wire into API
GET /v1/items/{id} goes through cached repo. Manually:
- Get (miss → DB)
- Get again (hit—log optional
cache_hit=true)
- Update/Delete invalidates
- Get loads fresh
Optional counter metrics later (Day 69); for now slog Debug is enough.
Lab 4 — Optional Redis
If Redis available:
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
// GET / SET with expiration / DEL — implement CacheSkip with build tag or runtime detect—document “Redis not used” if skipped. Never fail unit tests because Redis is down.
Lab 5 — singleflight stretch
Prove with a slow DB fake that 20 concurrent Gets cause ~1 load:
var loads atomic.Int64
// inner.Get sleeps 50ms and increments loads
// 20 goroutines call Cached Get same id
// assert loads == 1 (or very close) with singleflightCommon gotchas
| Gotcha | Fix |
|---|---|
| Cache forever | TTL always |
| No invalidation on write | Delete/Set |
| Caching errors/404 forever | Don’t cache misses or short negative TTL deliberately |
| Global keys for private data | Namespace by user |
| Huge values | Bound size |
| Ignoring ctx in Redis | Use context variants |
| Race on map | Mutex |
| Caching mutable pointers shared with callers | Marshal copies / return new structs |
Checkpoint
Cacheinterface + Memory impl
- Cache-aside Get + invalidate on write
- Tests for TTL and race
- Key naming scheme documented
- Optional Redis or explicit skip note
- Know when not to cache
Commit
git add .
git commit -m "day58: cache-aside interface and memory TTL cache"Write three personal gotchas before continuing.
Tomorrow
Day 59 — WebSockets or SSE: pick one realtime pattern; push live updates from your service.
Day 59 — WebSockets or SSE
Stage VI · ~3h
Goal: Add one realtime channel—Server-Sent Events (SSE) or WebSockets—with clear lifecycle, client test/script, and documentation of the trade-off.
Pick one. Implementing both shallowly is worse than one solid path.
Why this day exists
Request/response REST is not enough for:
- Live dashboards
- Notifications
- Collaborative presence
Two stdlib-friendly options dominate simple Go services:
| SSE | WebSocket | |
|---|---|---|
| Direction | Server → client (HTTP) | Full duplex |
| Protocol | HTTP response stream | HTTP Upgrade → WS |
| Reconnect | Easy (EventSource) | Manual or lib |
| Proxies | Usually friendly | Need Upgrade support |
| Binary | Awkward (text events) | Natural |
| Stdlib | net/http only |
Library preferred |
Theory 1 — SSE fundamentals
Response headers:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Body frames:
event: item_created
data: {"id":"1","name":"x"}
: keep-alive comment
data: plain line
Rules:
- Blank line ends an event
data:may repeat (joined with\n)
- Comments start with
:
- Client
EventSourceauto-reconnects
Go handler sketch
func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := s.hub.Subscribe()
defer s.hub.Unsubscribe(ch)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
_, _ = io.WriteString(w, ": ping\n\n")
flusher.Flush()
case msg, ok := <-ch:
if !ok {
return
}
_, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", msg.Event, msg.Data)
flusher.Flush()
}
}
}Fan-out hub
type Message struct {
Event string
Data string
}
type Hub struct {
mu sync.Mutex
subs map[chan Message]struct{}
}
func NewHub() *Hub {
return &Hub{subs: make(map[chan Message]struct{})}
}
func (h *Hub) Subscribe() chan Message {
ch := make(chan Message, 8)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *Hub) Unsubscribe(ch chan Message) {
h.mu.Lock()
delete(h.subs, ch)
h.mu.Unlock()
close(ch)
}
func (h *Hub) Publish(msg Message) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs {
select {
case ch <- msg:
default:
// slow consumer: drop (or disconnect policy)
}
}
}Publish from createItem after successful DB write.
Theory 2 — WebSocket fundamentals
Handshake: Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Key, …
Library choice (pragmatic)
| Library | Notes |
|---|---|
github.com/coder/websocket |
Modern, good defaults |
github.com/gorilla/websocket |
Widely known |
golang.org/x/net/websocket |
Older; less preferred |
Stdlib does not yet provide a full WS server as ergonomic as these. Using one well-known package is acceptable for this day—document it.
Patterns
- Read loop + write loop (often separate goroutines)
- Ping/pong for keepalive
- Auth on connect (query token or first message)—prefer before upgrade when possible
// conceptual — coder/websocket
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"localhost:*"},
})
if err != nil {
return
}
defer c.Close(websocket.StatusNormalClosure, "")
ctx := r.Context()
for {
_, data, err := c.Read(ctx)
if err != nil {
return
}
_ = c.Write(ctx, websocket.MessageText, data) // echo lab
}Theory 3 — Auth and multi-tenant streams
- SSE:
Authorizationheader works with some clients; browsers’EventSourcecannot set custom headers—use httpOnly cookie session or short-lived query token (careful with logs and referrer leakage).
- WS: same issues; cookies sent on same-site connects.
Filter events by userID from auth context so users don’t see others’ data.
// publish user-scoped
s.hub.Publish(Message{
Event: "item_created",
Data: string(payload),
})
// subscriber filters or hub stores per-user channelsTheory 4 — Backpressure & disconnects
- Detect
r.Context().Done()
- Bound subscription channel buffers
- Drop or disconnect slow clients
- Do not let Publish block on one bad consumer
- Cap max subscribers per process for lab safety
http.Server write deadlines can interfere with long-lived streams—tune WriteTimeout to 0 for the stream server or use a separate server/listener pattern in real systems. Document what you did.
Theory 5 — Testing strategy
| Layer | Approach |
|---|---|
| Hub | Unit tests: subscribe, publish, unsubscribe, drop policy |
| SSE handler | httptest.NewServer + short context; read until first event |
| WS | Small Go client against NewServer |
func TestHubFanout(t *testing.T) {
h := NewHub()
a, b := h.Subscribe(), h.Subscribe()
h.Publish(Message{Event: "x", Data: "1"})
select {
case m := <-a:
if m.Data != "1" {
t.Fatal(m)
}
case <-time.After(time.Second):
t.Fatal("timeout a")
}
<-b
h.Unsubscribe(a)
h.Unsubscribe(b)
}Worked example — choose SSE for lab default
SSE is pure stdlib and enough for “item created” feeds. Prefer SSE unless you need client→server messages over the same socket (chat, collaborative cursors).
README declaration:
realtime: sse
Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day59
cd ~/lab/90daysofx/01-go/day59
go mod init example.com/day59Declare track in README: realtime: sse or realtime: websocket.
Lab 2A — SSE path
- Hub with Subscribe/Unsubscribe/Publish
GET /v1/eventsSSE handler
- Publish on item create
- Manual test:
curl -N -H 'Accept: text/event-stream' http://127.0.0.1:8080/v1/events
# other terminal: create item- Auto test: unit-test Hub thoroughly + thin handler test with cancelled context after one event.
Lab 2B — WebSocket path
- Accept connection
- Broadcast item JSON messages
- Script or small Go client reading one message
- Origin check configured for lab
Lab 3 — Auth gate
Only authenticated users subscribe. Unauthorized → 401 before stream starts (do not flush event-stream headers first).
Lab 4 — Load lite
100 subscribers + 10 publishes; ensure no deadlock (-race test with fake hub consumers). Assert Publish returns quickly even with full buffers (drop policy).
Common gotchas
| Gotcha | Fix |
|---|---|
| Forgetting Flush | Call after each event |
| Buffering proxy | X-Accel-Buffering: no (nginx) awareness |
| Blocking Publish | default drop policy |
| Browser EventSource + bearer header | Cookies or ticket |
| WS without ping | Idle timeouts kill conns |
| Both protocols half-done | Pick one |
| WriteTimeout killing streams | Configure for long-lived connections |
| Leaking subscriptions | defer Unsubscribe |
Checkpoint
- One realtime protocol working end-to-end
- Hub or equivalent fan-out
- Disconnect cleans subscription
- Auth considered
- Trade-off documented
- Demo script in README
Commit
git add .
git commit -m "day59: realtime SSE or WebSocket live updates"Write three personal gotchas before continuing.
Tomorrow
Day 60 — TLS awareness: local HTTPS, tls.Config minimum versions, and terminate-at-proxy architecture notes.
Day 60 — TLS awareness
Stage VI · ~3h
Goal: Gain operational literacy for TLS in Go services—serve HTTPS locally with sane tls.Config, or document terminate-at-proxy with correct forwarded headers—and never casually disable verification.
You are not becoming a PKI engineer today. You are becoming hard to fool: where TLS ends, what clients verify, and why InsecureSkipVerify is almost always wrong.
Why this day exists
Encryption in transit is table stakes. Go developers must know:
- Where TLS terminates (app vs load balancer)
- How to set minimum TLS version and cipher policy
- How HTTP clients verify certificates
- What
InsecureSkipVerifyactually does
Stage VI APIs that only speak cleartext on a laptop are incomplete for production mental models—even when the final deploy terminates TLS at a proxy.
Theory 1 — What TLS gives you
- Confidentiality — eavesdroppers don’t read payloads
- Integrity — tampering detected
- Authentication — server identity (and optionally client certs)
Certificates bind a public key to a name (SAN DNS/IP) via a chain of trust to a root CA. Modern clients match against Subject Alternative Name (SAN), not only the legacy Common Name (CN).
Client hello → Server cert chain → Verify name + chain → Keys → Encrypted HTTP
Theory 2 — Terminate at app vs proxy
[Client] --TLS--> [App :443] // app terminates
[Client] --TLS--> [LB/Proxy] --HTTP--> [App :8080] // proxy terminates
| Mode | Pros | Cons |
|---|---|---|
| App TLS | End-to-end to process; simpler network trust | Cert reload, CPU on app |
| Proxy TLS | Central certs (ACME), WAF | App must trust private network; need real IP headers |
Many K8s setups terminate at ingress; apps speak cleartext inside the mesh or network policy. Document your trust boundary.
Choosing for the lab
| Situation | Pick |
|---|---|
Learning tls.Config API |
Lab A (app TLS) |
| Already designing ingress | Lab B (proxy doc) |
| Unsure | Lab A first; add Lab B notes if time |
Theory 3 — http.Server HTTPS
srv := &http.Server{
Addr: ":8443",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12, // prefer TLS 1.3 when clients allow
},
}
err := srv.ListenAndServeTLS("cert.pem", "key.pem")Or build the listener yourself:
ln, err := tls.Listen("tcp", ":8443", srv.TLSConfig)
if err != nil {
return err
}
err = srv.Serve(ln)Always keep ReadHeaderTimeout—TLS does not remove slowloris-style header tricks.
Generate local self-signed (lab)
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"Browsers will warn; curl needs -k only for this self-signed lab when you have not installed the cert as a trusted CA.
# preferred lab proof (no skip-verify):
curl --cacert cert.pem https://127.0.0.1:8443/healthzTheory 4 — Client verification
tr := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
// RootCAs: custom pool for private CA
},
// ForceHTTP2 / idle conns — tune later; defaults OK for lab
}
client := &http.Client{Transport: tr, Timeout: 10 * time.Second}Private CA
pemCerts, err := os.ReadFile("rootCA.pem")
if err != nil {
return err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemCerts) {
return errors.New("no certs appended")
}
cfg := &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
ServerName: "api.internal.example", // if dialing by IP
}The footgun
TLSClientConfig: &tls.Config{InsecureSkipVerify: true} // MITM-ableAllowed only for ephemeral debug with eyes open—never default, never copy into prod configs. If tests need it, isolate:
// test helper only — file name tls_lab_test.go
func labInsecureClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lab only
},
Timeout: 5 * time.Second,
}
}Theory 5 — Forwarded headers (proxy mode)
When proxy terminates TLS:
X-Forwarded-Proto: https
X-Forwarded-For: 203.0.113.10, 10.0.0.5
X-Forwarded-Host: api.example.com
App must only trust these from the proxy IP. Spoofed X-Forwarded-For from the public internet is a classic auth/audit bug.
func realIP(r *http.Request, trustProxy bool) string {
if trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func isHTTPS(r *http.Request, trustProxy bool) bool {
if r.TLS != nil {
return true
}
if trustProxy && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
return false
}Secure cookies: Secure: true when served over HTTPS (or when X-Forwarded-Proto is https and proxy is trusted).
Theory 6 — Redirect HTTP→HTTPS and HSTS
go func() {
_ = http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
target := "https://" + host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
}))
}()HSTS header (careful on localhost—browsers remember):
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")Do not enable HSTS on throwaway lab hostnames you share with other apps.
Theory 7 — mTLS awareness (read-only depth)
Mutual TLS authenticates clients with certificates. Go sketch:
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientCAPool,
}You will not implement a full PKI today. Know that service mesh (Istio, Linkerd) often does mTLS between proxies, not necessarily in your Go process.
Worked example — TLS server config baseline
func baselineTLS() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
},
// Prefer server cipher suites on TLS 1.2; TLS 1.3 has its own suite set
// PreferServerCipherSuites is ignored for TLS 1.3
}
}Prefer TLS 1.3 when clients allow (MinVersion: tls.VersionTLS13 for hardened internal services).
Worked example — client with custom CA
func clientWithRoot(caPath string) (*http.Client, error) {
pem, err := os.ReadFile(caPath)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("append CA")
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
},
},
}, nil
}Lab options (pick A or B)
mkdir -p ~/lab/90daysofx/01-go/day60
cd ~/lab/90daysofx/01-go/day60
go mod init example.com/day60Lab A — App-terminated HTTPS
- Generate self-signed certs with SAN for localhost + 127.0.0.1.
- Serve Stage VI API (or a minimal mux) on
:8443withMinVersion: TLS1_2.
- Client test using custom
RootCAsor documentedInsecureSkipVerifyonly in a test helper named// lab only.
- Prove
curl --cacert cert.pem https://127.0.0.1:8443/healthzworks.
gitignoreprivate keys (*.pemkeys; keep sample cert generation script).
Lab B — Terminate-at-proxy design
- Write architecture doc: Caddy/nginx/envoy terminates TLS, app on
127.0.0.1:8080.
- Sample Caddyfile or nginx site snippet.
- App notes: trust hop,
X-Forwarded-Proto, cookie Secure.
- Checklist for production cert automation (ACME).
- Explicit “app does not call
ListenAndServeTLS” decision recorded.
Either lab fully done beats both half-done.
Sample Caddyfile (Lab B)
api.localhost {
reverse_proxy 127.0.0.1:8080
}
Sample nginx sketch (Lab B)
server {
listen 443 ssl;
ssl_certificate /etc/ssl/certs/fullchain.pem;
ssl_certificate_key /etc/ssl/private/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Common gotchas
| Gotcha | Fix |
|---|---|
| CN only without SAN | Modern clients want SAN |
InsecureSkipVerify in prod |
Custom CA pool |
| Trusting X-Forwarded-* from world | Proxy-only / network policy |
| Missing ReadHeaderTimeout on TLS server | Still set it |
| Committing private keys | gitignore *key*.pem |
| Mixed content / Secure cookie wrong | Align with termination mode |
| Dial by IP with cert for DNS name | Set ServerName or use the DNS name |
| TLS 1.0 still allowed by empty config | Set MinVersion explicitly |
| “HTTPS so we skip auth” | Orthogonal; auth is Day 54 |
Checkpoint
- Can draw app vs proxy termination
- Lab A or B complete
tls.ConfigMinVersion set
- Know why skip-verify is dangerous
- Keys not committed
- Forwarded header trust story written
- ReadHeaderTimeout present on HTTPS server
Commit
git add .
git commit -m "day60: TLS awareness HTTPS or proxy termination"Write three personal gotchas before continuing.
Tomorrow
Day 61 — integration tests: real dependencies (SQLite/Postgres/docker/testcontainers) with cleanup discipline.
Day 61 — integration tests
Stage VI · ~3h
Goal: Write integration tests that exercise real storage (and optionally HTTP+DB together) with reliable setup/teardown—using SQLite, Docker, or testcontainers when available.
Why this day exists
Unit tests with fakes are fast but lie about SQL dialects, constraints, and transactions. Integration tests catch:
- Wrong placeholders
- Migration mistakes
- Unique index conflicts
- Scan/NULL issues
- Timezone / TEXT affinity surprises on SQLite
Balance: keep unit tests for pure logic; integration for the repository and optionally the full handler stack. Stage VI gate expects evidence that storage is real.
Theory 1 — Test pyramid for services
/ e2e few \ (browser/k8s — later)
/ integration \ (DB, cache, optional network)
/ unit many \ (validation, mappers, pure)
Flags/tags to separate:
//go:build integration
package store_testgo test ./... # unit default
go test -tags=integration ./... # integrationOr environment gate:
if os.Getenv("INTEGRATION") == "" {
t.Skip("set INTEGRATION=1")
}Prefer tags for files that import heavy deps; prefer env for optional slow suites in already-built packages.
Theory 2 — Isolation strategies
| Strategy | How | Notes |
|---|---|---|
| In-memory SQLite | New DB per test | Fast; dialect ≠ Postgres |
| Temp file SQLite | t.TempDir() |
Easy cleanup |
| Transaction rollback | Begin tx; inject tx; rollback | Fast; limited if code uses *sql.DB only |
| Docker Postgres | testcontainers / compose | Closest to prod |
| Shared DB + truncate | Truncate tables | Parallelism hard |
Prefer fresh schema per test via migrations.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := "file:" + filepath.Join(t.TempDir(), "test.db") + "?_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
ctx := context.Background()
if err := migrate.Apply(ctx, db, migrations); err != nil {
t.Fatal(err)
}
return db
}Theory 3 — testcontainers awareness
// conceptual
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "pass",
"POSTGRES_DB": "app",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
}Use when dialect fidelity matters. Skip if Docker unavailable:
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available")
}Do not make default CI red when Docker is missing—gate with tags (integration,postgres).
Theory 4 — HTTP+DB integration
func TestCreateAndGet(t *testing.T) {
db := openTestDB(t)
repo := store.NewItemRepo(db)
h := api.New(repo, log).Handler()
ts := httptest.NewServer(h)
t.Cleanup(ts.Close)
resp, err := ts.Client().Post(ts.URL+"/v1/items",
"application/json", strings.NewReader(`{"name":"alpha"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d", resp.StatusCode)
}
// decode id; GET; assert name; also SELECT from db for belt-and-suspenders
}This catches routing + serialization + SQL together.
Theory 5 — Cleanup and parallelism
t.Cleanupover manual defer for readability
t.Parallel()only with isolated DBs
- Do not share
*sql.DBwith mutable global schema across parallel tests without locking
Golden data builders
func seedUser(t *testing.T, repo *UserRepo, email, password string) string {
t.Helper()
id := newID()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if err := repo.Create(context.Background(), id, email, hash); err != nil {
t.Fatal(err)
}
return id
}Use MinCost in tests for speed; production uses DefaultCost.
Theory 6 — What belongs where
| Kind | Examples | Location |
|---|---|---|
| Unit | validateCreateItem, DTO mapping |
_test.go no tag |
| Integration | Repo CRUD, migrations | //go:build integration |
| Contract HTTP | httptest + fake repo | unit-ish, fast |
| HTTP+DB | NewServer + real repo | integration tag |
Do not delete unit tests when you add integration—keep both.
Worked example — build-tagged integration test
// file: store/item_integration_test.go
//go:build integration
package store_test
import (
"context"
"testing"
"example.com/day61/store"
)
func TestItemRepoIntegration(t *testing.T) {
db := openTestDB(t)
repo := store.NewItemRepo(db)
ctx := context.Background()
if err := repo.Create(ctx, "id1", "alpha"); err != nil {
t.Fatal(err)
}
got, err := repo.Get(ctx, "id1")
if err != nil || got.Name != "alpha" {
t.Fatalf("%+v %v", got, err)
}
// unique constraint / PK conflict
if err := repo.Create(ctx, "id1", "dup"); err == nil {
t.Fatal("expected conflict")
}
if _, err := repo.Get(ctx, "missing"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("want not found, got %v", err)
}
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day61
cd ~/lab/90daysofx/01-go/day61
go mod init example.com/day61Bring store + migrations + API from prior days (or minimal subset with create/get).
Lab 2 — Repository integration suite
Tests:
- Migrate fresh DB
- CRUD lifecycle
- Not found
- Conflict on PK/unique
- List limit
go test -tags=integration ./store/ -count=1 -raceLab 3 — API integration
httptest server + real repo + create/login/get protected route if auth present. Assert both HTTP status and a DB row when relevant.
func TestAPICreatePersists(t *testing.T) {
if os.Getenv("INTEGRATION") == "" {
t.Skip("INTEGRATION=1")
}
db := openTestDB(t)
repo := store.NewItemRepo(db)
ts := httptest.NewServer(api.New(repo, slog.Default()).Handler())
t.Cleanup(ts.Close)
resp, err := ts.Client().Post(ts.URL+"/v1/items",
"application/json", strings.NewReader(`{"name":"persist-me"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d", resp.StatusCode)
}
// decode id from JSON, then:
// got, err := repo.Get(context.Background(), id)
// assert name
}Lab 4 — CI-friendly skip
Document in README:
go test ./... # units
INTEGRATION=1 go test ./... -tags=integrationEnsure default go test ./... stays green without Docker and without -tags=integration.
Lab 5 — Optional Postgres container
If Docker works, one test file //go:build integration,postgres proving the same repo against Postgres DSN. Note placeholder differences ($1 vs ?)—use driver-specific SQL files or a small query abstraction.
If you skip: write two sentences on dialect risk and when you would invest in Postgres CI.
Common gotchas
| Gotcha | Fix |
|---|---|
| Tests depend on developer’s laptop DB state | Fresh migrate each test |
| Parallel + shared DB | Isolate or serial |
| Forgetting Cleanup close | t.Cleanup |
| Integration always on by default | Tags/env skip |
| Flaky sleeps | Wait on readiness, not blind sleep (containers) |
| Asserting only status, not DB rows | Read back from DB |
| Using production bcrypt cost in huge suites | MinCost in tests |
| SQLite-only CI, Postgres in prod | At least one dialect-fidelity path |
Checkpoint
- Integration tests for repo with real SQL engine
- Migrations applied in test setup
- Cleanup reliable
- Default unit tests don’t require Docker
- Optional HTTP+DB path
- README explains how to run
Commit
git add .
git commit -m "day61: integration tests for store and API"Write three personal gotchas before continuing.
Tomorrow
Day 62 — Stage VI gate: ship a service with data store + auth + tests—architecture paragraph included.
Day 62 — Stage VI gate
Stage VI · Gate day · ~3h (integration / ship)
Goal: Close the data & APIs stage by shipping a service + persistent store + auth + tests, with a clear architecture README and operational basics from Days 51–61.
Gates are pass/fail against a rubric. A pretty half-wired API fails. A small vertical slice that persists, authenticates, and tests cleanly passes.
Why this day exists
Stage VI introduced persistence, REST (or gRPC branch), auth, validation, caching, realtime, TLS literacy, and integration tests. The gate asks: can you assemble a credible vertical slice?
Not every elective must be in the gate binary—but core bar items must. Stage VII (build tags, pprof, containers, metrics, govulncheck) will profile and package this service. Spaghetti now becomes expensive later.
Theory 1 — Gate bar (definition of done)
| Requirement | Evidence |
|---|---|
Persistent store via database/sql |
Migrations + repo; data survives restart |
| Versioned schema migrations | Idempotent apply (up files or embed) |
HTTP JSON API under /v1 (or gRPC + documented HTTP health) |
Routes work end-to-end |
| Auth protecting at least one mutating route | 401 without creds |
| Consistent error envelope | Day 55 shape (code/message/details) |
| Integration or solid repo tests + handler tests | go test green on Go 1.26 |
| Graceful shutdown | SIGINT/SIGTERM → clean exit |
| slog logging | Structured request or lifecycle logs |
| README architecture paragraph | Trust boundaries, boxes |
| Race clean on unit/integration you claim | -race where applicable |
Strongly recommended (bonus)
- Cache-aside on hot GET
- One realtime endpoint or documented skip with reason
- TLS lab notes linked (Day 60)
- Elective track (Days 56–57) merged or archived with reason
- Request body limits + server timeouts
Explicit non-goals
- Perfect OpenAPI client SDK
- Multi-region HA
- Full OAuth provider
- Every Stage VI elective feature in one binary
Theory 2 — Vertical slice over horizontal sprawl
Prefer:
- One resource fully done (users + items with ownership)
Over:
- Five half-wired resources
Gate reviewers (including future you) should:
- Register / login
- Create an item (authenticated)
- Fetch it after process restart (persistence!)
- See tests pass
Trust boundary diagram (required)
[Client]
|
v
[http.Server + timeouts]
|
+--> [auth middleware] -- 401 if missing/invalid
|
v
[handlers] -- validate -- map domain errors --> error envelope
|
v
[app/service] -- business rules
|
+--> [repo] --> [sql.DB] --> [SQLite|Postgres]
|
+--> [cache?] (optional, in-process)
Theory 3 — Architecture paragraph (README)
Write in README (replace placeholders with yours):
## Architecture
Clients call the HTTP API (`/v1/*`). Authentication uses <JWT|sessions>.
Handlers validate input, call repository interfaces (or app services), and map
domain errors to HTTP status + a consistent error envelope. `database/sql`
talks to <SQLite|Postgres> with versioned SQL migrations applied at startup
(or via a migrate command). Optional in-process cache sits in front of hot
item reads. TLS is <terminated at the app|terminated at the proxy>.
Realtime updates use <SSE|WebSocket|none — skipped because ...>.Include the text diagram above (adapted). Stage VII reviewers will re-read this when wiring metrics and containers.
Theory 4 — Suggested package layout
cmd/service/main.go # composition root, signals, listen
internal/httpapi/ # handlers, middleware, routes
internal/auth/ # password hash, token/session
internal/store/ # sql repo
internal/migrate/ # apply versioned SQL
internal/cache/ # optional
internal/domain/ # errors, entities (if extracted)
migrations/*.sql
scripts/demo.sh
README.md
internal/ prevents external import of private code—good monorepo hygiene.
Composition root sketch
func main() {
cfg := config.Load()
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
db, err := sql.Open(cfg.Driver, cfg.DSN)
must(err)
defer db.Close()
must(migrate.Up(context.Background(), db))
repo := store.NewItemRepo(db)
auth := auth.NewService(cfg.JWTSecret, repo /* users */)
api := httpapi.New(log, auth, repo)
srv := &http.Server{
Addr: cfg.Addr,
Handler: api.Routes(),
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Info("listen", "addr", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("serve", "err", err)
stop()
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}Theory 5 — Auth and error envelope bars
Auth minimum
| Check | Pass looks like |
|---|---|
| Register | Hashed password stored (bcrypt/argon2/scrypt) |
| Login | Returns token or session cookie |
| Mutating route | Create item without creds → 401 |
| Ownership (bonus) | User A cannot mutate User B’s item → 403/404 |
Error envelope minimum (Day 55 shape)
{
"error": {
"code": "not_found",
"message": "item not found"
}
}Map domain sentinels consistently:
| Domain | HTTP |
|---|---|
| validation | 400 |
| unauthenticated | 401 |
| forbidden | 403 |
| not found | 404 |
| conflict | 409 |
| internal | 500 (no stack traces to client) |
Theory 6 — Rubric (score yourself)
| Criterion | Weight | 0 | 1 | 2 |
|---|---|---|---|---|
| Persistence + migrations | 20% | memory only | SQL flaky | restart-safe |
| Auth on mutating route | 15% | none | partial | solid 401 |
| API + error envelope | 15% | ad-hoc | inconsistent | Day 55 shape |
| Tests (unit/handler/integ) | 15% | none | thin | green suite |
| Shutdown + slog | 10% | hang/noisy | partial | clean |
| README architecture | 10% | missing | thin | diagram+paragraph |
| Race / quality | 10% | fails race | untested | -race green |
| Demo script | 5% | none | manual only | demo.sh works |
Pass: ≥ 80% with no zero on persistence or auth.
Lab 1 — Promote or assemble the gate module
mkdir -p ~/lab/90daysofx/01-go/day62
cd ~/lab/90daysofx/01-go/day62
# or: copy/promote day61 service and tag as day62
go mod init example.com/day62
# ensure: go 1.26Checklist before demos:
- Migrations apply on empty DB.
- Register + login work.
- Create + get item works.
- Kill process; restart; get still works.
go test ./...andgo test -race ./...as applicable.
Lab 2 — Demo script scripts/demo.sh
#!/usr/bin/env bash
set -euo pipefail
base="${BASE_URL:-http://127.0.0.1:8080}"
curl -sf "$base/healthz" >/dev/null
# register
curl -sf -X POST "$base/v1/auth/register" \
-H 'Content-Type: application/json' \
-d '{"email":"gate@example.com","password":"correct-horse-battery"}' \
>/dev/null || true
# login → TOKEN
TOKEN=$(curl -sf -X POST "$base/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"gate@example.com","password":"correct-horse-battery"}' \
| jq -r '.token // .access_token')
test -n "$TOKEN" && test "$TOKEN" != "null"
# unauthenticated create must fail
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$base/v1/items" \
-H 'Content-Type: application/json' \
-d '{"name":"nope"}')
test "$code" = "401"
# authenticated create
id=$(curl -sf -X POST "$base/v1/items" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"gate-item"}' | jq -r '.id')
curl -sf -H "Authorization: Bearer $TOKEN" "$base/v1/items/$id" | grep -q gate-item
echo "OK id=$id"Run against a live server; then restart the server and re-fetch $id to prove persistence.
Lab 3 — Verification commands
go test ./... -count=1
go test ./... -race -count=1
# if you use build tags:
go test -tags=integration ./... -count=1
go run ./cmd/service &
sleep 1
bash scripts/demo.sh
kill -TERM %1
wait || trueSelf-review questions (answer in GATE-VI.md)
- Can a stranger run the service from README alone?
- Does data survive process restart?
- Are secrets from env (not committed)?
- Do 500 responses hide internals?
- Is every
*sqlquery usingContext?
- Are request bodies limited (
MaxBytesReader)?
- Is shutdown clean (no hang)?
- What elective did you skip and why?
Lab 4 — Fill the evidence table
Create GATE-VI.md:
# Stage VI Gate
Date:
Module:
Go: go1.26.x
## Rubric scores
| Criterion | Score | Notes |
|-----------|-------|-------|
| Persistence | | |
| Auth | | |
| Envelope | | |
| Tests | | |
| Shutdown/slog | | |
| README | | |
| Race | | |
| Demo | | |
## Worked checklist
| Area | File(s) | Done? |
|------|---------|-------|
| Migrations | migrations/ | |
| Repo | internal/store | |
| Auth | internal/auth | |
| Errors | writeDomainErr | |
| Tests | *_test.go | |
| Shutdown | main.go | |
| README | architecture | |
## Retrospective
1. sqlc/raw/ORM choice:
2. JWT vs session pain:
3. What Stage VII needs from this service:Lab 5 — Deliberate persistence proof
- Create item; note id.
kill -TERMthe process; confirm exit.
- Start again with same DSN/file.
- GET item by id succeeds.
If you only ever used :memory: SQLite—fail. Use a file DSN for the gate demo.
# example SQLite DSN
file:gate.db?_pragma=foreign_keys(1)
Hour-by-hour suggestion
| Time | Focus |
|---|---|
| 0:00–0:30 | Gap analysis vs gate bar |
| 0:30–1:30 | Fix persistence/auth/envelope holes |
| 1:30–2:10 | Tests + race |
| 2:10–2:40 | demo.sh + restart proof |
| 2:40–3:00 | GATE-VI.md + README architecture |
Common gotchas
| Gotcha | Fix |
|---|---|
| In-memory only “DB” | Real SQL file/engine |
| Auth demo-only hardcoded user | Register + hashed password |
| README lists endpoints that 404 | Re-run smoke |
| Integration tests not runnable | Document tags / skip policy |
| Elective half-merge breaking build | Feature flag or remove |
| Leaked goroutines from hub/cache | Shutdown hooks |
| JWT secret committed | Env + .env.example only |
No ReadHeaderTimeout |
Set server timeouts |
500 with raw err.Error() to clients |
Log server-side; generic client message |
Checkpoint (gate)
- All gate bar rows satisfied
- Demo script succeeds against running server
- Data persists across restart
- Architecture paragraph + diagram present
go test -racegreen for default tests you claim
- Rubric ≥ 80%, no zero on persistence/auth
- Personal retrospective: Stage VI hardest topic
Commit
git add .
git commit -m "day62: stage VI gate service store auth tests"Write three personal gotchas before continuing.
Stage VI retrospective prompts
Answer briefly in GATE-VI.md:
- sqlc / raw SQL / ORM—what did you choose and would you keep it?
- JWT vs session—what broke first?
- What will Stage VII (pprof, containers, metrics) need from this service?
- Which elective (gRPC/REST-deep, cache, WS/SSE) paid off most?
Tomorrow
Stage VII begins — Day 63 build tags & cross-compile: ship multi-arch artifacts and understand file-level build constraints—on the path from “works on my laptop” to “releasable binary.”