Skip to content

psaraiva/hexagonal-architecture-with-build-tags

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lab: Hexagonal Architecture with Build Tags

Language: Português

📋 About the Lab

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.

🎯 The Problem

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)
}

Traditional Approach: Factory Pattern

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()
    }
}

🚨 Side Effects

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+.

💡 The Solution: Build Tags

Build tags (build constraints) allow you to conditionally compile only the necessary code:

Lab Structure

internal/
  adapters/
    log/
      text.go       (//go:build log_default)
      zap.go        (//go:build log_zap)
  core/
    ports/
      logger.go     (interface)

Implementations with Build Tags

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
}

Compile-Time Polymorphism

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!")
}

🚀 How to Use

Build

1. Compile with Default Logger (Text)

make build-with-default-logger
# or
go build -tags=log_default -o bin/app_default ./cmd/app

2. Compile with Uber Zap

make build-with-zap-logger
# or
go build -tags=log_zap -o bin/app_zap ./cmd/app

Tests

3. Generate Mocks

make generate-mocks

Generates mocks of the Logger interface using gomock/mockgen in internal/core/ports/mocks/.

4. Run Unit Tests

make test

5. Run Tests with Coverage

make test-coverage

Generates coverage.out and coverage.html reports in the lab root.

6. Run Everything (Clean + Mocks + Coverage)

make test-all

Implementation Verification

7. Verify Implementation in Binaries

# 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

Example Output

=== Logger Implementation Analysis ===

bin/app_default_logger:
  ✓ Uses Text Logger (local)

bin/app_zap_logger:
  ✓ Uses Zap (go.uber.org/zap)

Cleanup

8. Clean Build Artifacts

make clean

Removes the bin/ folder and coverage files.

✅ Immediate Benefits

1. Reduced Binary Size

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!)

2. Zero Overhead from Unused Dependencies

  • No external library is linked if not used
  • Faster startup time
  • Lower memory usage

3. Reduced Attack Surface

  • Less code = fewer potential vulnerabilities
  • Simplified security audits
  • Dependency updates only when necessary

4. Optimized Compilation Time

  • Compiles only the necessary code
  • Fewer dependencies to download and process

⚠️ Points of Attention

1. Management Complexity

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_prometheus

Recommendation: Maintain a manageable number of variations or use automated build tools.

2. Team Maturity

  • Requires understanding of build constraints
  • Clear documentation is essential
  • Tests must cover all variations

3. CI/CD

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/app

4. Debug (Delve)

For 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 Text Logger

Debug with Zap Logger:

Debug Zap Logger

IDEs may not recognize code under build tags. Also configure .vscode/settings.json:

{
  "go.buildTags": "log_zap"
}

🏗️ Architecture

┌─────────────────────────────────────┐
│      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

🔧 Requirements

  • Go 1.25+
  • Make (optional but recommended)

📚 References

📝 License

MIT License - Feel free to use this code as an example in your projects.


🔄 Alternatives to Build Tags (out of scope for this lab)

Click to expand

Below are some recommended alternatives depending on the context:

1. Factory Pattern with Runtime Configuration

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
./app

Benefits:

  • ✅ 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.

2. Plugin System (Go Plugins)

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.so

Benefits:

  • ✅ 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.

3. Build Automation (Scripts/CI)

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
done

Benefits:

  • ✅ 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.

4. Feature Flags (LaunchDarkly, Unleash, etc.)

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.

5. Dependency Injection Container (Wire, Dig, Fx)

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.

6. Multiple Binaries (Monorepo)

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).


📊 Quick Comparison

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.

About

Hexagonal Architecture with Build Tags

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors