Go Modernization Gotchas#
The opnDossier project is a Go CLI tool for converting OPNsense firewall configurations to documentation. Built with Go 1.26+, it adopts modern Go features including iterator-based APIs, enhanced pointer literal syntax, and automated modernization tooling. This article documents the specific gotchas, patterns, and best practices discovered during the project's modernization journey.
Go 1.26 introduces several language improvements that can improve code quality and performance, but their adoption comes with subtleties that are not immediately obvious. The opnDossier codebase serves as a practical example of navigating these modernization challenges, from choosing when to use strings.SplitSeq over strings.Split, to handling conflicts between modernization tools and linters, to establishing shared constant patterns for validation.
This knowledge base article captures project-specific lessons learned while modernizing to Go 1.26, providing software engineers with concrete examples and actionable guidance for their own modernization efforts.
Go 1.26 Version Requirements#
The project requires Go 1.26+ with toolchain go1.26.0. This requirement is enforced through:
- go.mod declaration:
go 1.26 - Development setup via
just installprerequisite checks - CI/CD pipeline validation
All code, documentation, and examples must be compatible with Go 1.26 standards.
Iterator APIs: strings.SplitSeq#
Overview#
strings.SplitSeq is a Go 1.24+ iterator-based string splitting function included in Go 1.26. It returns an iterator instead of allocating a slice, offering memory efficiency benefits when processing large strings or when early termination is possible.
When to Use SplitSeq vs Split#
Use strings.SplitSeq when:
- Iterating through split results with possible early termination
- Processing large strings where memory allocation matters
- Aligns with project goal of "memory-efficient handling of large configuration files"
Use strings.Split when:
- You need the full slice of results
- Slice semantics are clearer for the use case
- Random access to split results is required
Code Examples#
Hostname Validation#
From internal/processor/validate_helpers.go:
func isValidHostname(hostname string) bool {
if hostname == "" || len(hostname) > constants.MaxHostnameLength {
return false
}
for part := range strings.SplitSeq(hostname, ".") {
if part == "" || !hostnamePattern.MatchString(part) {
return false
}
}
return true
}
This validates each dot-separated label of a hostname without allocating a slice, and can exit early on the first invalid part.
Plugin String Parsing#
From internal/plugins/firewall/firewall.go:
for plugin := range strings.SplitSeq(device.System.Firmware.Plugins, ",") {
if strings.EqualFold(strings.TrimSpace(plugin), autoConfigBackupPackage) {
return checkResult{Result: true, Known: true}
}
}
This parses a comma-separated plugin list and returns immediately when the target package is found.
JSON Log Line Processing#
From internal/logging/logger_test.go:
if tt.format == testFormatJSON {
lines := strings.SplitSeq(strings.TrimSpace(output), "\n")
for line := range lines {
if line == "" {
continue
}
// ... validate JSON
}
}
This processes log output line-by-line for JSON validation in tests without allocating a full slice of lines.
Adoption Status#
The opnDossier codebase uses SplitSeq in 4 locations, showing pragmatic mixed usage: SplitSeq where iterator benefits apply, Split where slice semantics are necessary.
new() Syntax for Pointer Values#
Overview#
Go 1.26+ supports the new(expr) syntax for creating pointers to literal values. The opnDossier project uses this extensively (46 occurrences across test files) to replace helper functions like StringPtr().
Why *string Is Required#
The schema.Source and schema.Destination types use *string for the Any field to distinguish XML element presence from absence:
<any/>(self-closing) →*stringpointing to""(non-nil)<any>1</any>→*stringpointing to"1"(non-nil)- absent element →
nil
This is necessary because Go's encoding/xml produces "" for both self-closing tags and absent elements when using plain string fields.
Code Examples#
Creating *string Pointers for Source/Destination#
From internal/schema/security_test.go:
{name: "empty string any", src: Source{Any: new("")}, want: true},
{name: "non-empty any", src: Source{Any: new("1")}, want: true},
Validation Test Cases#
From internal/validator/opnsense_test.go:
{
Type: "pass",
Interface: schema.InterfaceList{"lan"},
Source: schema.Source{Any: new("")},
},
Official Guidance#
// Good — Go 1.26+ new(expr) syntax
src := Source{Any: new(""), Network: new("lan")}
// Legacy — StringPtr helper (still available in model package)
src := Source{Any: model.StringPtr(""), Network: model.StringPtr("lan")}
Legacy Helper Function#
The StringPtr() helper function in internal/model/security.go remains for backward compatibility:
// StringPtr returns a pointer to the given string value.
// Convenience helper for constructing Source/Destination literals
// with the *string Any field. Prefer new(expr) in Go 1.26+.
func StringPtr(s string) *string {
return new(s)
}
Note that the helper itself now internally uses new(s) rather than &s, showing the migration path.
Modernize Tool Integration#
Overview#
The opnDossier project integrates golang.org/x/tools modernize tool for automated Go 1.26+ compatibility checks and fixes.
Available Commands#
# Apply modernization fixes
just modernize
# Check for modernization opportunities (dry-run)
just modernize-check
These commands run:
go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix -test ./...
Integration Points#
just formatcallsjust modernizeafter running golangci-lintjust lintcallsjust modernize-checkafter linting- CI/CD pipeline includes modernize checks
Primary Modernize Check: omitempty on Struct Fields#
From AGENTS.md linter troubleshooting table:
| Linter | Issue | Fix |
|---|---|---|
modernize | omitempty on struct fields | Remove omitempty from JSON tags on struct-typed fields (no effect in encoding/json); YAML omitempty is fine |
JSON struct tags on nested struct fields must NOT use
omitempty(Go 1.26+ modernize check)
This applies specifically to the internal/model/common/ directory.
Additional Modernization Patterns#
The modernize tool has been used for:
- Modernizing benchmark loops using
b.Loop()for Go 1.24+ compatibility
Critical Gotcha: //go Directive Conflict#
The Problem#
The modernize tool with -fix flag auto-applies Go idiom upgrades but adds //go:fix inline directives that must be removed afterward.
# Modernization (Go 1.26+)
go run golang.org/x/tools/go/analysis/passes/modernize/cmd/modernize@latest -test -fix ./...
# Note: remove //go:fix inline directives afterward (conflicts with gocheckcompilerdirectives)
Why It Conflicts#
The gocheckcompilerdirectives linter is enabled in the project's .golangci.yml configuration. This linter validates that compiler directives are well-formed and recognized by the Go compiler.
The //go:fix directive is not a valid Go compiler directive, so gocheckcompilerdirectives flags it as an error.
Solution#
Manual cleanup required: After running modernize -fix, you must:
- Review all changes made by the tool
- Search for and remove all
//go:fixdirectives - Run linters to verify no directive conflicts remain
Current Status#
No //go:fix directives exist in the current codebase, indicating the project has successfully adopted this workflow.
Shared Validation Constants Pattern#
Overview#
The internal/constants/constants.go file contains shared constants used across the opnDossier application, including validation whitelists that serve as a single source of truth for allowed configuration values.
Validation Whitelists#
ValidOptimizationModes#
// ValidOptimizationModes defines the allowed system optimization modes.
// Shared by processor and validator packages — single source of truth.
var ValidOptimizationModes = map[string]struct{}{
"normal": {},
"high-latency": {},
"aggressive": {},
"conservative": {},
}
ValidPowerdModes#
// ValidPowerdModes defines the allowed powerd power modes.
// Shared by processor and validator packages — single source of truth.
var ValidPowerdModes = map[string]struct{}{
"hadp": {},
"hiadp": {},
"adaptive": {},
"minimum": {},
"maximum": {},
}
Both use map[string]struct{} for efficient O(1) membership checking.
Usage in Processor Package#
From internal/processor/validate.go:
ValidOptimizationModes (line 80):
if _, ok := constants.ValidOptimizationModes[s.Optimization]; !ok {
errors = append(errors, ValidationError{Field: "system.optimization", Message: "invalid optimization value"})
}
ValidPowerdModes checks for three power mode fields:
- Line 99: Validates
PowerdACMode - Line 105: Validates
PowerdBatteryMode - Line 114: Validates
PowerdNormalMode
Usage in Validator Package#
From internal/validator/opnsense.go:
ValidOptimizationModes with detailed error messages:
if _, ok := constants.ValidOptimizationModes[system.Optimization]; !ok {
errors = append(errors, ValidationError{
Field: "system.optimization",
Message: fmt.Sprintf("optimization '%s' must be one of: %v",
system.Optimization,
slices.Sorted(maps.Keys(constants.ValidOptimizationModes))),
})
}
ValidPowerdModes with sorted list for user-friendly errors:
- Line 125: Creates sorted list
- Lines 128, 137, 146: Validates AC, Battery, and Normal modes
Benefits of This Pattern#
- Single Source of Truth: Explicitly documented in comments
- Prevents Duplication: No risk of validation logic diverging between packages
- Efficient Membership Checking:
map[string]struct{}provides O(1) lookups - Easy Maintenance: Single location to update when valid values change
- Consistency: Both packages use the same validation rules
Related Topics#
- Go 1.26 Language Features: Iterator-based APIs, enhanced literal syntax
- Code Modernization: Automated tooling for Go version upgrades
- Linter Configuration: Managing conflicts between tools and linters
- Validation Patterns: Centralized constant management for consistency
- XML Parsing: Pointer semantics for distinguishing element presence
- Memory Efficiency: Iterator patterns for large data processing
Relevant Code Files#
| File Path | Purpose | Key Content |
|---|---|---|
| go.mod | Go version declaration | go 1.26, toolchain go1.26.0 |
| internal/constants/constants.go | Shared validation constants | ValidOptimizationModes, ValidPowerdModes |
| internal/processor/validate.go | Processor validation | Uses shared constants for validation |
| internal/validator/opnsense.go | Validator validation | Uses shared constants with detailed errors |
| internal/processor/validate_helpers.go | Validation helpers | strings.SplitSeq for hostname validation |
| internal/plugins/firewall/firewall.go | Plugin parsing | strings.SplitSeq for plugin list parsing |
| internal/schema/security.go | XML schema types | *string for distinguishing XML presence |
| internal/schema/security_test.go | Schema tests | new("") syntax examples (46 occurrences) |
| internal/model/security.go | Legacy helpers | StringPtr() helper for backward compatibility |
| justfile | Development automation | modernize and modernize-check commands |
| .golangci.yml | Linter configuration | gocheckcompilerdirectives linter enabled |
| AGENTS.md | Development standards | Modernization patterns and guidance |
| CHANGELOG.md | Project history | Modernize tool integration history |