Stage 7 · Master
Phase 1 — Project Foundation
Configuration Management
Replace scattered os.Getenv calls with one typed Config struct the rest of the organization service can trust at compile time, not at 3am.
Why a Typed Struct Beats Scattered os.Getenv
A codebase that calls os.Getenv("PORT") from inside a handler, os.Getenv("DB_URL") from inside a repository constructor, and os.Getenv("LOG_LEVEL") from main.go has three unrelated places that can each parse the same idea differently, three unrelated places that can each silently accept an empty string, and zero places where a reviewer can see the service's full configuration surface at once. A typed Config struct, populated once at startup and passed down through the composition root, turns all three problems into one: read the struct definition, know everything the service needs from its environment.
A Shared pkg/config Module Around cleanenv
Every service needs typed configuration, defaults, required-value checks, and useful parse errors. Reimplementing those rules with os.Getenv and strconv in each service would create subtly different behavior. pkg/config therefore owns one thin wrapper around cleanenv; each service still owns its own Config struct and env tags, so sharing loading behavior never forces unrelated services into one schema.
// Package config centralizes typed environment loading. Service-specific
// schemas remain in each service's internal/config package.
package config
import (
"fmt"
"github.com/ilyakaznacheev/cleanenv"
)
// Load populates the caller's service-owned config struct from environment
// variables according to its env, env-default, and env-required tags.
func Load(target any) error {
if err := cleanenv.ReadEnv(target); err != nil {
return fmt.Errorf("load environment configuration: %w", err)
}
return nil
}
internal/config Owns the Organization Service's Own Schema
// Package config defines the organization service's own configuration
// schema and loads it once at startup through the shared cleanenv wrapper.
package config
import sharedconfig "github.com/hoa-platform/backend/pkg/config"
type Config struct {
Env string `env:"ORGANIZATION_ENV" env-default:"development"`
Port int `env:"ORGANIZATION_PORT" env-default:"8080"`
}
// Load reads the organization service's configuration from the environment.
// It fails fast — returning a descriptive error rather than letting a bad
// value surface later as a confusing runtime failure.
func Load() (*Config, error) {
var cfg Config
if err := sharedconfig.Load(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
import (
+ "fmt"
"log"
+ "github.com/hoa-platform/backend/services/organization/internal/config"
"github.com/hoa-platform/backend/services/organization/internal/server"
)
func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+
- srv := server.New(":8080")
+ srv := server.New(fmt.Sprintf(":%d", cfg.Port))
- log.Printf("organization service listening on %s", srv.Addr)
+ log.Printf("organization service (%s) listening on %s", cfg.Env, srv.Addr)
if err := srv.ListenAndServe(); err != nil {Six added lines and two changed ones. Note what did not change: server.New still takes an address string, so nothing below the composition root learns that configuration exists. That boundary is what lets the next lesson add .env loading without touching the server package at all.
Failing Fast Instead of Failing Weird
cleanenv parses ORGANIZATION_PORT into an int while loading the struct. An invalid value returns an error before net/http is constructed, rather than coercing to zero or surfacing later as an unrelated listener failure. A misconfigured service that refuses to start is far easier to diagnose than one that binds the wrong port.
2026/08/02 09:00:00 config: load environment configuration: parsing field Port env ORGANIZATION_PORT: strconv.ParseInt: parsing "abc": invalid syntax
exit status 1
Confirming the Default and the Override Both Work
Applied exercise
Add a required field and prove it fails fast when missing
Every field in Config so far has a safe default. Add one that does not, and confirm Load behaves correctly in both directions.
- Add
ServiceName stringwith tagsenv:"ORGANIZATION_SERVICE_NAME" env-required:"true"to Config. - Leave Load unchanged: cleanenv enforces the new required tag through the shared loader.
- Run go run ./cmd/api with the variable unset and record the exact error text.
- Run it again with ORGANIZATION_SERVICE_NAME=organization set and confirm the service starts normally.
Deliverable
A modified internal/config/config.go plus a short note recording both terminal outputs (missing vs. present).
Completion checks
- The missing-variable run exits non-zero with a message naming ORGANIZATION_SERVICE_NAME specifically.
- The present-variable run starts the server exactly as before.
- The error flows through pkg/config.Load's %w wrapper so a future errors.Is/As check can still inspect it.
What does cleanenv do when ORGANIZATION_PORT is unset but the Config field declares env-default:"8080"?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.