DEV Community

Cover image for The Dependency Rule: One Import Statement Will Tell You If Your Go Architecture Is Broken
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Dependency Rule: One Import Statement Will Tell You If Your Go Architecture Is Broken

Open your domain package. Read the imports.

package domain

import (
    "context"
    "fmt"
)
Enter fullscreen mode Exit fullscreen mode

Clean. This domain depends on nothing but standard library utilities.

Now look at this:

package domain

import (
    "context"
    "database/sql"
    "fmt"
)
Enter fullscreen mode Exit fullscreen mode

One extra line. database/sql. Your domain now depends on infrastructure. The architecture is broken.

The Rule

Inner layers must never import outer layers.

The domain defines interfaces. The adapters implement them. The domain never knows which adapter is on the other side. That's the entire rule.

  Adapter ──imports──► Domain    ✅ Correct
  Domain  ──imports──► Adapter   ❌ Broken
Enter fullscreen mode Exit fullscreen mode

What "Depends On" Means in Go

In Go, dependency is an import. Nothing else. No annotation-based DI. No classpath scanning. If package A imports package B, A depends on B.

Want to know which direction your dependencies point? Open the file. Read the imports. The compiler is your architecture linter.

How Violations Sneak In

Even experienced developers break the rule accidentally:

JSON struct tags on domain types. You add json:"customer_id" to your Order struct. Now your domain knows about serialization. That knowledge belongs in the adapter's DTO types.

sql.NullString in a domain field. "It's just one field." Now your domain imports database/sql. Every test needs the sql package. You're locked to SQL databases.

*http.Request as a domain method parameter. Your domain now depends on net/http. Can't call that method from a CLI tool or a Kafka consumer without importing the HTTP package.

ORM tags. gorm:"column:order_id" on your domain struct. Your domain depends on gorm.io. If you switch ORMs, you rewrite the domain.

Returning pq.Error from an adapter. The raw PostgreSQL error escapes into the service layer. Anyone handling it must import github.com/lib/pq.

Each seems harmless. Together they erode the boundary until your "hexagonal architecture" is just a folder structure with no enforcement.

The Correct Way

The domain defines a port. The adapter implements it. The domain never imports the adapter.

// domain/service.go — imports: context, fmt. Nothing else.
type OrderRepository interface {
    Save(ctx context.Context, order Order) error
}

type OrderService struct {
    repo OrderRepository
}

func (s *OrderService) CreateOrder(ctx context.Context, customerID string, total int64) (Order, error) {
    if customerID == "" {
        return Order{}, fmt.Errorf("customer ID is required")
    }
    order := Order{ID: generateID(), CustomerID: customerID, TotalCents: total}
    if err := s.repo.Save(ctx, order); err != nil {
        return Order{}, fmt.Errorf("saving order: %w", err)
    }
    return order, nil
}
Enter fullscreen mode Exit fullscreen mode
// adapter/memory/repository.go — imports the domain, not the other way around
type InMemoryRepository struct {
    orders map[string]domain.Order
}

func (r *InMemoryRepository) Save(_ context.Context, order domain.Order) error {
    r.orders[order.ID] = order
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The adapter knows about the domain. The domain has no idea the adapter exists. Arrow points inward.

How to Detect Violations

Read the imports. Open your domain package. If you see database/sql, net/http, encoding/json, or any ORM, fix it.

Compile the domain alone:

go build ./internal/domain/...
Enter fullscreen mode Exit fullscreen mode

If it fails because of missing infrastructure packages, you have a violation.

Automate it:

go list -f '{{.Imports}}' ./internal/domain/... | grep -E "database|net/http|encoding/json"
Enter fullscreen mode Exit fullscreen mode

If that returns anything, the architecture has a hole.

main() Is the Exception

main() imports everything. It creates adapters, injects them into services, and starts the server. That's its job — to be the one place where all layers meet so that nothing else has to.

func main() {
    repo := postgres.NewOrderRepository(db)
    svc := domain.NewOrderService(repo)
    handler := httphandler.New(svc)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

main() is the composition root, not part of any layer.


📖 The dependency rule is Chapter 7 of Hexagonal Architecture in Go, and it's the foundation everything else builds on. The book covers correct vs incorrect dependency direction with side-by-side code, how violations sneak in through five common patterns, and detection techniques including automated import checking.

Part 4 of 5. Final post: stop passing *sql.Tx through your service layer — the Unit of Work pattern in Go.

Top comments (0)

The discussion has been locked. New comments can't be added.