This lab demonstrates the use of build tags (build constraints) in Go as a solution to manage multiple adapter implementations in a Ports and Adapters (Hexagonal) architecture, avoiding the problem of unnecessary dependencies in the final binary.
In hexagonal/ports-adapters architectures, it's common to have multiple implementations of the same port (interface). For example:
type Logger interface {
Info(msg string, keysAndValues ...any)
Error(msg string, err error, keysAndValues ...any)
Debug(msg string, keysAndValues ...any)
}The traditional solution uses the Factory Pattern to choose between implementations:
func NewLogger(loggerType string) (ports.Logger, error) {
switch loggerType {
case "zap":
return newZapLogger()
case "text":
return newTextLogger()
default:
return newTextLogger()
}
}This approach has a critical side effect:
- ✅ Runtime flexibility
- ❌ All dependencies are included in the binary
- ❌ Unused libraries increase binary size
- ❌ Unnecessary dependency loading
- ❌ Increased attack surface (security)
- ❌ Longer compilation time
Practical example: Even when using only the simple text logger, the go.uber.org/zap library would be included in the final binary, unnecessarily adding ~500KB+.
Build tags (build constraints) allow you to conditionally compile only the necessary code:
internal/
adapters/
log/
text.go (//go:build log_default)
zap.go (//go:build log_zap)
core/
ports/
logger.go (interface)
text.go - Simple logger (no external dependencies):
//go:build log_default
package log
type textAdapter struct{}
func NewLogger() (ports.Logger, error) {
return &textAdapter{}, nil
}zap.go - Logger with Uber Zap:
//go:build log_zap
package log
import "go.uber.org/zap"
type zapAdapter struct {
logger *zap.Logger
}
func NewLogger() (ports.Logger, error) {
z, err := zap.NewProduction()
return &zapAdapter{logger: z}, err
}Both implementations expose exactly the same function:
func NewLogger() (ports.Logger, error)The application code remains completely agnostic of the implementation:
func main() {
logger, err := log.NewLogger()
if err != nil {
panic(err)
}
logger.Info("Application started!")
}make build-with-default-logger
# or
go build -tags=log_default -o bin/app_default ./cmd/appmake build-with-zap-logger
# or
go build -tags=log_zap -o bin/app_zap ./cmd/appmake generate-mocksGenerates mocks of the Logger interface using gomock/mockgen in internal/core/ports/mocks/.
make testmake test-coverageGenerates coverage.out and coverage.html reports in the lab root.
make test-all# See all available commands
make help
# Verify which implementation is in each binary
make verify-logger-impl
# Verify only binaries with Zap
make verify-logger-zap
# Verify only binaries with Text Logger
make verify-logger-text=== Logger Implementation Analysis ===
bin/app_default_logger:
✓ Uses Text Logger (local)
bin/app_zap_logger:
✓ Uses Zap (go.uber.org/zap)
make cleanRemoves the bin/ folder and coverage files.
| Binary | Size | Dependencies |
|---|---|---|
| app_default_logger | 2.2 MB | stdlib only |
| app_zap_logger | 5.5 MB | stdlib + zap + multierr |
Savings: ~3.3 MB or ~60% when not using Zap (binary 2.5x smaller!)
- No external library is linked if not used
- Faster startup time
- Lower memory usage
- Less code = fewer potential vulnerabilities
- Simplified security audits
- Dependency updates only when necessary
- Compiles only the necessary code
- Fewer dependencies to download and process
As the number of implementations grows, managing build tags can become complex:
# Small project: 2-3 implementations ✅
-tags=log_default
-tags=log_zap
# Large project: 10+ implementations ⚠️
-tags=log_zap,cache_redis,db_postgres,queue_rabbitmq,metrics_prometheusRecommendation: Maintain a manageable number of variations or use automated build tools.
- Requires understanding of build constraints
- Clear documentation is essential
- Tests must cover all variations
Configure the pipeline to test all relevant combinations:
strategy:
matrix:
logger: [log_default, log_zap]
steps:
- run: go test -tags=${{ matrix.logger }} ./...
- run: go build -tags=${{ matrix.logger }} ./cmd/appFor proper debugging, you need to add the build flag. The .vscode/launch.json file already has both configurations:
- Debug with Text Logger (default) - Uses
-tags=log_default - Debug with Zap Logger - Uses
-tags=log_zap
Debug with Text Logger:
Debug with Zap Logger:
IDEs may not recognize code under build tags. Also configure .vscode/settings.json:
{
"go.buildTags": "log_zap"
}┌─────────────────────────────────────┐
│ Application Layer │
│ (cmd/app/main.go) │
│ │
│ logger, err := log.NewLogger() │
└─────────────────────────────────────┘
│
│ Depends on interface
▼
┌─────────────────────────────────────┐
│ Domain Layer │
│ (internal/core/ports) │
│ │
│ type Logger interface {...} │
└─────────────────────────────────────┘
▲
│ Implements
┌─────────┴─────────┐
│ │
┌───────────────┐ ┌───────────────┐
│ text.go │ │ zap.go │
│ log_default │ │ log_zap │
│ │ │ │
│ Zero deps │ │ go.uber.org/ │
│ │ │ zap │
└───────────────┘ └───────────────┘
BUILD TAG BUILD TAG
- Go 1.25+
- Make (optional but recommended)
MIT License - Feel free to use this code as an example in your projects.
Click to expand
Below are some recommended alternatives depending on the context:
How it works: All implementations are compiled into the binary and the choice is made via environment variable or configuration file.
Usage:
export LOGGER_TYPE=zap
./appBenefits:
- ✅ Full runtime flexibility
- ✅ Easy to switch implementation without recompiling
- ✅ Ideal for environments where the same binary runs in different contexts
Disadvantages:
- ❌ All dependencies in the binary
- ❌ Larger size
- ❌ Unnecessary overhead
When to use: Cloud-native applications that need to adapt to different environments with the same binary.
How it works: Implementations are compiled as .so plugins and loaded dynamically at runtime.
Usage:
go build -buildmode=plugin -o logger_zap.so ./adapters/log/zap
./app --logger-plugin=logger_zap.soBenefits:
- ✅ Very small main binary
- ✅ Plugins can be updated independently
- ✅ Loads only what's necessary
Disadvantages:
- ❌ Significant complexity
- ❌ Doesn't work on Windows
- ❌ Version compatibility issues between plugin and app
- ❌ Slightly inferior performance
When to use: Systems that need extensibility by third parties without recompilation.
How it works: Scripts automate the generation of multiple binary variations with different build tags.
Usage:
# Script build-all.sh
for logger in default zap; do
go build -tags=log_$logger -o app_$logger ./cmd/app
doneBenefits:
- ✅ Combines benefits of build tags with automation
- ✅ Generates optimized binaries for each use case
- ✅ CI/CD tests all combinations
Disadvantages:
- ❌ Multiple artifacts to distribute
- ❌ Requires more robust build infrastructure
When to use: Products that distribute to different clients with specific needs.
How it works: Remote flags control which implementation to use, with graceful fallback.
Usage:
# Feature flag controls remotely
if featureFlag.IsEnabled("use-zap-logger") {
logger = zapLogger
} else {
logger = textLogger
}Benefits:
- ✅ Change implementation without deployment
- ✅ Gradual rollout (canary/blue-green)
- ✅ A/B testing of implementations
- ✅ Instant kill switch
Disadvantages:
- ❌ All dependencies in the binary
- ❌ Dependency on external service
- ❌ Additional latency in decision
When to use: SaaS with multiple tenants or continuous experimentation.
How it works: DI container chooses implementations based on configuration/environment.
Usage:
// Configuration determines which provider to use
container.Provide(func() Logger {
if config.LoggerType == "zap" {
return NewZapLogger()
}
return NewTextLogger()
})Benefits:
- ✅ Excellent testability
- ✅ Maximum decoupling
- ✅ Easy to mock in tests
Disadvantages:
- ❌ Learning curve
- ❌ All dependencies compiled
- ❌ Additional complexity for simple projects
When to use: Large applications with many dependencies and testability requirements.
How it works: Each variation is a completely separate binary in the same repository.
Usage:
cmd/
app-lite/ (uses text logger)
app-full/ (uses zap logger)
Benefits:
- ✅ Maximum optimization per binary
- ✅ Shared code via internal/
- ✅ Zero overhead between variations
Disadvantages:
- ❌ Duplication of initialization code
- ❌ Maintenance of multiple entrypoints
When to use: Products with different editions (Community vs Enterprise).
| Approach | Binary Size | Flexibility | Complexity | Best For |
|---|---|---|---|---|
| Build Tags | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | Maximum optimization |
| Factory Pattern | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | Cloud-native apps |
| Plugins | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ | Extensibility |
| Build Automation | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | Multi-target distribution |
| Feature Flags | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | SaaS/Experimentation |
| DI Container | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | Enterprise apps |
| Multiple Binaries | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | Product editions |
Conclusion: Build tags are a powerful tool for optimizing Go binaries when applied to ports-adapters architectures. The gains in binary size, performance, and security outweigh the additional management complexity in small to medium-sized projects. For large projects with many variations, consider build automation tools or evaluate whether runtime flexibility is more important than binary optimization.

