thio.dev
[CASE STUDY][2026-09-12] 2 MIN READ

Hexagonal Architecture in Go: Value Objects and Ports That Keep GORM Out of the Domain

A Go reference service where the domain owns its rules and its ports: validated Email and Username value objects, a Repository interface the GORM adapter implements, and storage errors translated before they reach business code.

#golang#hexagonal-architecture#domain-driven-design#value-objects#refactoring

Context

In many Go services the "service" layer is welded to the database library. Business functions take GORM models, check gorm.ErrRecordNotFound, and pass emails and usernames around as plain strings, so validation ends up scattered across handlers and services. Changing the schema, swapping the persistence library, or testing a business rule then means dragging the database along.

This reference implementation keeps the domain in charge: it defines its own types, its own ports, and its own errors, and the infrastructure adapts to them.

Solution

1. Value objects: invalid data can't become a user

Emails and usernames are types with constructors, not strings:

// business/user/user.go
type Email string

func NewEmail(e string) (Email, error) {
	e = strings.TrimSpace(e)
	if e == "" {
		return "", ErrInvalidEmail
	}
	re := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
	if !re.MatchString(strings.ToLower(e)) {
		return "", ErrInvalidEmail
	}
	return Email(e), nil
}

NewUsername does the same for 3–20 alphanumeric characters, and NewUser builds a User only through both constructors, wrapping the reason (email validation: %w). Validation lives in one place, in the domain, instead of in every handler.

2. Ports owned by the domain

The business package declares what it offers and what it needs, and imports neither GORM nor Echo:

// business/user/interface.go
type Service interface {   // inbound port: what the API layer may call
	FindUserByID(id int) (*FindUser, error)
}

type Repository interface { // outbound port: what the domain needs from storage
	FindUserByID(id int) (*FindUser, error)
}

var ErrUserNotFound = errors.New("user not found")

3. The adapter keeps GORM at the edge

GormRepository implements Repository. The table struct with its gorm tags lives only in the adapter, rows are mapped to the domain type, and storage errors are translated before they cross the boundary:

// repository/user/user_repo.go
func (r *GormRepository) FindUserByID(id int) (*user.FindUser, error) {
	var userData UserTable
	err := r.DB.First(&userData, id).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, user.ErrUserNotFound
		}
		return nil, fmt.Errorf("database error: %w", err)
	}
	user := userData.ToUser()
	return &user, nil
}

Business code checks user.ErrUserNotFound; it never sees a GORM error.

4. Wiring in one place

app/main.go is the only file that knows every layer: it opens the database, builds NewGormDBRepository(db), passes it to NewService, and hands the service to the HTTP controller. Swapping the storage means changing this one line of wiring and writing another Repository implementation.

5. Business rules tested without a database

Table-driven tests cover NewUser (valid user, invalid email, too-short and special-character usernames, empty email), ModifyUser, and the service's FindUserByID against a mock Repository (found, ErrUserNotFound, and a database failure). They run in under a second with no MySQL: business/user is at 90.6% statement coverage.

Impact

  • One home for the rules: an invalid email or username can't produce a User through NewUser.
  • No ORM in the domain: the business package has no GORM or HTTP import; "not found" crosses the boundary as a domain error, and other storage failures are wrapped.
  • Fast feedback: business behaviour is verified with mocks, without a database or network.
  • Documented: ADR 001 records why ports and adapters were worth the extra files.

What's next

Only FindUserByID runs end to end so far. Create, update and delete will go through the same two ports, and the HTTP handler (still a stub) will map ErrUserNotFound to a 404.

Transferable Pattern

This applies to any system where:

  • Domain concepts are still primitives (string, int, float64)
  • Infrastructure errors leak into business logic
  • Tests need a running database to check a business rule