background-shape
Clean Architecture in Go, Project Layout
June 6, 2022 · 5 min read · by Muhammad Amal programming

TL;DRcmd/<service>/main.go for the binary, internal/domain/, internal/usecase/, internal/adapter/<kind>/, internal/platform/ for framework boot. One package per layer. Go’s compile-time import checking enforces the dependency rule for free.

After the layers post, here’s the concrete Go layout I’m using. Not the “Standard Go Project Layout” (which is over-prescriptive). Not Uncle Bob’s diagram literally rendered into directories. Something that works.

The layout

billing/
├── cmd/
│   └── billing/
│       └── main.go              # entry point + wiring
├── internal/
│   ├── domain/
│   │   ├── subscription.go      # entity
│   │   ├── plan.go              # value object
│   │   ├── errors.go            # domain errors
│   │   └── event.go             # domain events
│   ├── usecase/
│   │   ├── start_subscription.go
│   │   ├── cancel_subscription.go
│   │   ├── port.go              # interfaces use cases depend on
│   │   └── dto.go               # input/output structs
│   ├── adapter/
│   │   ├── http/
│   │   │   ├── router.go
│   │   │   ├── handler_subscription.go
│   │   │   └── dto.go           # JSON shapes
│   │   ├── postgres/
│   │   │   ├── subscription_repository.go
│   │   │   └── customer_repository.go
│   │   └── kafka/
│   │       └── publisher.go
│   └── platform/
│       ├── config/
│       │   └── config.go
│       ├── db/
│       │   └── postgres.go
│       └── logger/
│           └── logger.go
├── migrations/
│   └── 001_create_subscriptions.up.sql
├── Dockerfile
├── go.mod
└── go.sum

Let me walk through what’s specific.

Why internal/

Go’s internal/ directory has a special property: any package under internal/foo/ can only be imported from packages rooted at the same parent. yourorg/billing/internal/domain can be imported by yourorg/billing/usecase but not by yourorg/notifications.

For Clean Architecture this matters: it stops other services from depending on your service’s internals. Want to expose something to other services? Move it out of internal/ (rare) or expose it via a shared proto/ package.

One package per layer

Three layers under internal/: domain, usecase, adapter. Then platform for framework code.

Each package has clear responsibility. The compiler enforces the dependency rule for free:

  • domain imports: standard library, uuid, time — nothing app-specific.
  • usecase imports: domain, standard library, context.
  • adapter/http imports: usecase, domain, chi, encoding/json.
  • adapter/postgres imports: usecase (for its port interfaces), domain, pgx.
  • platform imports: framework configs and drivers.

If you accidentally import adapter from usecase, the compile fails. Free architecture enforcement.

Package naming: avoid models, services, utils

Three names to avoid:

  • models — what kind of model? Domain? DB? DTO? Be specific.
  • services — too generic; Service is overloaded in Go.
  • utils — landfill package. Where dead code lives.

Names that work:

  • domain — the bounded context’s core types
  • usecase — application operations
  • repository, publisher, handler — specific adapter roles
  • httplog, httpauth — narrowly scoped middleware packages

When to split a domain across packages

For one bounded context (billing), one internal/domain package is enough. If you find yourself needing a billing service that also handles user accounts, that’s a sign of two bounded contexts. Either:

  • Split the service: cmd/billing/ and cmd/accounts/, each with their own internal/
  • Stay one service but use subpackages: internal/domain/billing/, internal/domain/accounts/

I prefer the first. Two binaries, two domains, separate.

Interfaces — where to define them

Go-specific convention worth knowing: define interfaces in the package that consumes them, not the package that implements them.

// internal/usecase/port.go — the consumer's interface
package usecase

import (
    "context"
    "github.com/google/uuid"
    "yourorg/billing/internal/domain"
)

type SubscriptionRepository interface {
    Save(ctx context.Context, s *domain.Subscription) error
    GetByID(ctx context.Context, id uuid.UUID) (*domain.Subscription, error)
}

The Postgres implementation in internal/adapter/postgres/ doesn’t declare it satisfies this interface — Go figures out structural conformance at compile time when you pass it to NewStartSubscription.

This is fundamentally different from Java/PHP where you’d declare implements. The Go pattern leads to smaller, consumer-focused interfaces. The Postgres struct doesn’t need to know about every consumer; each consumer defines what it needs.

The cmd directory

cmd/billing/main.go is the only place that imports everything. Where the actual graph gets wired:

package main

import (
    "log"
    "yourorg/billing/internal/adapter/http"
    "yourorg/billing/internal/adapter/kafka"
    "yourorg/billing/internal/adapter/postgres"
    "yourorg/billing/internal/platform/config"
    "yourorg/billing/internal/platform/db"
    "yourorg/billing/internal/usecase"
)

func main() {
    cfg, err := config.Load()
    if err != nil { log.Fatal(err) }

    pool, err := db.OpenPostgres(cfg.DatabaseURL)
    if err != nil { log.Fatal(err) }
    defer pool.Close()

    // adapters
    subRepo := postgres.NewSubscriptionRepository(pool)
    pub := kafka.NewPublisher(cfg.KafkaBrokers)

    // use cases
    startSub := usecase.NewStartSubscription(subRepo, pub, time.Now)
    cancelSub := usecase.NewCancelSubscription(subRepo, pub, time.Now)

    // HTTP
    router := http.NewRouter(startSub, cancelSub)
    if err := http.ListenAndServe(cfg.Port, router); err != nil {
        log.Fatal(err)
    }
}

50 lines, end to end. Easy to read; the whole dependency graph is visible.

For larger services with 20+ use cases, consider a wire.go file or use google/wire to codegen the wiring. For a billing service with 5-7 use cases, manual wiring is fine.

A note on pkg/

The “Standard Go Project Layout” suggests pkg/ for libraries you want to expose externally. I don’t use it. For a service, everything is internal. For a shared library, the repo IS the library — no pkg/ needed.

Skip pkg/ until you have a concrete reason.

Tests live next to code

internal/usecase/
├── start_subscription.go
└── start_subscription_test.go

Go convention. Tests in the same package as the code they test (or _test package for black-box). Don’t put tests in a separate tests/ directory at the top level; you’d lose access to package internals.

For integration tests that need real Postgres / real HTTP, a top-level tests/integration/ directory is fine. Run separately via build tags or test scripts.

Common Pitfalls

Putting interfaces with implementations. Defines them in the wrong place; pulls implementations into the consumer’s package graph. Always interface-where-consumed.

Single-file domain.go with 2000 lines. Split by concept: one file per entity or aggregate.

Mixing adapters in one package. internal/adapter/ with everything in one package gets tangled. Subpackages per adapter kind: http, postgres, kafka.

Wiring inside use cases. Use cases shouldn’t know about pgxpool. Wire in main.go; pass interface-satisfying implementations.

Importing adapter from domain. Compiler stops you, but if you find yourself wanting to, you’ve conflated concerns. Refactor.

pkg/utils/helpers.go. Landfill. Name your packages by what they do.

Wrapping Up

cmd/, internal/{domain,usecase,adapter,platform}/, one package per layer, interfaces where consumed. Go’s compile-time import rules enforce the architecture for free. Wednesday: DI without a framework — how the wiring above actually works at scale.