Go Development Patterns
This document defines patterns and conventions for writing consistent Go code.
1. Clean Architecture
Layer Structure
code
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
code
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:
code
internal/feature/<feature>/subfeature/<module>/
Cross-feature integration (Consumer Port implementation):
code
internal/feature/<provider>/subfeature/consumer/<consumer>/adapter/
Consumer defines Port (requirements), Provider implements and owns transaction.
2. Error Handling
Domain Error Structure
go
// 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)
go
// 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
go
func AsSpaceError(err error) (*SpaceError, bool) {
var target *SpaceError
if errors.As(err, &target) {
return target, true
}
return nil, false
}
Protocol Layer Error Conversion
go
// 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
go
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
go
// 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
go
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
//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
go
// 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
//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
bash
wire ./internal/infrastructure/di/
5. gRPC/Connect Handlers
Manager Structure
go
// 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
go
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
go
// 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
code
internal/feature/<feature>/data/repo/sql/ ├── get_<entity>.sql ├── list_<entity>.sql ├── insert_<entity>.sql ├── update_<entity>.sql └── delete_<entity>.sql
Query Examples
sql
-- 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
go
// 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
go
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
go
// 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
go
// 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