Agent Skill
2/7/2026

17th-go-patterns

Go project development patterns and conventions. Covers Clean Architecture, Error Handling, Testing, DI (Wire), gRPC/Connect, Repository patterns. Reference this skill before writing code to maintain consistent patterns.

S
seventeenthearth
0GitHub Stars
1Views
npx skills add SeventeenthEarth/glm-worker-mcp

SKILL.md

Name17th-go-patterns
DescriptionGo project development patterns and conventions. Covers Clean Architecture, Error Handling, Testing, DI (Wire), gRPC/Connect, Repository patterns. Reference this skill before writing code to maintain consistent patterns.

name: 17th-go-patterns description: | Go project development patterns and conventions. Covers Clean Architecture, Error Handling, Testing, DI (Wire), gRPC/Connect, Repository patterns. Reference this skill before writing code to maintain consistent patterns.

Go Development Patterns

This document defines patterns and conventions for writing consistent Go code.


1. Clean Architecture

Layer Structure

internal/feature/<feature>/
├── domain/           # Core business logic (no dependencies)
│   ├── entity/       # Domain models, error definitions
│   ├── repo/         # Repository interfaces (no implementations)
│   └── port/         # External dependency port interfaces
├── application/      # Use cases, business rules
│   ├── *_usecase.go  # UseCase interface + implementation
│   └── port/         # Application-level ports
├── data/             # Data access implementations
│   ├── repo/         # Repository implementations
│   └── adapter/      # External service adapters
└── protocol/         # Transport layer (gRPC, HTTP)
    ├── manager.go    # gRPC handlers
    └── mapper.go     # DTO ↔ Domain conversion

Dependency Direction

protocol → application → domain ← data
                ↑
              data (implements domain interfaces)
  • domain: No external package imports (only stdlib, uuid basics allowed)
  • application: Only imports domain, no infrastructure packages
  • data: Implements domain interfaces, uses sqlc/external packages
  • protocol: Calls application, uses gRPC/Connect packages

Subfeature Pattern

Internal cohesion within same feature:

internal/feature/<feature>/subfeature/<module>/

Cross-feature integration (Consumer Port implementation):

internal/feature/<provider>/subfeature/consumer/<consumer>/adapter/

Consumer defines Port (requirements), Provider implements and owns transaction.


2. Error Handling

Domain Error Structure

// domain/entity/errors.go

// Domain identifier
const DomainSpace = "space.v1"

// ErrorCode - transport-agnostic codes (1:1 mapping with gRPC codes)
type ErrorCode string

const (
    CodePermissionDenied   ErrorCode = "PERMISSION_DENIED"
    CodeFailedPrecondition ErrorCode = "FAILED_PRECONDITION"
    CodeNotFound           ErrorCode = "NOT_FOUND"
    CodeAlreadyExists      ErrorCode = "ALREADY_EXISTS"
    CodeAborted            ErrorCode = "ABORTED"
    CodeInternal           ErrorCode = "INTERNAL"
)

// Reason tokens - detailed cause identification
const (
    ReasonNotHost          = "not_host"
    ReasonSpaceFull        = "space_full"
    ReasonCASConflict      = "cas_conflict"
)

// Domain error type
type SpaceError struct {
    code         ErrorCode
    reason       string
    domain       string
    message      string
    meta         map[string]string
    precondition *PreconditionViolation
    inner        error
}

func (e *SpaceError) Error() string   { return e.message }
func (e *SpaceError) Unwrap() error   { return e.inner }
func (e *SpaceError) Code() string    { return string(e.code) }
func (e *SpaceError) Reason() string  { return e.reason }

Error Creation Pattern (Factory Functions)

// Specific factory functions per error scenario
func NotHost(spaceID, callerID uuid.UUID) *SpaceError {
    meta := map[string]string{
        "space_id":       spaceID.String(),
        "caller_user_id": callerID.String(),
    }
    msg := fmt.Sprintf("caller %s is not the host of space %s", callerID, spaceID)
    return NewSpaceError(CodePermissionDenied, ReasonNotHost, msg, meta, nil, nil)
}

func SpaceFull(spaceID uuid.UUID, max, current int32) *SpaceError {
    meta := map[string]string{
        "space_id":         spaceID.String(),
        "max_participants": fmt.Sprintf("%d", max),
    }
    violation := &PreconditionViolation{
        Type:        "capacity",
        Subject:     "space.participant_count",
        Description: fmt.Sprintf("max=%d current=%d", max, current),
    }
    return NewSpaceError(CodeResourceExhausted, ReasonSpaceFull, msg, meta, violation, nil)
}

