Files
gh-buzzdan-ai-coding-rules-…/skills/refactoring/examples.md
2025-11-29 18:02:42 +08:00

1677 lines
50 KiB
Markdown

# Example 1: Storifying Mixed Abstractions and Extracting Logic into Leaf Types
This is a real-world example from a production codebase showing how to transform a complex function by extracting logic into a new leaf type.
## Key Learning: From Fat Function to Lean Orchestration + Leaf Type
The original function contained ALL the logic. After refactoring:
- **Orchestration layer** (thin): `upsertIfaceAddrHost` - reads like a story
- **Leaf type** (juicy logic): `IPConfig` - owns IP collection, validation, testable in isolation
- **Result**: Most complexity moved to testable leaf type with 100% coverage potential
This is a real world example from a production codebase and what the developer chose to refactor.
It is not perfect, and it could be improved further, but it demonstrates the core refactoring pattern:
## Before refactoring
```go
// upsertIfaceAddrHost sets any IP from iface or returns error if provided IP not match to the interface
func (c *Config) upsertIfaceAddrHost(iface net.Interface) error {
addr, err := iface.Addrs()
if err != nil {
return fmt.Errorf("network addr: %w", err)
}
var (
addrIP4Added bool
addrIP6Added bool
)
for _, a := range addr {
ipnet, ok := a.(*net.IPNet)
if !ok || !ipnet.IP.IsGlobalUnicast() {
logger.Debug().Str("addr", a.String()).Msg("Not a global unicast address")
continue
}
if ipnet.IP.To4() == nil { // validate IP6
if addrIP6Added { // already added. skip
continue
}
if !c.parseIP6(ipnet) {
return fmt.Errorf("IP6 %q address is not valid", c.IP6)
}
logger.Debug().Str("ip6", c.IP6).Msg("set IP6")
addrIP6Added = true
continue
}
if addrIP4Added {
continue // already added. skip
}
if !c.parseIP4(ipnet) {
return fmt.Errorf("IP4 %q address is not valid", c.IP4)
}
logger.Debug().Str("ip4", c.IP6).Msg("set IP4")
addrIP4Added = true
}
if !addrIP4Added && !addrIP6Added {
return fmt.Errorf("IP address is not valid. IP4: %q, IP6: %q", c.IP4, c.IP6)
}
return nil
}
func (c *Config) isIP4Set() bool {
return len(c.IP4) > 0
}
func (c *Config) isIP6Set() bool {
return len(c.IP6) > 0
}
func (c *Config) parseIP4(ipnet *net.IPNet) bool {
if c.IP4 == ipnet.IP.To4().String() {
logger.Debug().Str("addr", ipnet.IP.To4().String()).Msg("IP4 match to interface")
return true
}
if c.IP4 == anyIPv4 || c.IP4 == "" {
logger.Debug().Str("addr", ipnet.IP.To4().String()).Msg("Using interface IP for NodeIP")
// use first ip found from interface
c.IP4 = ipnet.IP.To4().String()
return true
}
return false
}
func (c *Config) parseIP6(ipnet *net.IPNet) bool {
if c.IP6 == ipnet.IP.To16().String() {
logger.Debug().Str("addr", ipnet.IP.To16().String()).Msg("IP6 match to interface")
return true
}
if c.IP6 == anyIPv6 || c.IP6 == "" {
logger.Debug().Str("addr", ipnet.IP.To16().String()).Msg("Using interface IP for NodeIP")
// use first ip found from interface
c.IP6 = ipnet.IP.To16().String()
return true
}
return false
}
```
## Code Smells Identified
The `upsertIfaceAddrHost` function suffers from:
1. **Fat Function Anti-Pattern** - All logic crammed into one function (48 lines, complexity 12)
2. **Hidden Side Effects** - `parseIP4/parseIP6` names hide mutation
3. **Mixed Abstraction Levels** - Combines low-level iteration with high-level business logic
4. **No Leaf Types** - All logic lives in methods, nothing is extracted to testable types
5. **Flow Control Complexity** - Nested ifs, continues, boolean flags tracking state
6. **Poor Testability** - Must mock `net.Interface` to test anything
**The Core Problem**: All the juicy logic is trapped in a complex orchestration function. We need to extract it into a leaf type.
## After refactoring
```go
// upsertIfaceAddrHost sets any IP from iface or returns error if provided IP not match to the interface
func (c *Config) upsertIfaceAddrHost(iface net.Interface) error {
addr, err := iface.Addrs()
if err != nil {
return fmt.Errorf("network addr: %w", err)
}
ipConfig := collectIPConfigFrom(addr)
if err = c.AlignIPs(ipConfig); err != nil {
return fmt.Errorf("align config IPs err: %w", err)
}
return nil
}
func collectIPConfigFrom([]net.Addr addresses) IPConfig {
var ipConfig IPConfig
for _, a := range addresses {
ipConfig.AddAddress(a)
}
return ipConfig
}
type IPConfig struct {
IP4 string
IP6 string
}
func (c *IPConfig) AddAddress(a net.Addr) {
ipnet, ok := a.(*net.IPNet)
if !ok || !ipnet.IP.IsGlobalUnicast() {
logger.Debug().Str("addr", a.String()).Msg("Not a global unicast address")
return
}
if ipnet.IP.To4() != nil {
if len(c.IP4) > 0 {
return // already added
}
c.IP4 = ipnet.IP.To4().String()
}
if ipnet.IP.To4() == nil {
if len(c.IP6) > 0 {
return // already added
}
c.IP6 = ipnet.IP.To16().String()
return
}
}
func (c *IPConfig) Validate() error {
if len(c.IP4) == 0 && len(c.IP6) == 0 {
return errors.New("IP addresses are not found")
}
return nil
}
func (c *Config) AlignIPs(ipConfig IPConfig) error {
if err := ipConfig.Validate(); err != nil {
return fmt.Errorf("ip config is not valid: %w", err)
}
if err := c.alignIPv4(ipConfig.IP4); err != nil {
return fmt.Errorf("align IPv4 err: %w", err)
}
if err := c.alignIPv6(ipConfig.IP6); err != nil {
return fmt.Errorf("align IPv6 err: %w", err)
}
if len(c.IPv4) == 0 {
c.ExistingClusterStartedIPv4Only = false
}
if c.ExistingClusterStartedIPv4Only && len(c.IPv6) > 0 {
logger.Warn().
Str("IPv6", c.IPv6).
Str("IPv4", c.IPv4).
Msg("existing cluster is running in IPv4 only. Dual stack is not possible.")
}
return nil
}
func (c *Config) alignIPv4(ip string) error {
if c.IPv4 == ip {
logger.Debug().Str("addr", ip).Msg("IP4 match to interface")
return nil
}
if c.IPv4 == anyIPv4 || c.IPv4 == "" {
logger.Debug().Str("addr", ip).Msg("Using interface IP for NodeIP")
// use first ip found from interface
c.IPv4 = ip
return nil
}
return fmt.Errorf("existing IPv4 [%s] mismatch configured [%s]", ip, c.IPv4)
}
func (c *Config) alignIPv6(ip string) error {
if c.IPv6 == ip {
logger.Debug().Str("addr", ip).Msg("IPv6 match to interface")
return nil
}
if c.IPv6 == anyIPv6 || c.IPv6 == "" {
logger.Debug().Str("addr", ip).Msg("Using interface IP for NodeIP")
// use first ip found from interface
c.IPv6 = ip
return nil
}
return fmt.Errorf("existing IPv6 [%s] mismatch configured [%s]", ip, c.IPv6)
}
```
## Refactoring Thought Process
### Step 1: Identify What's Orchestration vs. Logic
The original function does 3 things:
1. **Collects** IP addresses from interface (LOGIC)
2. **Validates** them (LOGIC)
3. **Aligns** Config state with discovered IPs (orchestration + logic)
**Decision**: Extract the collection logic into a new type
### Step 2: Create a Leaf Type to Hold the Juicy Logic
Instead of keeping all logic in `Config` methods:
**Created `IPConfig` type** - a leaf type (no dependencies on other types)
**Moved collection logic** into `IPConfig.AddAddress()` method
**Moved validation logic** into `IPConfig.Validate()` method
**Why this matters**:
- `IPConfig` is now a **leaf type** with testable logic
- Can achieve 100% unit test coverage without mocking anything
- Logic is isolated and reusable
### Step 3: Make Orchestration Read Like a Story
`upsertIfaceAddrHost` now reads:
1. Get addresses from interface
2. Collect them into IPConfig
3. Align our config with what we collected
No nested ifs, no continues, no boolean flags - just clear steps.
### Step 4: Honest Naming for Side Effects
`parseIP4/parseIP6``alignIPv4/alignIPv6`
The word "align" signals mutation, "parse" suggested read-only.
## Key Improvements
### Architecture
* **Fat function became lean orchestration** - 48 lines → 12 lines in main function
* **Created leaf type `IPConfig`** - Holds all the juicy IP collection logic
* **Separated concerns** - Collection (IPConfig) vs. Alignment (Config methods)
### Readability
* **Storified orchestration** - `upsertIfaceAddrHost` reads like: collect → align → done
* **Honest naming** - `align*` reveals side effects vs. `parse*` hiding them
* **Single level of abstraction** - Each function operates at one conceptual level
### Testability
* **Leaf type with 100% coverage** - `IPConfig` can be fully unit tested without mocks
* **Testable in isolation**:
```go
// Test collection logic without network code
func TestIPConfig_AddAddress(t *testing.T) {
cfg := &IPConfig{}
cfg.AddAddress(createIPv4Addr("192.168.1.1"))
assert.Equal(t, "192.168.1.1", cfg.IP4)
}
```
* **Integration tests for orchestration** - Test the seams between IPConfig and Config
### Complexity Reduction
**Before**: Cognitive complexity 18, cyclomatic complexity 12
**After**: Max complexity 6 per function
## Refactoring Patterns Applied
1. **Type Extraction** → Created `IPConfig` leaf type for IP collection
2. **Storifying** → Top-level reads: collect → validate → align
3. **Honest Naming** → `align*` instead of `parse*` reveals mutation
4. **Single Responsibility** → Each function does ONE thing
5. **Early Returns** → Replaced `continue` with `return` for clarity
## The Leaf Type Strategy
**Before**: All logic trapped in one place
```
Config.upsertIfaceAddrHost() {
// ALL the logic here: iteration, validation, collection, alignment
// 48 lines, complexity 12, impossible to test separately
}
```
**After**: Logic extracted to leaf type
```
IPConfig (LEAF TYPE - no dependencies)
├─ AddAddress() // Collection logic (juicy!)
└─ Validate() // Validation logic (juicy!)
Config (ORCHESTRATOR)
├─ upsertIfaceAddrHost() // Thin story: collect → align
└─ AlignIPs() // Thin coordination
```
**Result**: Most of the complexity now lives in `IPConfig`, a leaf type with 100% test coverage potential.
# Example 2: Primitive Obsession with Multiple Types and Storifying Switch Statements
This real-world example shows how to transform a 60-line function with nested switches and boolean flags into a 7-line story by extracting multiple leaf types. The original function was named `validateCIDR()` but actually mutated state - a classic naming smell that triggered deeper refactoring.
## Key Learning: From Primitive Obsession to Type-Rich Design (Without Over-Abstraction!)
**Before**: All logic operates on raw `[]string` with manual parsing and boolean flags
```
One 60-line function
└─ Manual string parsing + switch statements + boolean flags
```
**After**: Multiple focused leaf types with clear responsibilities
```
K3SArgs (Leaf Type - string slice wrapper)
├─ ParseCIDRConfig() → returns domain model
└─ AppendCIDRDefaults() → mutation with explicit dependencies
CIDRConfig (Leaf Type - domain model with private fields)
├─ clusterCIDRSet (private bool - controlled mutation)
├─ serviceCIDRSet (private bool - controlled mutation)
├─ ClusterCIDRSet() → accessor (read-only)
├─ ServiceCIDRSet() → accessor (read-only)
└─ AreBothSet() → reads like English
Note: No CIDRPresence wrapper! Private fields achieve
same safety without wrapper ceremony.
IPVersionConfig (Leaf Type - configuration)
└─ DefaultCIDRs() → value generator
Main Function (Orchestrator - 7 lines)
└─ Story: create config → convert to type → append defaults → store back
```
**Result**:
- Main function reduced from 60 to 7 lines
- Most complexity lives in 3 leaf types (100% testable)
- Each type can be tested without mocking anything
- Code reads like English: "append CIDR defaults based on IP config"
- **Avoided over-abstraction**: Rejected `CIDRPresence` wrapper, used private fields instead
## Code Smells Identified
1. **Misleading Name** - `validateCIDR()` doesn't validate - it mutates! Should return `bool` or `error` if validating
2. **Primitive Obsession (CRITICAL)** - Operating on raw `[]string`, manual parsing everywhere, no encapsulation
3. **Mixed Abstraction Levels** - Jumps between string splitting (`strings.SplitN`) and business logic (`isClusterCIDRSet`)
4. **Boolean Flags Tracking State** - Two booleans tracking related information instead of domain type
5. **Switch Statement Duplication** - Three nearly identical switch cases (IPv4/IPv6/dual) differing only in data values
6. **Fat Function** - 60 lines doing: parse + detect + construct + mutate
7. **Hard to Test** - Must construct entire Config object, can't test parsing independently
**The Core Problem**: All the juicy logic is trapped in string manipulation and scattered across switch cases. We need multiple leaf types to separate parsing, configuration, and value generation concerns.
## Before Refactoring
```go
// Original name was validateCIDR - misleading!
func (c *Config) alignCIDRArgs() {
var (
isClusterCIDRSet bool
isServerCIDRSet bool
)
// LOW LEVEL: String parsing
for _, arg := range c.Configuration.K3SArgs {
kv := strings.SplitN(arg, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "--cluster-cidr":
isClusterCIDRSet = true
case "--service-cidr":
isServerCIDRSet = true
}
}
// HIGH LEVEL: Business logic
if isClusterCIDRSet && isServerCIDRSet {
return // both set, nothing to do
}
// DUPLICATION: Same pattern repeated 3 times with different values
switch {
case c.isIP4Set() && c.isIP6Set():
if !isClusterCIDRSet {
c.Configuration.K3SArgs = append(c.Configuration.K3SArgs,
fmt.Sprintf("--cluster-cidr=%s,%s", clusterCIDRIPv4, clusterCIDRIPv6))
}
if !isServerCIDRSet {
c.Configuration.K3SArgs = append(c.Configuration.K3SArgs,
fmt.Sprintf("--service-cidr=%s,%s", serviceCIDRIPv4, serviceCIDRIPv6))
}
case c.isIP4Set():
if !isClusterCIDRSet {
c.Configuration.K3SArgs = append(c.Configuration.K3SArgs,
"--cluster-cidr="+clusterCIDRIPv4)
}
if !isServerCIDRSet {
c.Configuration.K3SArgs = append(c.Configuration.K3SArgs,
"--service-cidr="+serviceCIDRIPv4)
}
case c.isIP6Set():
if !isClusterCIDRSet {
c.Configuration.K3SArgs = append(c.Configuration.K3SArgs,
"--cluster-cidr="+clusterCIDRIPv6)
}
if !isServerCIDRSet {
c.Configuration.K3SArgs = append(c.Configuration.K3SArgs,
"--service-cidr="+serviceCIDRIPv6)
}
}
}
```
## First Refactoring Attempt: The Over-Abstraction Trap
Before showing the final solution, let's see a common mistake: **over-abstracting booleans**.
### What We Tried (Over-Abstraction ❌)
```go
// CIDRPresence - A wrapper that adds NO value
type CIDRPresence bool
const (
cidrPresent CIDRPresence = true
)
func (p CIDRPresence) IsSet() bool {
return bool(p) // Just unwraps the bool!
}
type CIDRConfig struct {
ClusterCIDR CIDRPresence // Wrapped bool
ServiceCIDR CIDRPresence // Wrapped bool
}
func (c CIDRConfig) AreBothSet() bool {
return c.ClusterCIDR.IsSet() && c.ServiceCIDR.IsSet()
}
```
### Why This Is Over-Abstraction
**Problems with CIDRPresence**:
1. ❌ **8 lines of code** for a trivial wrapper
2. ❌ **One method** that just unwraps: `return bool(p)`
3. ❌ **No type safety** - still just a bool underneath
4. ❌ **Not more readable** - compare:
- `config.ClusterCIDR.IsSet()` (with wrapper)
- `config.ClusterCIDRSet` (with good naming)
5. ❌ **No validation, no logic, no invariants** - pure ceremony
6. ❌ **Increases cognitive load** - one more type to understand
**The Honest Question**: Is `config.ClusterCIDR.IsSet()` **significantly** clearer than `config.ClusterCIDRSet`?
**Answer**: No! Good naming achieves the same clarity.
**The Real Need**: We DO need controlled mutation (only parser should set these values), but we don't need a wrapper type to achieve it.
### The Better Solution: Private Fields
Instead of wrapping with `CIDRPresence`, use **private fields with accessor methods**:
```go
// ✅ Simple, safe, clear
type CIDRConfig struct {
clusterCIDRSet bool // Private: can only be set by ParseCIDRConfig
serviceCIDRSet bool // Private: can only be set by ParseCIDRConfig
}
// Read-only accessors
func (c CIDRConfig) ClusterCIDRSet() bool { return c.clusterCIDRSet }
func (c CIDRConfig) ServiceCIDRSet() bool { return c.serviceCIDRSet }
func (c CIDRConfig) AreBothSet() bool {
return c.clusterCIDRSet && c.serviceCIDRSet
}
```
**Why This Is Better**:
- ✅ **4 lines** vs 8 lines for CIDRPresence wrapper
- ✅ **Same safety** - compiler enforces that only parser can set values
- ✅ **Same readability** - `ClusterCIDRSet()` is just as clear
- ✅ **No wrapper ceremony** - fields are what they are: bools
- ✅ **Controlled mutation** - private fields can't be set externally
**Key Lesson**: Not every primitive needs a type. Use private fields when you need controlled mutation without wrapper overhead.
---
## After Refactoring (Final Solution)
```go
// Main function: Now a 7-line story!
func (c *Config) alignCIDRArgs() {
ipConfig := IPVersionConfig{
IPv4Enabled: c.isIP4Set(),
IPv6Enabled: c.isIP6Set(),
}
k3sArgs := K3SArgs(c.K3SArgs)
k3sArgs.AppendCIDRDefaults(ipConfig)
c.K3SArgs = []string(k3sArgs)
}
// ==================== LEAF TYPE 1: K3SArgs ====================
// K3SArgs represents K3S command-line arguments.
// Encapsulates ALL argument list operations.
// Design choice: Type alias (not struct) allows direct use in JSON configs:
// type Config struct {
// K3SArgs K3SArgs `json:"k3sArgs,omitempty"`
// }
type K3SArgs []string
// ParseCIDRConfig extracts which CIDRs are already configured.
// This is the ONLY place where CIDR flags can be set.
func (args K3SArgs) ParseCIDRConfig() CIDRConfig {
var config CIDRConfig
for _, arg := range args {
key, _, found := parseK3SArgument(arg)
if !found {
continue
}
switch key {
case "--cluster-cidr":
config.clusterCIDRSet = true // ✓ Controlled mutation in parser
case "--service-cidr":
config.serviceCIDRSet = true // ✓ Controlled mutation in parser
}
}
return config
}
// AppendCIDRDefaults adds missing CIDR arguments based on IP configuration.
func (args *K3SArgs) AppendCIDRDefaults(ipConfig IPVersionConfig) {
existing := args.ParseCIDRConfig()
if existing.AreBothSet() {
return // nothing to do
}
defaults := ipConfig.DefaultCIDRs()
if !existing.ClusterCIDRSet() { // ✓ Read-only access via method
*args = append(*args, defaults.ClusterCIDRArg())
}
if !existing.ServiceCIDRSet() { // ✓ Read-only access via method
*args = append(*args, defaults.ServiceCIDRArg())
}
}
// parseK3SArgument splits a K3S argument into key and value.
func parseK3SArgument(arg string) (key, value string, ok bool) {
parts := strings.SplitN(arg, "=", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// ==================== LEAF TYPE 2: CIDRConfig ====================
// CIDRConfig represents which CIDR configurations are present.
// Uses private fields for controlled mutation - can only be set by ParseCIDRConfig.
type CIDRConfig struct {
clusterCIDRSet bool // Private: controlled mutation
serviceCIDRSet bool // Private: controlled mutation
}
// ClusterCIDRSet returns true if cluster CIDR is configured.
func (c CIDRConfig) ClusterCIDRSet() bool {
return c.clusterCIDRSet
}
// ServiceCIDRSet returns true if service CIDR is configured.
func (c CIDRConfig) ServiceCIDRSet() bool {
return c.serviceCIDRSet
}
// AreBothSet returns true if both cluster and service CIDRs are configured.
func (c CIDRConfig) AreBothSet() bool {
return c.clusterCIDRSet && c.serviceCIDRSet
}
// ==================== LEAF TYPE 3: IPVersionConfig ====================
// IPVersionConfig describes which IP versions are enabled.
type IPVersionConfig struct {
IPv4Enabled bool
IPv6Enabled bool
}
func (cfg IPVersionConfig) DefaultCIDRs() DefaultCIDRValues {
return DefaultCIDRValues{
ipv4Enabled: cfg.IPv4Enabled,
ipv6Enabled: cfg.IPv6Enabled,
}
}
// DefaultCIDRValues generates default CIDR arguments based on IP config.
type DefaultCIDRValues struct {
ipv4Enabled bool
ipv6Enabled bool
}
func (d DefaultCIDRValues) ClusterCIDRArg() string {
return "--cluster-cidr=" + d.clusterCIDRValue()
}
func (d DefaultCIDRValues) ServiceCIDRArg() string {
return "--service-cidr=" + d.serviceCIDRValue()
}
func (d DefaultCIDRValues) clusterCIDRValue() string {
var cidrs []string
if d.ipv4Enabled {
cidrs = append(cidrs, defaultClusterCIDRIPv4)
}
if d.ipv6Enabled {
cidrs = append(cidrs, defaultClusterCIDRIPv6)
}
return strings.Join(cidrs, ",")
}
func (d DefaultCIDRValues) serviceCIDRValue() string {
var cidrs []string
if d.ipv4Enabled {
cidrs = append(cidrs, defaultServiceCIDRIPv4)
}
if d.ipv6Enabled {
cidrs = append(cidrs, defaultServiceCIDRIPv6)
}
return strings.Join(cidrs, ",")
}
```
## Refactoring Thought Process
### Step 1: Recognize Primitive Obsession - The Root Cause
**What's happening**: Function operates on raw `[]string` with manual parsing scattered throughout
```go
// Config struct uses primitive type
type Config struct {
K3SArgs []string `json:"k3sArgs,omitempty"` // Just a slice!
}
// Parsing logic mixed into business logic
for _, arg := range c.K3SArgs {
kv := strings.SplitN(arg, "=", 2) // String parsing
if len(kv) != 2 { continue } // Validation
switch kv[0] { ... } // Business logic
}
```
→ **Decision**: Extract a `K3SArgs` type alias to encapsulate argument list operations
```go
type K3SArgs []string // Type alias, not struct
type Config struct {
K3SArgs K3SArgs `json:"k3sArgs,omitempty"` // Now has methods!
}
```
**Why type alias vs struct?**
- ✅ Can use directly in JSON config structs (serializes as array)
- ✅ Can convert to/from `[]string` easily: `K3SArgs(slice)` and `[]string(k3sArgs)`
- ✅ No wrapper overhead
- ✅ Backward compatible with existing JSON configs
**Why this matters**:
- Once you have a type, you can move ALL operations on that data into methods
- Type can be used directly as a config field with JSON tags
- Creates a testable boundary
- Methods travel with the data everywhere it's used
### Step 2: Identify What Logic Belongs Where
**Analysis of the original function**:
1. **Parse existing arguments** → Belongs in `K3SArgs.ParseCIDRConfig()`
2. **Track which CIDRs exist** → Needs domain type: `CIDRConfig`
3. **Determine defaults based on IP version** → Needs config type: `IPVersionConfig`
4. **Generate CIDR strings** → Needs value generator: `DefaultCIDRValues`
→ **Decision**: Extract 4 different types, each with one responsibility
**Why this matters**: Instead of one 60-line function, we get 4 small leaf types that are independently testable.
### Step 3: Replace Boolean Flags with Domain Type
**Before**: Two booleans tracking related state
```go
var isClusterCIDRSet bool
var isServerCIDRSet bool
if isClusterCIDRSet && isServerCIDRSet { return }
```
**After**: Domain model with query method
```go
type CIDRConfig struct {
clusterCIDRSet bool // Private fields
serviceCIDRSet bool
}
func (c CIDRConfig) AreBothSet() bool {
return c.clusterCIDRSet && c.serviceCIDRSet
}
if existing.AreBothSet() { return }
```
→ **Why this transformation matters**:
- Reads like English: "are both set?"
- Encapsulates the logic in one place
- Extensible: easy to add DNS CIDR field
- Groups related state
### Step 3.5: Recognize Over-Abstraction (Critical Decision!)
**Temptation**: Wrap the bool in a type
```go
// ❌ Over-abstraction!
type CIDRPresence bool
func (p CIDRPresence) IsSet() bool { return bool(p) }
type CIDRConfig struct {
ClusterCIDR CIDRPresence
ServiceCIDR CIDRPresence
}
```
**Questions to ask**:
1. Does `CIDRPresence` add meaningful methods? → **NO** (just `.IsSet()` which unwraps)
2. Does it enforce invariants? → **NO** (still just a bool)
3. Does it need controlled mutation? → **YES!** (should only be set by parser)
4. Is `.ClusterCIDR.IsSet()` clearer than `.ClusterCIDRSet()`? → **NO!**
→ **Decision**: Don't create `CIDRPresence` wrapper. Instead, use **private fields** for controlled mutation:
```go
// ✅ Better: Private fields + accessor methods
type CIDRConfig struct {
clusterCIDRSet bool // Private: only parser can set
serviceCIDRSet bool
}
func (c CIDRConfig) ClusterCIDRSet() bool { return c.clusterCIDRSet }
func (c CIDRConfig) ServiceCIDRSet() bool { return c.serviceCIDRSet }
```
**Why this matters**:
- Achieves same safety (compiler-enforced controlled mutation)
- 4 fewer lines than wrapper approach
- No ceremonial type wrapping
- Just as readable: `ClusterCIDRSet()` vs `ClusterCIDR.IsSet()`
**Key lesson**: Not every primitive needs a type. Use private fields when you need controlled mutation without wrapper overhead.
### Step 4: Eliminate Switch Statement Duplication
**Problem identified**: Same pattern repeated 3 times
```go
case c.isIP4Set() && c.isIP6Set():
if !isClusterCIDRSet { append(..., IPv4+IPv6) }
if !isServerCIDRSet { append(..., IPv4+IPv6) }
case c.isIP4Set():
if !isClusterCIDRSet { append(..., IPv4) }
if !isServerCIDRSet { append(..., IPv4) }
case c.isIP6Set():
// Same pattern again!
```
**What differs**: Only the CIDR values (IPv4 vs IPv6 vs both)
→ **Decision**: Extract value generation into `DefaultCIDRValues` type
**Result**: The pattern disappears entirely - replaced by:
```go
defaults := ipConfig.DefaultCIDRs()
if !existing.ClusterCIDR.IsSet() {
*args = append(*args, defaults.ClusterCIDRArg())
}
```
**Why this matters**: Duplication eliminated by separating data selection from flow control.
### Step 5: Storify the Main Function
**Goal**: Make it read like a story at ONE abstraction level
**Process**:
```go
// Step 1: Create configuration object (HIGH LEVEL)
ipConfig := IPVersionConfig{
IPv4Enabled: c.isIP4Set(),
IPv6Enabled: c.isIP6Set(),
}
// Step 2: Convert to typed wrapper (HIGH LEVEL)
k3sArgs := K3SArgs(c.K3SArgs)
// Step 3: Apply business logic (HIGH LEVEL)
k3sArgs.AppendCIDRDefaults(ipConfig)
// Step 4: Store result (HIGH LEVEL)
c.K3SArgs = []string(k3sArgs)
```
**Read it aloud**: "Create IP config, convert args to typed wrapper, append CIDR defaults, store back."
→ **Result**: All implementation details (parsing, switching, string building) are hidden in leaf types
## Key Improvements
### Architecture
* **Fat function became lean orchestrator** - 60 lines → 7 lines
* **Created 3 leaf types** - Each handles one concern:
- `K3SArgs`: Argument list operations (parsing, appending) - **usable as config field**
- `CIDRConfig`: Domain model with **private fields for safety**
- `IPVersionConfig` + `DefaultCIDRValues`: CIDR value generation
* **Clear separation** - Parsing vs Detection vs Value Generation vs Orchestration
* **Type alias pattern** - `K3SArgs` as type alias enables direct use in config structs with JSON serialization
* **Avoided over-abstraction** - Rejected `CIDRPresence` wrapper, used private fields instead (4 fewer lines, same safety)
### Readability
* **Storified main function** - Reads like: create config → convert → append → store
* **Fixed misleading name** - `validateCIDR()` → `alignCIDRArgs()` (now accurately describes mutation)
* **Query methods read like English**:
```go
if existing.AreBothSet() { return }
if !existing.ClusterCIDR.IsSet() { /* ... */ }
```
* **Single abstraction level** - Main function operates entirely at HIGH level
### Testability
* **All leaf types testable independently**:
```go
// Test argument parsing without Config
func TestK3SArgs_ParseCIDRConfig(t *testing.T) {
args := K3SArgs{"--cluster-cidr=10.0.0.0/8", "--other-flag=value"}
config := args.ParseCIDRConfig()
assert.True(t, config.ClusterCIDR.IsSet())
assert.False(t, config.ServiceCIDR.IsSet())
}
// Test CIDR value generation without network code
func TestDefaultCIDRValues_ClusterCIDRArg(t *testing.T) {
values := DefaultCIDRValues{ipv4Enabled: true, ipv6Enabled: true}
arg := values.ClusterCIDRArg()
assert.Equal(t, "--cluster-cidr=10.42.0.0/16,fd00:42::/56", arg)
}
// Test domain logic without parsing
func TestCIDRConfig_AreBothSet(t *testing.T) {
config := CIDRConfig{
ClusterCIDR: cidrPresent,
ServiceCIDR: cidrPresent,
}
assert.True(t, config.AreBothSet())
}
```
* **No mocking needed** - Each type constructed with simple values
* **100% coverage achievable** - All logic in leaf types
### Complexity Reduction
**Before**:
- 60 lines in one function
- Cyclomatic complexity: 12
- Cognitive complexity: 18
- 3 nesting levels
**After**:
- Main function: 7 lines, complexity 1
- Largest helper: 15 lines, complexity 4
- Max nesting: 2 levels
- **Most complexity in leaf types** (easily testable)
### Avoiding Over-Abstraction
* **Rejected CIDRPresence wrapper** - Recognized it added no value:
- Would be 8 lines for a trivial bool wrapper
- Only one method: `.IsSet()` that just unwraps the bool
- Not more readable than good naming
- No validation, no logic, no invariants
* **Used private fields instead** - Achieved same safety with less code:
- Compiler-enforced controlled mutation
- Only parser can set values
- 4 fewer lines than wrapper approach
* **Key decision**: Compared `config.ClusterCIDR.IsSet()` vs `config.ClusterCIDRSet()` honestly
- **Answer**: Good naming is just as clear as method call
- **Lesson**: Not every primitive needs a type
## Refactoring Patterns Applied
1. **Replace Primitive with Domain Type (Type Alias Pattern)** → Created `K3SArgs` type alias for `[]string` (usable in config fields)
2. **Extract Multiple Leaf Types** → Created 3 leaf types (`K3SArgs`, `CIDRConfig`, `IPVersionConfig`) instead of one complex function
3. **Storifying** → Main function reads: create config → convert → append → store (all at same abstraction level)
4. **Replace Boolean Flags with Domain Model** → `isClusterCIDRSet, isServerCIDRSet` → `CIDRConfig` with **private fields** and query methods
5. **Eliminate Switch Duplication** → Extracted value generation to `DefaultCIDRValues`, eliminated 3 duplicate cases
6. **Introduce Parameter Object** → Created `IPVersionConfig` to pass related configuration together
7. **Query Method Pattern** → `AreBothSet()`, `ClusterCIDRSet()`, `ServiceCIDRSet()` read like English questions
8. **Avoid Over-Abstraction** → Rejected `CIDRPresence` wrapper, used private fields with accessors for controlled mutation
## The Type Extraction Strategy
**Before**: All logic in one place
```
Config.alignCIDRArgs() {
// 60 lines of:
// - String parsing (strings.SplitN, validation)
// - Boolean flag tracking
// - Switch statements with duplication
// - String building (fmt.Sprintf, string concatenation)
// - Slice mutation
}
```
**After**: Multiple focused leaf types
```
K3SArgs (LEAF TYPE - no external dependencies)
├─ ParseCIDRConfig() // Parsing logic (juicy!)
├─ AppendCIDRDefaults() // Mutation logic (juicy!)
└─ parseK3SArgument() // Helper (juicy!)
CIDRConfig (LEAF TYPE - domain model with private fields)
├─ clusterCIDRSet (private bool)
├─ serviceCIDRSet (private bool)
├─ ClusterCIDRSet() // Accessor (read-only)
├─ ServiceCIDRSet() // Accessor (read-only)
└─ AreBothSet() // Domain logic (juicy!)
Note: No CIDRPresence wrapper! Private fields achieve
same safety with less ceremony.
IPVersionConfig (LEAF TYPE - configuration)
└─ DefaultCIDRs() → DefaultCIDRValues
DefaultCIDRValues (LEAF TYPE - value generator)
├─ ClusterCIDRArg() // String building (juicy!)
├─ ServiceCIDRArg() // String building (juicy!)
├─ clusterCIDRValue() // IPv4/IPv6 selection (juicy!)
└─ serviceCIDRValue() // IPv4/IPv6 selection (juicy!)
Config (ORCHESTRATOR)
└─ alignCIDRArgs() // Thin story: 7 lines
```
**Result**:
- Main function is 7 lines of pure orchestration
- ALL complexity moved to leaf types
- Each leaf type achieves 100% unit test coverage
- No mocking required for any test
## Linter Metrics
**Before**:
- Lines: 60
- Cyclomatic complexity: 12
- Cognitive complexity: 18
- Functions: 1 (doing everything)
- Testable units: 1 (requires full Config)
**After**:
- Main function: 7 lines, complexity 1
- Total lines: ~146 (across 5 types + helpers)
- Max complexity per function: 4
- Testable units: 9 (all independently testable)
- Leaf types: 3 (all with 100% coverage potential)
## Abstraction Balance: Comparison Table
| Approach | Total Lines | Types | Readability | Safety | Ceremony | Verdict |
|----------|-------------|-------|-------------|--------|----------|---------|
| **CIDRPresence wrapper** | ~150 | 6 | Good | Low | High | ❌ Over-abstraction |
| **Public bool fields** | ~142 | 5 | Good | Low | Low | ⚠️ Acceptable for small teams |
| **Private bool + accessors** | ~146 | 5 | Good | **High** | Low | ✅ **Recommended** |
**Why Private Fields Win**:
- Only 4 extra lines vs public fields (2 accessor methods)
- 4 fewer lines than CIDRPresence wrapper
- Compiler-enforced mutation control (can only be set in `ParseCIDRConfig`)
- Same readability as public fields
- Best safety-to-complexity ratio
- No wrapper ceremony
## Remaining Opportunities
**What could still be improved** (and why we stopped):
### 1. Why We Rejected CIDRPresence Wrapper ❌
**Could have done**:
```go
type CIDRPresence bool
func (p CIDRPresence) IsSet() bool { return bool(p) }
```
**Why we didn't**:
- ❌ 8 lines for a trivial bool wrapper
- ❌ Only one method that just unwraps: `return bool(p)`
- ❌ Not more readable: `config.ClusterCIDR.IsSet()` vs `config.ClusterCIDRSet()`
- ❌ No validation, no logic, no invariants
- ❌ Would add ceremony without benefit
**What we did instead**: Private bool fields with accessor methods
- ✅ Same safety (compiler-enforced controlled mutation)
- ✅ 4 fewer lines
- ✅ No wrapper overhead
- ✅ Just as readable
**Lesson**: **Not every primitive needs a type.** Ask: "Does this wrapper add meaningful logic or just ceremony?"
### 2. Why We Chose Private Fields Over Public Fields
**Could have used public fields**:
```go
type CIDRConfig struct {
ClusterCIDRSet bool // Public
ServiceCIDRSet bool // Public
}
```
**Why we used private fields**:
- ✅ Compiler enforces that only `ParseCIDRConfig` can set values
- ✅ Single source of truth for where values come from
- ✅ Easy to debug: only one place to check
- ✅ Only 4 extra lines (2 accessor methods)
- ✅ Public fields would work for small, disciplined teams, but private fields are safer
**Lesson**: **Use private fields when mutation should be controlled.** Only 4 lines for compile-time safety.
### 3. DefaultCIDRValues Has Similar Methods
**Could extract**:
- `clusterCIDRValue()` and `serviceCIDRValue()` are similar
- Could extract common pattern with constants as parameters
**Why we stopped**:
- Only 2 cases - extraction would be premature abstraction
- Current code is clear and straightforward
- YAGNI principle applies
### 4. K3SArgs Could Support More Operations
**Could add**:
- `Remove()`, `Update()`, `HasFlag()` methods
**Why we stopped**:
- YAGNI - only need parsing and appending for now
- Add methods when you need them, not before
### 5. IPVersionConfig Is Just Two Bools
**Could use enum**:
```go
type IPVersion int
const (
IPv4Only IPVersion = iota
IPv6Only
DualStack
)
```
**Why we stopped**:
- Two bools are clear and simple enough
- Enum would add complexity without clarity benefit
- Current code is self-documenting
### 6. Why Type Alias Over Struct for K3SArgs
```go
// ❌ Struct would require unwrapping for JSON
type K3SArgs struct {
args []string
}
type Config struct {
K3SArgs K3SArgs // JSON: {"k3sArgs": {"args": [...]}}
}
// ✅ Type alias works directly
type K3SArgs []string
type Config struct {
K3SArgs K3SArgs `json:"k3sArgs,omitempty"` // JSON: {"k3sArgs": [...]}
}
```
---
## Key Lessons: When to Stop Refactoring
**Good refactoring knows when to stop.** We achieved our goals:
- ✅ Main function reads like a story (7 lines)
- ✅ All logic extracted to testable leaf types
- ✅ No primitive obsession (created `K3SArgs` with real behavior)
- ✅ **Avoided over-abstraction** (rejected `CIDRPresence` wrapper)
- ✅ Switch duplication eliminated
- ✅ Complexity under control
- ✅ Controlled mutation via private fields
- ✅ Type alias pattern enables clean JSON serialization
**The Balance**:
```
Too Simple Sweet Spot Over-Engineering
| | |
Raw primitives Domain types Types for everything
[]string K3SArgs CIDRPresence wrapper
bool flags CIDRConfig Every bool wrapped
(private fields)
```
**Critical Questions Before Creating a Type**:
1. Does it have >1 meaningful method with logic? (Not just unwrapping)
2. Does it enforce invariants or validation?
3. Does it need controlled mutation? (Use private fields, not wrappers)
4. Is the method call **significantly** clearer than good naming?
5. Does it hide complex implementation?
**If answers are mostly NO** → Use primitives with good naming (or private fields for safety)
Further refactoring would be over-engineering at this point.
# Example 3: Dependency Rejection Pattern - Incremental Global Elimination
This real-world example shows how to handle `noglobals` linter failures by incrementally pushing global dependencies up the call chain, creating "islands of clean code" that are 100% testable.
## Key Learning: From Global Chaos to Clean Islands
**The Problem**: `env.Configs.NATsAddress` accessed throughout codebase (20+ locations)
**The Solution**: Reject dependency up one level at a time (bottom-up approach)
**The Result**: Clean, testable types with globals only at entry points (2 locations)
This pattern is DIFFERENT from other refactorings:
- **NOT a one-time fix** - it's an incremental journey
- **Start at the bottom** (leaf code)
- **Create one clean island at a time**
- **Slowly push globals toward main()**
- **Pragmatic endpoint** - accept globals at top level
## Why Not Just Refactor Logger?
**Important**: Some globals are designed to be global (like `slog.Logger`, `zerolog`). Those are fine!
**Problem globals**: Configuration access like:
- `env.Configs.NATsAddress`
- `env.Configs.DBHost`
- `env.Configs.RedisURL`
- Any `env.Configs.*` scattered throughout code
**Why these are problems**:
- Makes code untestable (need to set global state)
- Creates hidden dependencies
- Impossible to run tests in parallel
- Can't swap implementations
- Tight coupling to global config struct
## Before Refactoring: Global Chaos
```go
// Global config accessed everywhere
package env
var Configs struct {
NATsAddress string
DBHost string
RedisURL string
}
// ❌ Deep in the messaging code - globals everywhere
package messaging
func PublishEvent(event Event) error {
// Global accessed here
conn, err := nats.Connect(env.Configs.NATsAddress)
if err != nil {
return fmt.Errorf("connect failed: %w", err)
}
defer conn.Close()
data, err := json.Marshal(event)
if err != nil {
return err
}
return conn.Publish(event.Topic, data)
}
func PublishBatch(events []Event) error {
// Global accessed here too
conn, err := nats.Connect(env.Configs.NATsAddress)
if err != nil {
return fmt.Errorf("connect failed: %w", err)
}
defer conn.Close()
for _, event := range events {
// ... publishing logic
}
return nil
}
// ❌ In the order service - more global access
package order
func ProcessOrder(orderID string) error {
// More globals
db := connectDB(env.Configs.DBHost)
defer db.Close()
// Even more globals
err := messaging.PublishEvent(orderCreatedEvent)
// ...
}
// ❌ Testing is a nightmare
func TestPublishEvent(t *testing.T) {
// Must set global state!
env.Configs.NATsAddress = "nats://test:4222"
// Tests can't run in parallel
// Can't easily test with different addresses
// Global state leaks between tests
}
```
**Problems Identified**:
- `env.Configs.NATsAddress` used in 12 places
- `env.Configs.DBHost` used in 8 places
- Testing requires global state mutation
- Parallel tests impossible
- Hidden dependencies everywhere
## Step-by-Step Refactoring
### Step 1: Analyze Dependency Chain
```
DEPENDENCY MAP:
main()
└─ HTTP handlers (entry points)
├─ OrderService.ProcessOrder() [USES env.Configs.DBHost]
│ └─ messaging.PublishEvent() [USES env.Configs.NATsAddress]
│ └─ messaging.PublishBatch() [USES env.Configs.NATsAddress]
└─ UserService.CreateUser()
└─ messaging.PublishEvent() [USES env.Configs.NATsAddress]
DEEPEST USAGE: messaging.PublishEvent/PublishBatch (furthest from main)
START HERE: Extract messaging types first
```
### Step 2: Create First Clean Island (Bottom Level)
**Extract NATSClient - the deepest leaf**:
```go
// ✅ Clean type with injected dependency
type NATSClient struct {
natsAddress string // Injected, not global!
}
func NewNATSClient(natsAddress string) *NATSClient {
return &NATSClient{natsAddress: natsAddress}
}
func (c *NATSClient) PublishEvent(event Event) error {
// Uses injected value, not global
conn, err := nats.Connect(c.natsAddress)
if err != nil {
return fmt.Errorf("connect failed: %w", err)
}
defer conn.Close()
data, err := json.Marshal(event)
if err != nil {
return err
}
return conn.Publish(event.Topic, data)
}
func (c *NATSClient) PublishBatch(events []Event) error {
conn, err := nats.Connect(c.natsAddress)
if err != nil {
return fmt.Errorf("connect failed: %w", err)
}
defer conn.Close()
for _, event := range events {
// ... publishing logic
}
return nil
}
// ✅ Now testable without globals!
func TestNATSClient_PublishEvent(t *testing.T) {
// Start local test NATS server
testNATS := startTestNATS(t)
defer testNATS.Stop()
// Create client with test address - NO GLOBALS!
client := NewNATSClient(testNATS.URL())
// Test with real implementation
err := client.PublishEvent(testEvent)
assert.NoError(t, err)
// Can run in parallel!
// No global state needed!
}
```
**Island #1 Created**: `NATSClient` is now 100% testable without global dependencies.
### Step 3: Push Global Up One Level
**Now the callers (one level up) need updating**:
```go
// ❌ Before - OrderService accessed globals
package order
func ProcessOrder(orderID string) error {
db := connectDB(env.Configs.DBHost)
defer db.Close()
// This used to access env.Configs.NATsAddress internally
err := messaging.PublishEvent(orderCreatedEvent)
return err
}
// ✅ After - OrderService gets clean dependency
package order
type OrderService struct {
dbHost string // Injected
natsClient *NATSClient // Clean dependency!
}
func NewOrderService(dbHost string, natsClient *NATSClient) *OrderService {
return &OrderService{
dbHost: dbHost,
natsClient: natsClient,
}
}
func (s *OrderService) ProcessOrder(orderID string) error {
db := connectDB(s.dbHost)
defer db.Close()
// Use clean dependency - no globals
err := s.natsClient.PublishEvent(orderCreatedEvent)
return err
}
// ✅ Now OrderService is testable!
func TestOrderService_ProcessOrder(t *testing.T) {
testNATS := startTestNATS(t)
defer testNATS.Stop()
natsClient := NewNATSClient(testNATS.URL())
service := NewOrderService("localhost:5432", natsClient)
err := service.ProcessOrder("order-123")
assert.NoError(t, err)
}
```
**Island #2 Created**: `OrderService` is now testable with clean dependencies.
### Step 4: Continue Up the Chain to Entry Points
**Finally, reach the HTTP handlers (entry points)**:
```go
// ✅ HTTP handler - global accessed only here (top level)
package api
type OrderHandler struct {
orderService *OrderService
}
func SetupOrderHandler() *OrderHandler {
// Global accessed only at entry point!
natsClient := NewNATSClient(env.Configs.NATsAddress)
orderService := NewOrderService(env.Configs.DBHost, natsClient)
return &OrderHandler{
orderService: orderService,
}
}
func (h *OrderHandler) HandleCreateOrder(w http.ResponseWriter, r *http.Request) {
// All clean code from here down - no globals!
err := h.orderService.ProcessOrder(orderID)
// ...
}
```
**Final state**:
- Globals only in `SetupOrderHandler()` (acceptable!)
- Everything below is clean, testable code
- 2 global accesses (was 20+)
## Complete Before/After Comparison
### Before: Globals Everywhere (20+ accesses)
```
main()
└─ handlers [env.Configs access]
├─ OrderService [env.Configs access]
│ └─ messaging funcs [env.Configs access]
└─ UserService [env.Configs access]
└─ messaging funcs [env.Configs access]
Testing: IMPOSSIBLE without global state mutation
Parallel tests: NO
```
### After: Globals Only at Top (2 accesses)
```
main()
└─ handlers [env.Configs access - 2 locations ONLY]
├─ OrderService [clean - injected deps]
│ └─ NATSClient [clean - injected deps]
└─ UserService [clean - injected deps]
└─ NATSClient [clean - injected deps]
Testing: EASY - inject test values
Parallel tests: YES
Islands of clean code: 3 types (NATSClient, OrderService, UserService)
```
## Testing Benefits Demonstrated
### Before: Global State Nightmare
```go
func TestPublishEvent(t *testing.T) {
// ❌ Must mutate global
originalAddr := env.Configs.NATsAddress
env.Configs.NATsAddress = "nats://test:4222"
defer func() {
env.Configs.NATsAddress = originalAddr // Restore
}()
// ❌ Can't run in parallel - global state shared
// ❌ Tests can interfere with each other
// ❌ Hard to test multiple scenarios
}
```
### After: Clean Dependency Injection
```go
func TestNATSClient_PublishEvent(t *testing.T) {
t.Parallel() // ✅ Parallel execution!
// ✅ No global state needed
testNATS := startTestNATS(t)
defer testNATS.Stop()
// ✅ Clean injection
client := NewNATSClient(testNATS.URL())
// ✅ Easy to test edge cases
tests := []struct {
name string
address string
wantErr bool
}{
{"valid", testNATS.URL(), false},
{"invalid", "nats://nonexistent:4222", true},
{"empty", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Each test gets clean client - no interference!
c := NewNATSClient(tt.address)
err := c.PublishEvent(testEvent)
// ... assertions
})
}
}
```
## Metrics
### Before Refactoring
```
Global Accesses:
- env.Configs.NATsAddress: 12 locations (deep in code)
- env.Configs.DBHost: 8 locations (deep in code)
Total: 20 global accesses scattered
Testability:
- Testable types: 0 (all depend on globals)
- Parallel tests: Impossible
- Test complexity: High (global state setup/teardown)
Code Quality:
- Hidden dependencies: Everywhere
- Coupling: Tight (everything to env.Configs)
- Flexibility: Low (can't swap implementations)
```
### After Refactoring
```
Global Accesses:
- Entry points only: 2 locations (SetupOrderHandler, SetupUserHandler)
- Deep code: 0 global accesses
Testability:
- Clean testable types: 3 islands (NATSClient, OrderService, UserService)
- Test coverage: 100% on clean types
- Parallel tests: Fully supported
- Test complexity: Low (simple dependency injection)
Code Quality:
- Hidden dependencies: Eliminated
- Coupling: Loose (injected dependencies)
- Flexibility: High (easy to swap implementations)
Improvement:
- 90% reduction in global access points (20 → 2)
- 3 new testable types created
- 100% of business logic now testable
```
## Key Lessons
### 1. Incremental is Better Than Perfect
**DON'T**:
```go
// ❌ Try to eliminate ALL globals at once
// This is overwhelming and error-prone
```
**DO**:
```go
// ✅ One type at a time, one level at a time
// Step 1: Extract NATSClient (week 1)
// Step 2: Extract OrderService (week 2)
// Step 3: Continue gradually
```
### 2. Bottom-Up Approach Works
**Start at the leaf** (furthest from main):
1. Identify deepest usage (`messaging.PublishEvent`)
2. Extract clean type (`NATSClient`)
3. Push global up one level (`OrderService`)
4. Repeat until reaching entry points
### 3. Pragmatic Stopping Point
**Acceptable to have globals at**:
- `main()` function
- HTTP handler setup
- Application initialization
- Top-level factory functions
**NOT acceptable deep in**:
- Business logic types
- Data access types
- Service layer
- Utility functions
### 4. Islands of Clean Code
Each extracted type is an "island":
- **Fully testable** in isolation
- **No global dependencies**
- **Explicit dependencies** via constructor
- **Can be tested in parallel**
- **Easy to understand** (dependencies visible)
### 5. Real Testability
**Before**: "We have 80% test coverage"
(But all tests depend on global state mutation)
**After**: "We have 95% test coverage"
(All tests use clean dependency injection, run in parallel)
Real testability means:
- ✅ No global state manipulation
- ✅ Tests run in parallel
- ✅ Tests are isolated
- ✅ Easy to test edge cases
- ✅ Can use real implementations (no mocking)
## When to Apply This Pattern
**Apply dependency rejection when**:
- ✅ `noglobals` linter fails
- ✅ Global config accessed throughout codebase
- ✅ Testing requires global state mutation
- ✅ Parallel tests fail due to shared state
- ✅ Code has hidden dependencies
**DON'T apply to**:
- ❌ Loggers (`slog`, `zerolog`) - designed to be global
- ❌ Constants/enums - these are fine as globals
- ❌ Read-only singletons with no state
## Refactoring Progression Example
```
Iteration 1 (Week 1):
├─ Extract NATSClient
├─ Global accesses: 20 → 14
└─ Testable types: 1
Iteration 2 (Week 2):
├─ Extract OrderService
├─ Global accesses: 14 → 8
└─ Testable types: 2
Iteration 3 (Week 3):
├─ Extract UserService
├─ Global accesses: 8 → 4
└─ Testable types: 3
Iteration 4 (Week 4):
├─ Push to handlers
├─ Global accesses: 4 → 2
└─ Mission accomplished! ✅
```
**Note**: Each iteration is a working, tested, deployable state. No "big bang" refactoring needed.
## Conclusion
Dependency rejection is about **gradual improvement**, not perfection:
- Start at the bottom (leaf code)
- Create one clean island at a time
- Push globals up one level per iteration
- Stop when globals are only at entry points
- Every step improves testability
**The goal isn't zero globals** - it's **globals only where they belong** (entry points), with all business logic cleanly injectable and testable.