go-hexagonal/skills/go-hexagonal/SKILL.md
Use this skill when working on Go projects, creating new Go services, adding features to Go applications, designing Go package structure, implementing repository patterns, structuring layered Go architectures, or discussing Go application design. Applies to any Go development following a pragmatic layered architecture with clean separation between domain, service, storage, and interface layers.
npx skillsauth add egravert/egravert-marketplace Go Layered ArchitectureInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Opinionated, pragmatic guide for building Go applications using a layered architecture inspired by hexagonal (ports and adapters) principles. This is not a pure hexagonal implementation — it intentionally flattens the ports-and-adapters model into a straightforward layered structure where dependencies flow inward toward the domain core. The goal is clarity and navigability over architectural purity: each layer has clear responsibilities and boundaries without the extra indirection that strict hexagonal architecture introduces.
Interface (CLI/TUI/Web) → Service → Domain ← Storage
↑
Adapters (external APIs)
project/
├── cmd/
│ ├── cli/ # urfave/cli entry point
│ ├── tui/ # bubbletea entry point
│ └── web/ # chi router entry point
├── internal/
│ ├── domain/ # Entities, value objects, repository + client interfaces
│ ├── service/ # Business logic, validation, orchestration
│ ├── storage/ # Repository implementations (wraps sqlc)
│ │ └── db/ # sqlc-generated code (do not edit)
│ ├── <client>/ # External API adapters (e.g., bgg/, stripe/)
│ ├── cli/ # CLI command handlers
│ ├── tui/ # Bubbletea models and views
│ └── web/ # Chi handlers, middleware, templates
├── db/
│ ├── migrations/ # goose SQL migrations
│ └── queries/ # sqlc SQL query files
├── sqlc.yml
└── go.mod
The domain layer is the innermost ring. It has zero external dependencies — no framework imports, no struct tags, no database concerns. Include a doc.go with a glossary of key business concepts so that new contributors (human or agent) can understand the domain language.
Pure Go structs representing business concepts:
type Boardgame struct {
ID int64
Name string
Players Optional[Range]
Complexity Optional[uint8]
Description string
}
json, db, or yaml struct tagsOptional[T] for nullable fields instead of pointers or sql.Null typesEnforce invariants through construction:
type Range struct {
min uint16 // private fields
max uint16
}
func Between(min, max uint16) (Range, error) {
if min == 0 || max == 0 {
return Range{}, errors.New("min and max must be greater than zero")
}
if max < min {
return Range{}, errors.New("max must be >= min")
}
return Range{min: min, max: max}, nil
}
Min(), Max(), IsSingle()(T, error) when validation is neededGeneric wrapper replacing *T and sql.NullXxx in the domain:
type Optional[T any] struct {
value T
ok bool
}
func Some[T any](v T) Optional[T] { return Optional[T]{value: v, ok: true} }
func None[T any]() Optional[T] { return Optional[T]{} }
func (o Optional[T]) IsSet() bool { return o.ok }
func (o Optional[T]) Value() T { return o.value }
func (o Optional[T]) OrElse(v T) T { if o.ok { return o.value }; return v }
Define sentinel errors in domain for conditions every layer must distinguish — most importantly "not found". Storage translates driver errors into these sentinels; handlers map them to interface-specific responses. This keeps database/sql out of the service and interface layers:
// internal/domain/errors.go
var ErrNotFound = errors.New("not found")
Storage translates sql.ErrNoRows into domain.ErrNotFound (see references/database.md); web handlers branch on errors.Is(err, domain.ErrNotFound) to return 404 vs 500 (see references/packages.md). Without the sentinel, "not found" and "server error" collapse into a single opaque error and the interface layer can't tell them apart.
Defined in domain — not at point of consumption. Repository interfaces are the shared contract between service (consumer) and storage (implementor). Both layers depend inward on domain; neither should import the other. Moving these to the service package would force Storage → Service, breaking the dependency flow.
type BoardgameRepository interface {
Create(ctx context.Context, game Boardgame) (Boardgame, error)
GetByID(ctx context.Context, id int64) (Boardgame, error)
List(ctx context.Context) ([]Boardgame, error)
}
Create returns the entity with server-generated fields (ID) populatedreferences/testing.md)The same dependency inversion pattern applies to any external dependency the service needs — not just databases. When a service calls an external API (e.g., BoardGameGeek, Stripe, a weather service), define the interface in domain using domain types. The external client lives behind an adapter that translates between the API's types and domain types.
// internal/domain/game_lookup.go
// GameLookup searches an external catalog for boardgame metadata.
// The service depends on this interface; the BGG adapter implements it.
type GameLookup interface {
SearchGames(ctx context.Context, query string) ([]Boardgame, error)
GetGameDetails(ctx context.Context, externalID string) (Boardgame, error)
}
The adapter wraps the external client and translates its types into domain types — the same pattern as storage repositories:
// internal/bgg/adapter.go
type Adapter struct {
client *bgg.Client
}
func (a *Adapter) SearchGames(ctx context.Context, query string) ([]domain.Boardgame, error) {
results, err := a.client.Search(ctx, query)
if err != nil {
return nil, fmt.Errorf("searching BGG for %q: %w", query, err)
}
games := make([]domain.Boardgame, len(results))
for i, r := range results {
games[i] = gameFromBGGResult(r)
}
return games, nil
}
Without this pattern, the service imports the external client's types directly, coupling business logic to a specific API. The adapter keeps the service testable (fake the domain interface) and swappable (replace BGG with another catalog).
Services contain business logic, validation, and multi-repository orchestration. They depend only on domain interfaces.
type BoardGameService struct {
logger *slog.Logger
repo domain.BoardgameRepository
}
func NewBoardGameService(repo domain.BoardgameRepository, logger *slog.Logger) *BoardGameService {
return &BoardGameService{repo: repo, logger: logger}
}
When inner work in a multi-aggregate transaction carries real business rules (validation, counters, audit) — not just a bare write — factor that logic into a method that operates on a repository passed in, rather than the service's own field. The method then runs identically whether called standalone or inside another service's transaction closure, and never needs to know which:
// ConsumeTx enforces invitation rules (max-uses, expiry) against the supplied
// repo. The caller decides whether that repo is tx-bound; this method does not
// know or care, which is exactly why it composes inside RunRegistrationTx.
func (s *InvitationService) ConsumeTx(ctx context.Context, repo domain.InvitationRepository, code string, userID int64) error {
inv, err := repo.GetByCode(ctx, code)
if err != nil {
return fmt.Errorf("loading invitation %q: %w", code, err)
}
if inv.Uses >= inv.MaxUses {
return fmt.Errorf("invitation %q exhausted: %w", code, ErrInvitationExhausted)
}
return repo.Use(ctx, code, userID)
}
The owning service exposes a normal Consume(ctx, ...) wrapper that supplies its own repo for standalone use, and the tx-driving service calls ConsumeTx(ctx, repos.Invitations(), ...) inside its closure. Owner of the invariant owns the transaction; services parameterize over their store so the transaction owner can inject a tx-bound repo. See references/database.md, "Transactions", for the full pattern and the decision rule on when a closure needs a XxxTx method versus a bare repo write.
Implements domain repository interfaces by wrapping sqlc-generated code. This is the only layer that knows about database types.
Every repository method translates between domain.Entity and db.Entity:
func (r *BoardgameRepository) GetByID(ctx context.Context, id int64) (domain.Boardgame, error) {
result, err := r.queries.GetBoardgameByID(ctx, id)
if err != nil {
return domain.Boardgame{}, err
}
return gameFromResult(result), nil
}
Helper functions handle type mapping:
sql.NullInt64 ↔ domain.Optional[T]sql.NullString ↔ domain.Optional[string]domain.Optional[domain.Range]See references/database.md for complete sqlc workflow and translation patterns.
Each interface (CLI, TUI, Web) is a thin adapter. It parses input, calls services, and formats output. It never contains business logic.
Handlers define narrow interfaces at point of consumption — in the handler file or its test file, not in a shared package:
// internal/web/handlers/boardgame_handler.go
type BoardGameService interface {
ListGames(context.Context) ([]domain.Boardgame, error)
FindGame(context.Context, int64) (domain.Boardgame, error)
AddGame(ctx context.Context, bg domain.Boardgame) (domain.Boardgame, error)
}
This enables focused testing — fakes only need to implement the methods the handler actually uses.
Why this differs from repository interfaces: Handler → Service is a one-directional dependency — no third party implements the interface across a layer boundary. Repository interfaces live in domain because they're a shared contract between service (consumer) and storage (implementor), enabling dependency inversion. Service interfaces for handlers have no such constraint.
| Layer | Pattern |
|-------|---------|
| Domain | Return error from constructors when invariants fail |
| Service | Validate inputs, wrap errors with identifying context: fmt.Errorf("adding session for game %d: %w", gameID, err) |
| Storage | Wrap database errors with operation and entity context: fmt.Errorf("getting game %d: %w", id, err) |
| Web | Map errors to HTTP status codes (400/404/500), log with slog.Error |
| CLI | Map errors to user-facing messages and exit codes |
Standards that apply across all layers for consistency and correctness.
Write comments that explain why, not what. Never describe what code does — agents and humans can read the code. Instead, document:
// Bad — describes what the code does
// GetOrder retrieves an order by ID from the database
// Good — explains the design decision
// GetOrder retrieves an order and its full item tree. Items are denormalized
// at placement time because menu item IDs are unstable across published
// versions — we cannot look them up later.
Every package should have a doc.go explaining its purpose, key concepts, and important design decisions. Domain packages should include a glossary of business terms:
// Package domain defines the core business entities for a boardgame tracker.
//
// Glossary:
// - Boardgame: a tabletop game with metadata (player count, complexity)
// - Session: a recorded play of a boardgame with players and date
// - Play: alias for Session in user-facing contexts
package domain
Always wrap errors with enough context to trace causation — include relevant IDs and values, not just the operation name:
// Good — an agent or operator can trace this immediately
return domain.Session{}, fmt.Errorf(
"creating session for game %d with %d players: %w", gameID, len(players), err)
// Good — includes the conflicting value
return domain.Boardgame{}, fmt.Errorf(
"game name %q already exists: %w", game.Name, err)
// Bad — bare error with no context for debugging
return domain.Review{}, err
// Bad — operation name only, no identifying information
return domain.Review{}, fmt.Errorf("adding review: %w", err)
Each layer adds its own context. A fully wrapped error reads as a chain of operations:
creating session for game 42 with 3 players: inserting session: UNIQUE constraint failed
└── service └── repository └── database
Use unique, specific names that can be found with a single search. Prefer GameStatusUnplayed over Unplayed, OrderMatchScore over Score. If a function is called, there must be a greppable call site — avoid string-based dispatch and dynamic method resolution.
Known size: Use make([]T, len(results)) with index assignment — avoids repeated grow/copy from append:
games := make([]domain.Boardgame, len(results))
for i, r := range results {
games[i] = gameFromResult(r)
}
return games, nil
Unknown size: Use make([]T, 0) with append — signals intent to build up incrementally:
reviews := make([]domain.Review, 0)
for _, r := range results {
if r.Rating > 3 {
reviews = append(reviews, reviewFromResult(r))
}
}
return reviews, nil
On error: Return nil for the slice — signals the result is meaningless and should not be used:
if err != nil {
return nil, fmt.Errorf("listing reviews for game %d: %w", gameID, err)
}
| Scenario | Slice value | Error value |
|----------|------------|-------------|
| Success, known size | make([]T, len(n)) | nil |
| Success, unknown size | make([]T, 0) + append | nil |
| Error | nil | fmt.Errorf("context: %w", err) |
| Struct + error | zero value T{} | fmt.Errorf("context: %w", err) |
Use koanf with layered precedence (lowest to highest):
~/.config/<app>/config.yaml)APPNAME_*)k := koanf.New(".")
k.Load(confmap.Provider(defaults, "."), nil)
k.Load(env.Provider("ROLLCALL_", ".", transformKey), nil)
return Config{Addr: k.String("addr"), DBPath: k.String("db_path")}
Critical rule: Load config in cmd/*/main.go, extract into a plain struct, pass values to internal layers. Never pass the koanf instance deeper than main.
Use slog (standard library) throughout:
cmd/*/main.goNewService(repo, logger)slog.Warn("parse failed", "id", id, "error", err)internal/domain/ (pure Go structs)db/migrations/db/queries/ with sqlc annotationsinternal/domain/go generate ./... (generates sqlc code)internal/storage/ (wrap sqlc, translate types):memory: SQLiteinternal/service/internal/cli/, internal/tui/, or internal/web/cmd/*/main.gogo generate ./... # Runs sqlc
sqlc generate # SQL queries only
All generated code lives in internal/storage/db/ and must never be manually edited.
references/packages.md — Detailed usage patterns for each package (urfave/cli, chi, bubbletea, koanf, slog, templ, HTMX)references/testing.md — Testing strategy, hand-written fakes, testify usage, handler-first test structurereferences/database.md — sqlc workflow, goose migrations, repository translation patterns, SQL conventionsConsult reference files for package-specific syntax and detailed examples.
tools
This skill should be used when the user asks to "fossil commit", "fossil branch", "fossil merge", "fossil clone", "fossil sync", "fossil ticket", "fossil stash", "fossil timeline", mentions working with a Fossil repository, asks about Fossil vs Git differences, or needs help with Fossil SCM commands and workflows.
development
# Agentic Practices Installer Install agentic engineering practices into the current project's CLAUDE.md as permanent coding standards. ## Procedure Follow these steps exactly when this skill is invoked: ### Step 1: Identify available languages List the files in this skill's `references/` directory matching the pattern `agentic-*-practices.md`. Extract the language name from each filename (e.g., `agentic-go-practices.md` → "Go"). These are the supported languages. ### Step 2: Ask the user
development
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
development
Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.