Error Extraction Pattern

func AsSpaceError(err error) (*SpaceError, bool) {
    var target *SpaceError
    if errors.As(err, &target) {
        return target, true
    }
    return nil, false
}

Protocol Layer Error Conversion

// protocol/rpcerr/from.go
// Domain error → Connect error conversion only in protocol layer

func From(err error) *connect.Error {
    spaceErr, ok := entity.AsSpaceError(err)
    if !ok {
        return connect.NewError(connect.CodeInternal, err)
    }

    code := mapToConnectCode(spaceErr.Code())
    connectErr := connect.NewError(code, errors.New(spaceErr.Error()))

    // Add ErrorInfo detail
    if spaceErr.Reason() != "" {
        detail, _ := connect.NewErrorDetail(&errdetails.ErrorInfo{
            Reason:   spaceErr.Reason(),
            Domain:   spaceErr.Domain(),
            Metadata: spaceErr.Metadata(),
        })
        connectErr.AddDetail(detail)
    }

    return connectErr
}

3. Testing Patterns

Table-Driven Tests

func TestValidateInput(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name    string
        input   Input
        wantErr bool
        errCode ErrorCode
    }{
        {
            name:    "valid input",
            input:   Input{Name: "test"},
            wantErr: false,
        },
        {
            name:    "empty name",
            input:   Input{Name: ""},
            wantErr: true,
            errCode: CodeInvalidArgument,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            err := Validate(tt.input)

            if tt.wantErr {
                require.Error(t, err)
                spaceErr, ok := AsSpaceError(err)
                require.True(t, ok)
                assert.Equal(t, string(tt.errCode), spaceErr.Code())
            } else {
                require.NoError(t, err)
            }
        })
    }
}

Mock/Stub Pattern

// Interface-based stub
type snapshotReaderStub struct {
    snapshot *entity.SpaceSnapshot
    err      error
}

func (s *snapshotReaderStub) GetSnapshot(ctx context.Context, id uuid.UUID) (*entity.SpaceSnapshot, error) {
    if s.err != nil {
        return nil, s.err
    }
    return s.snapshot.Clone(), nil
}

// Function type-based fake (flexible behavior definition)
type fsmPortFunc func(ctx context.Context, cmd SpaceFSMCommand) (SpaceFSMResult, error)

func (f fsmPortFunc) Execute(ctx context.Context, cmd SpaceFSMCommand) (SpaceFSMResult, error) {
    return f(ctx, cmd)
}

UseCase Test Structure

func TestJoinSpaceUseCaseExecuteSuccess(t *testing.T) {
    t.Parallel()

    // Arrange - test data
    spaceID := uuid.New()
    participantID := uuid.New()
    performedAt := time.Date(2024, 10, 20, 15, 4, 5, 0, time.UTC)

    initialSnapshot := &entity.SpaceSnapshot{
        ID:           spaceID,
        StateVersion: 3,
    }

    // Arrange - Mock/Stub setup
    snapshotReader := &snapshotReaderStub{snapshot: initialSnapshot.Clone()}

    port := fsmPortFunc(func(ctx context.Context, cmd SpaceFSMCommand) (SpaceFSMResult, error) {
        if cmd.EventName != "add_participant" {
            t.Fatalf("unexpected event %s", cmd.EventName)
        }
        next := cmd.Space.Clone()
        next.StateVersion++
        return SpaceFSMResult{Snapshot: next}, nil
    })

    // Arrange - UseCase creation
    usecase := NewJoinSpaceUseCase(JoinSpaceDependencies{
        Logger:    zaptest.NewLogger(t),
        FSM:       port,
        Snapshots: snapshotReader,
        Clock:     func() time.Time { return performedAt },
    })

    // Act
    result, err := usecase.Execute(ctx, command)

    // Assert
    require.NoError(t, err)
    assert.Equal(t, spaceID, result.Space.ID)
}

mockgen Usage

//go:generate go run go.uber.org/mock/mockgen -destination=../../../mocks/mock_space_join_usecase.go -package=mocks github.com/example/project/internal/feature/space/application JoinSpaceUseCase

4. Dependency Injection (Wire)

Provider Function Pattern

// internal/infrastructure/di/providers_<feature>.go

// Single implementation provider
func ProvideSpaceRepository(exec ssql.Executor, transactor ssql.Transactor, logger *zap.Logger) spacerepo.SpaceRepository {
    if exec == nil || transactor == nil {
        return nil
    }
    return spaceDataRepo.NewRepository(exec, transactor, logger)
}

