50 KiB
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
// 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:
- Fat Function Anti-Pattern - All logic crammed into one function (48 lines, complexity 12)
- Hidden Side Effects -
parseIP4/parseIP6names hide mutation - Mixed Abstraction Levels - Combines low-level iteration with high-level business logic
- No Leaf Types - All logic lives in methods, nothing is extracted to testable types
- Flow Control Complexity - Nested ifs, continues, boolean flags tracking state
- Poor Testability - Must mock
net.Interfaceto 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
// 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:
- Collects IP addresses from interface (LOGIC)
- Validates them (LOGIC)
- 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:
IPConfigis 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:
- Get addresses from interface
- Collect them into IPConfig
- 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 -
upsertIfaceAddrHostreads 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 -
IPConfigcan be fully unit tested without mocks - Testable in isolation:
// 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
- Type Extraction → Created
IPConfigleaf type for IP collection - Storifying → Top-level reads: collect → validate → align
- Honest Naming →
align*instead ofparse*reveals mutation - Single Responsibility → Each function does ONE thing
- Early Returns → Replaced
continuewithreturnfor 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
CIDRPresencewrapper, used private fields instead
Code Smells Identified
- Misleading Name -
validateCIDR()doesn't validate - it mutates! Should returnboolorerrorif validating - Primitive Obsession (CRITICAL) - Operating on raw
[]string, manual parsing everywhere, no encapsulation - Mixed Abstraction Levels - Jumps between string splitting (
strings.SplitN) and business logic (isClusterCIDRSet) - Boolean Flags Tracking State - Two booleans tracking related information instead of domain type
- Switch Statement Duplication - Three nearly identical switch cases (IPv4/IPv6/dual) differing only in data values
- Fat Function - 60 lines doing: parse + detect + construct + mutate
- 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
// 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 ❌)
// 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:
- ❌ 8 lines of code for a trivial wrapper
- ❌ One method that just unwraps:
return bool(p) - ❌ No type safety - still just a bool underneath
- ❌ Not more readable - compare:
config.ClusterCIDR.IsSet()(with wrapper)config.ClusterCIDRSet(with good naming)
- ❌ No validation, no logic, no invariants - pure ceremony
- ❌ 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:
// ✅ 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)
// 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
// 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
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
[]stringeasily: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:
- Parse existing arguments → Belongs in
K3SArgs.ParseCIDRConfig() - Track which CIDRs exist → Needs domain type:
CIDRConfig - Determine defaults based on IP version → Needs config type:
IPVersionConfig - 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
var isClusterCIDRSet bool
var isServerCIDRSet bool
if isClusterCIDRSet && isServerCIDRSet { return }
After: Domain model with query method
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
// ❌ Over-abstraction!
type CIDRPresence bool
func (p CIDRPresence) IsSet() bool { return bool(p) }
type CIDRConfig struct {
ClusterCIDR CIDRPresence
ServiceCIDR CIDRPresence
}
Questions to ask:
- Does
CIDRPresenceadd meaningful methods? → NO (just.IsSet()which unwraps) - Does it enforce invariants? → NO (still just a bool)
- Does it need controlled mutation? → YES! (should only be set by parser)
- Is
.ClusterCIDR.IsSet()clearer than.ClusterCIDRSet()? → NO!
→ Decision: Don't create CIDRPresence wrapper. Instead, use private fields for controlled mutation:
// ✅ 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()vsClusterCIDR.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
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:
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:
// 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 fieldCIDRConfig: Domain model with private fields for safetyIPVersionConfig+DefaultCIDRValues: CIDR value generation
- Clear separation - Parsing vs Detection vs Value Generation vs Orchestration
- Type alias pattern -
K3SArgsas type alias enables direct use in config structs with JSON serialization - Avoided over-abstraction - Rejected
CIDRPresencewrapper, 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:
if existing.AreBothSet() { return } if !existing.ClusterCIDR.IsSet() { /* ... */ } - Single abstraction level - Main function operates entirely at HIGH level
Testability
- All leaf types testable independently:
// 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()vsconfig.ClusterCIDRSet()honestly- Answer: Good naming is just as clear as method call
- Lesson: Not every primitive needs a type
Refactoring Patterns Applied
- Replace Primitive with Domain Type (Type Alias Pattern) → Created
K3SArgstype alias for[]string(usable in config fields) - Extract Multiple Leaf Types → Created 3 leaf types (
K3SArgs,CIDRConfig,IPVersionConfig) instead of one complex function - Storifying → Main function reads: create config → convert → append → store (all at same abstraction level)
- Replace Boolean Flags with Domain Model →
isClusterCIDRSet, isServerCIDRSet→CIDRConfigwith private fields and query methods - Eliminate Switch Duplication → Extracted value generation to
DefaultCIDRValues, eliminated 3 duplicate cases - Introduce Parameter Object → Created
IPVersionConfigto pass related configuration together - Query Method Pattern →
AreBothSet(),ClusterCIDRSet(),ServiceCIDRSet()read like English questions - Avoid Over-Abstraction → Rejected
CIDRPresencewrapper, 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:
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()vsconfig.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:
type CIDRConfig struct {
ClusterCIDRSet bool // Public
ServiceCIDRSet bool // Public
}
Why we used private fields:
- ✅ Compiler enforces that only
ParseCIDRConfigcan 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()andserviceCIDRValue()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:
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
// ❌ 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
K3SArgswith real behavior) - ✅ Avoided over-abstraction (rejected
CIDRPresencewrapper) - ✅ 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:
- Does it have >1 meaningful method with logic? (Not just unwrapping)
- Does it enforce invariants or validation?
- Does it need controlled mutation? (Use private fields, not wrappers)
- Is the method call significantly clearer than good naming?
- 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.NATsAddressenv.Configs.DBHostenv.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
// 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.NATsAddressused in 12 placesenv.Configs.DBHostused 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:
// ✅ 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:
// ❌ 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):
// ✅ 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
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
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:
// ❌ Try to eliminate ALL globals at once
// This is overwhelming and error-prone
DO:
// ✅ 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):
- Identify deepest usage (
messaging.PublishEvent) - Extract clean type (
NATSClient) - Push global up one level (
OrderService) - 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:
- ✅
noglobalslinter 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.