Agent Skill
2/7/202617th-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
| 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. |
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
| Type | Pattern | Example |
|---|---|---|
| UseCase | <action>_<entity>_usecase.go | join_space_usecase.go |
| UseCase Test | <action>_<entity>_usecase_test.go | join_space_usecase_test.go |
| Repository | repository.go or <entity>_repository.go | space_repository.go |
| Handler | manager.go or grpc_manager.go | manager.go |
| Mapper | mapper.go | mapper.go |
| Errors | errors.go | errors.go |
| SQL | <action>_<entity>.sql | get_space.sql |
Function Names
| Type | Pattern | Example |
|---|---|---|
| Constructor | New<Type> | NewRepository, NewJoinSpaceUseCase |
| Provider (DI) | Provide<Type> | ProvideSpaceRepository |
| Mapper | Map<From>To<To> | MapSpaceToProto, MapRowToEntity |
| Error Factory | <ErrorCondition> | NotHost, SpaceFull, CASConflict |
Package Names
| Location | Rule | Example |
|---|---|---|
| Feature | Singular, snake_case folder | space, quiz_set |
| Import alias | domain+layer | spaceentity, 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
-
internal/feature/<name>/domain/entity/- Define domain models -
internal/feature/<name>/domain/entity/errors.go- Define domain errors -
internal/feature/<name>/domain/repo/- Repository interfaces -
internal/feature/<name>/application/- Define UseCases -
internal/feature/<name>/data/repo/- Repository implementations -
internal/feature/<name>/data/repo/sql/- SQL queries -
internal/feature/<name>/protocol/- gRPC handlers -
internal/infrastructure/di/providers_<name>.go- DI Providers - Add Set to
wire.go→wire ./internal/infrastructure/di/
Adding New UseCase
- Define Interface (
<Action><Entity>UseCase) - Define Dependencies struct
- Constructor (
New<Action><Entity>UseCase) - Verify
var _ Interface = (*impl)(nil) - Write tests (table-driven)
- Add DI Provider
Adding New Error
- Check ErrorCode constants (reuse existing or create new)
- Add Reason constant
- Write Factory function (include metadata, precondition)
- Verify Protocol layer mapping
Skills Info
Original Name:17th-go-patternsAuthor:seventeenthearth
Download