// Interface exposure (concrete → interface)
func ProvideSpaceSnapshotReader(repo *spaceDataRepo.Repository) spacerepo.SpaceSnapshotReader {
    return repo
}

// UseCase provider
func ProvideJoinSpaceUseCase(
    logger *zap.Logger,
    joiner SpaceJoiner,
    fsm SpaceFSMPort,
    snapshots spacerepo.SpaceSnapshotReader,
) application.JoinSpaceUseCase {
    return application.NewJoinSpaceUseCase(application.JoinSpaceDependencies{
        Logger:    logger,
        Joiner:    joiner,
        FSM:       fsm,
        Snapshots: snapshots,
    })
}

wire.go Configuration

//go:build wireinject
// +build wireinject

package di

import "github.com/google/wire"

var SpaceSet = wire.NewSet(
    ProvideSpaceRepository,
    ProvideSpaceSnapshotReader,
    ProvideJoinSpaceUseCase,
    // ...
)

func InitializeServerDependencies(ctx context.Context, cfg *Config) (*Dependencies, error) {
    wire.Build(
        CoreSet,
        SpaceSet,
        // ...
    )
    return nil, nil
}

wire_gen.go Regeneration

wire ./internal/infrastructure/di/

5. gRPC/Connect Handlers

Manager Structure

// protocol/manager.go

type SpaceManager struct {
    spacev1connect.UnimplementedSpaceServiceHandler
    logger  *zap.Logger
    service application.SpaceService
}

type SpaceManagerDependencies struct {
    Logger       *zap.Logger
    SpaceService application.SpaceService
}

func NewSpaceManager(deps SpaceManagerDependencies) *SpaceManager {
    logger := deps.Logger
    if logger == nil {
        logger = zap.NewNop()
    }
    return &SpaceManager{
        logger:  logger,
        service: deps.SpaceService,
    }
}

RPC Handler Pattern

func (m *SpaceManager) CreateSpace(
    ctx context.Context,
    req *connect.Request[spacev1.CreateSpaceRequest],
) (*connect.Response[spacev1.CreateSpaceResponse], error) {
    // 1. Extract Principal
    principal, ok := authctx.PrincipalFrom(ctx)
    if !ok {
        return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("authentication required"))
    }

    // 2. Request → Application Params conversion
    params := application.CreateSpaceParams{
        HostID:    principal.UserID,
        QuizSetID: req.Msg.GetQuizSetId(),
    }

    // 3. UseCase call
    result, err := m.service.CreateSpace(ctx, params)
    if err != nil {
        return nil, spacerpcerr.From(err)  // Domain error → Connect error
    }

    // 4. Result → Response conversion
    resp := &spacev1.CreateSpaceResponse{
        SpaceId: result.SpaceID.String(),
    }

    return connect.NewResponse(resp), nil
}

Mapper Pattern

// protocol/mapper.go

func MapSpaceToProto(s *entity.SpaceSnapshot) *spacev1.Space {
    if s == nil {
        return nil
    }
    return &spacev1.Space{
        Id:        s.ID.String(),
        Status:    mapStatusToProto(s.Status),
        CreatedAt: timestamppb.New(s.CreatedAt),
    }
}

func mapStatusToProto(status entity.SpaceStatus) spacev1.SpaceStatus {
    switch status {
    case entity.SpaceStatusWaiting:
        return spacev1.SpaceStatus_SPACE_STATUS_WAITING
    case entity.SpaceStatusActive:
        return spacev1.SpaceStatus_SPACE_STATUS_ACTIVE
    default:
        return spacev1.SpaceStatus_SPACE_STATUS_UNSPECIFIED
    }
}

6. Repository & sqlc

sqlc Query File Structure

internal/feature/<feature>/data/repo/sql/
├── get_<entity>.sql
├── list_<entity>.sql
├── insert_<entity>.sql
├── update_<entity>.sql
└── delete_<entity>.sql

Query Examples

-- name: GetSpace :one
SELECT id, status, state_version, created_at, updated_at
FROM spaces
WHERE id = $1;

-- name: ListSpacesByStatus :many
SELECT id, status, state_version, created_at, updated_at
FROM spaces
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;

-- name: UpdateSpaceStatus :exec
UPDATE spaces
SET status = $2, state_version = state_version + 1, updated_at = NOW()
WHERE id = $1 AND state_version = $3;

