Clean Architecture in Go, Project Layout
TL;DR —
cmd/<service>/main.gofor 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:
domainimports: standard library,uuid,time— nothing app-specific.usecaseimports:domain, standard library,context.adapter/httpimports:usecase,domain,chi,encoding/json.adapter/postgresimports:usecase(for its port interfaces),domain,pgx.platformimports: 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 typesusecase— application operationsrepository,publisher,handler— specific adapter roleshttplog,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/andcmd/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.