Repository Implementation

// data/repo/repository.go

type Repository struct {
    queries    *sqlc.Queries
    transactor ssql.Transactor
    logger     *zap.Logger
}

func NewRepository(exec ssql.Executor, transactor ssql.Transactor, logger *zap.Logger) *Repository {
    if logger == nil {
        logger = zap.NewNop()
    }
    return &Repository{
        queries:    sqlc.New(exec),
        transactor: transactor,
        logger:     logger,
    }
}

// Interface implementation verification
var _ spacerepo.SpaceRepository = (*Repository)(nil)

func (r *Repository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Space, error) {
    row, err := r.queries.GetSpace(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, entity.SpaceNotFound(id)
        }
        return nil, fmt.Errorf("get space: %w", err)
    }
    return mapRowToEntity(row), nil
}

Transaction Management

func (r *Repository) CreateWithParticipant(ctx context.Context, space *entity.Space, participant *entity.Participant) error {
    return r.transactor.WithTx(ctx, func(tx ssql.Executor) error {
        queries := sqlc.New(tx)

        if err := queries.InsertSpace(ctx, mapEntityToInsertParams(space)); err != nil {
            return fmt.Errorf("insert space: %w", err)
        }

        if err := queries.InsertParticipant(ctx, mapParticipantToInsertParams(participant)); err != nil {
            return fmt.Errorf("insert participant: %w", err)
        }

        return nil
    })
}

7. Naming Conventions

File Names

TypePatternExample
UseCase<action>_<entity>_usecase.gojoin_space_usecase.go
UseCase Test<action>_<entity>_usecase_test.gojoin_space_usecase_test.go
Repositoryrepository.go or <entity>_repository.gospace_repository.go
Handlermanager.go or grpc_manager.gomanager.go
Mappermapper.gomapper.go
Errorserrors.goerrors.go
SQL<action>_<entity>.sqlget_space.sql

Function Names

TypePatternExample
ConstructorNew<Type>NewRepository, NewJoinSpaceUseCase
Provider (DI)Provide<Type>ProvideSpaceRepository
MapperMap<From>To<To>MapSpaceToProto, MapRowToEntity
Error Factory<ErrorCondition>NotHost, SpaceFull, CASConflict

Package Names

LocationRuleExample
FeatureSingular, snake_case folderspace, quiz_set
Import aliasdomain+layerspaceentity, spacerepo, spaceapp

Interface + Implementation

// Interface definition (domain/repo/)
type SpaceRepository interface {
    GetByID(ctx context.Context, id uuid.UUID) (*entity.Space, error)
}

// Implementation (data/repo/)
type Repository struct { ... }

// Interface implementation verification
var _ spacerepo.SpaceRepository = (*Repository)(nil)

Dependencies Struct

// Use Dependencies struct when 3+ dependencies
type JoinSpaceDependencies struct {
    Logger    *zap.Logger
    FSM       SpaceFSMPort
    Snapshots spacerepo.SpaceSnapshotReader
    Clock     func() time.Time
}

func NewJoinSpaceUseCase(deps JoinSpaceDependencies) JoinSpaceUseCase {
    logger := deps.Logger
    if logger == nil {
        logger = zap.NewNop()
    }
    // ...
}

Quick Reference Checklist

Adding New Feature

  1. internal/feature/<name>/domain/entity/ - Define domain models
  2. internal/feature/<name>/domain/entity/errors.go - Define domain errors
  3. internal/feature/<name>/domain/repo/ - Repository interfaces
  4. internal/feature/<name>/application/ - Define UseCases
  5. internal/feature/<name>/data/repo/ - Repository implementations
  6. internal/feature/<name>/data/repo/sql/ - SQL queries
  7. internal/feature/<name>/protocol/ - gRPC handlers
  8. internal/infrastructure/di/providers_<name>.go - DI Providers
  9. Add Set to wire.gowire ./internal/infrastructure/di/

Adding New UseCase

  1. Define Interface (<Action><Entity>UseCase)
  2. Define Dependencies struct
  3. Constructor (New<Action><Entity>UseCase)
  4. Verify var _ Interface = (*impl)(nil)
  5. Write tests (table-driven)
  6. Add DI Provider

Adding New Error

  1. Check ErrorCode constants (reuse existing or create new)
  2. Add Reason constant
  3. Write Factory function (include metadata, precondition)
  4. Verify Protocol layer mapping
Skills Info
Original Name:17th-go-patternsAuthor:seventeenthearth