| name | go-foundations |
| description | Go coding conventions for dependency injection, module structure, error handling, testing, and context propagation. Load when working on .go files. |
| argument-hint | Working on .go files — services, repositories, handlers, domain models, or tests |
| stack | go |
Go Foundations
Baseline: Follow Effective Go as the foundation. Everything below extends or overrides where noted.
Adaptive Behavior
Read stack.md for the go_principles field to determine how strictly to apply these conventions:
adopt — New project or project born with these principles. All new code MUST follow the patterns. Structure new modules with the full internal/module/domain/service/repository/factory.go layout. Flag deviations as errors.
recommend — Existing codebase not originally built with these patterns. New code follows the patterns. Existing code is left alone. When reviewing, suggest improvements as recommendations, not requirements. Phrase as: "Consider extracting..." or "This could benefit from...".
off — Skill is inactive. Follow Effective Go only.
If go_principles is not set in stack.md, default to recommend.
Dependency Inversion
Interface Ownership
Interfaces are defined where they are consumed, not where they are implemented.
package service
import "context"
type UserRepository interface {
Create(ctx context.Context, u *User) (*User, error)
Update(ctx context.Context, u *User) (*User, error)
Delete(ctx context.Context, id string) error
GetByID(ctx context.Context, id string) (*User, error)
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
Interface size matches consumer needs. If the service needs 10 methods, the interface has 10 methods. Do not impose artificial caps.
Unexported Implementation, Interface Return
The implementation struct is unexported (lowercase). The constructor is exported and returns the consumer's interface type.
package repository
import (
"context"
"database/sql"
"myapp/internal/user/service"
)
type userRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) service.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, u *service.User) (*service.User, error) {
}
Multi-Consumer Interfaces
When one implementation satisfies interfaces from different consumers, the constructor returns a composite:
func NewUserRepository(db *sql.DB) interface {
service.UserRepository
reporting.UserQuery
} {
return &userRepository{db: db}
}
Each consumer receives only the interface it declared. The composite is only visible at the wiring site.
Module Factory Pattern
Each module has a factory.go at the module root that wires its internals and returns ready-to-use components.
package user
import (
"database/sql"
"myapp/internal/user/repository"
"myapp/internal/user/service"
)
type Services struct {
UserService service.UserServiceAPI
}
func NewModule(db *sql.DB) *Services {
repo := repository.NewUserRepository(db)
svc := service.NewUserService(repo)
return &Services{UserService: svc}
}
If the module has a single service, returning it directly is fine:
func NewModule(db *sql.DB) *service.UserService {
repo := repository.NewUserRepository(db)
return service.NewUserService(repo)
}
Cross-Module Dependencies
Pass interfaces through the factory — never concrete types or another module's internal packages:
package task
func NewModule(db *sql.DB, userQuerier service.UserQuerier) *Services {
repo := repository.NewTaskRepository(db)
svc := service.NewTaskService(repo, userQuerier)
return &Services{TaskService: svc}
}
userMod := user.NewModule(db)
taskMod := task.NewModule(db, userMod.UserService)
Factory Rules
- One
factory.go per module at the module root package
- The factory is the only file that imports internal sub-packages — it is the module's own composition root
- The
Services struct exposes interfaces, not concrete types for cross-module use
main.go only imports module root packages and shared packages — never internal/user/repository directly
- If
main.go imports a sub-package of a module, the factory is incomplete
Project Structure
internal/
user/ # module: everything related to users
factory.go # wires internals, returns Services struct
domain/ # domain types for this module
user.go # User struct, value objects
service/ # business logic + interfaces it needs
user_service.go # UserService + UserRepository interface
user_service_test.go # unit tests with mockery mocks
mock_user_repository.go # generated by mockery (next to interface)
repository/ # data layer implementations
user_repository.go # unexported userRepository
task/ # module: everything related to tasks
factory.go
domain/
service/
repository/
shared/ # cross-module infrastructure
clock/ # testable time
slogx/ # context-propagated logger
aop/ # execution tracing
errorx/ # structured error types
cmd/
server/
main.go # composition root — imports modules, not layers
Import Rules
Within a module: layers import inward toward the domain.
handler -> service <- repository
^
domain (shared within the module)
- Never import a repository from a handler — always go through the service layer
- Never import another module's repository — depend on the other module's service interface
- Domain packages are the exception — other modules may import
user/domain for shared types. Keep domain packages free of business logic.
Import Aliasing
When importing multiple packages with the same name, use meaningful aliases:
import (
userService "myapp/internal/user/service"
orderService "myapp/internal/order/service"
)
Never use service1, service2, or similar meaningless aliases.
Domain Model Strategy
Internal Domain — ORM Tags Allowed
Domain models live in domain/ and are internal to the module. They may carry ORM tags.
package domain
import "time"
type User struct {
ID string `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex"`
FirstName string
LastName string
CreatedAt time.Time
UpdatedAt time.Time
}
External Models — JSON Tags + Aliases
For API representation, use a models/ package (or DTOs) with JSON tags.
When the shapes are identical, use type aliases for zero-cost refactor safety:
package models
import "myapp/internal/user/domain"
type User = domain.User
When shapes diverge, introduce a concrete type with explicit mapping:
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsActive bool `json:"isActive"`
CreatedAt string `json:"createdAt"`
}
func FromDomain(u *domain.User) *UserResponse {
return &UserResponse{
ID: u.ID,
Email: u.Email,
FirstName: u.FirstName,
LastName: u.LastName,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
Rule: Start with aliases. Break into concrete types only when the domain and API shapes genuinely diverge.
Structured Error Handling
Sentinel Errors + Wrapping
Define sentinel errors for domain error categories. Wrap with %w at layer boundaries.
package errorx
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrNotAuthorized = errors.New("not authorized")
ErrValidation = errors.New("validation error")
)
Structured Error Type
For rich API responses, define an AppError that carries a code, message, and details while still supporting errors.Is():
package errorx
import "fmt"
type AppError struct {
Cause error
Code string
Message string
Details map[string]any
}
func (e *AppError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Cause
}
func New(cause error, code string, message string) *AppError {
return &AppError{Cause: cause, Code: code, Message: message}
}
func (e *AppError) WithDetails(details map[string]any) *AppError {
e.Details = details
return e
}
Usage in Services
func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, errorx.New(errorx.ErrNotFound, "USER_NOT_FOUND",
fmt.Sprintf("User with id %s not found", id),
).WithDetails(map[string]any{"id": id})
}
return user, nil
}
Routing at the Middleware Boundary
The middleware (or handler) uses errors.Is() on the sentinel to determine HTTP status, and the AppError fields for the response body:
var appErr *errorx.AppError
if errors.As(err, &appErr) {
status := mapSentinelToHTTPStatus(appErr.Cause)
c.JSON(status, map[string]any{
"code": appErr.Code,
"message": appErr.Message,
"details": appErr.Details,
})
return
}
Key principle: Services never import net/http. The sentinel-to-status mapping lives at the transport boundary.
Error Wrapping at Layer Boundaries
When a repository returns a raw error (e.g., from the database driver), wrap it with context:
func (r *userRepository) GetByID(ctx context.Context, id string) (*service.User, error) {
var user service.User
err := r.db.QueryRowContext(ctx, "SELECT ...", id).Scan(&user.ID, &user.Email)
if err != nil {
return nil, fmt.Errorf("querying user %s: %w", id, err)
}
return &user, nil
}
The service layer then decides whether to wrap this into an AppError or propagate it as-is.
Testing Conventions
Mock Generation with Mockery
Use mockery to generate mocks. Never hand-write mock structs.
Add go:generate directives next to the interface:
type UserRepository interface {
Create(ctx context.Context, u *User) (*User, error)
GetByID(ctx context.Context, id string) (*User, error)
}
Mocks are generated next to the interface file (same directory), not in a mocks/ subfolder. Configure .mockery.yaml:
all: false
with-expecter: true
dir: "{{.InterfaceDir}}"
outpkg: "{{.PackageName}}"
filename: "mock_{{.InterfaceName | snakecase}}.go"
Table-Driven Tests with Testify
Use table-driven tests with t.Run subtests. Assertions use testify assert and require only — no suite, no testify mock package (mockery handles mocking).
func TestUserService_GetByID(t *testing.T) {
tests := []struct {
name string
id string
setupMock func(*MockUserRepository)
want *User
wantErr error
}{
{
name: "returns user when found",
id: "usr-123",
setupMock: func(m *MockUserRepository) {
m.EXPECT().GetByID(mock.Anything, "usr-123").
Return(&User{ID: "usr-123", Email: "test@example.com"}, nil)
},
want: &User{ID: "usr-123", Email: "test@example.com"},
},
{
name: "returns error when not found",
id: "usr-999",
setupMock: func(m *MockUserRepository) {
m.EXPECT().GetByID(mock.Anything, "usr-999").
Return(nil, fmt.Errorf("not found"))
},
wantErr: errorx.ErrNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := NewMockUserRepository(t)
tt.setupMock(mockRepo)
svc := NewUserService(mockRepo)
got, err := svc.GetByID(context.Background(), tt.id)
if tt.wantErr != nil {
require.Error(t, err)
assert.True(t, errors.Is(err, tt.wantErr))
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
Testing Rules
assert for soft checks, require for fatal checks. Use require when failure makes subsequent assertions meaningless (e.g., nil checks before accessing fields).
mock.Anything for context.Context parameters. Always.
mock.MatchedBy(func) for partial field matching when the exact value doesn't matter but a specific field does.
- Let
t.Cleanup handle mock verification — NewMock*(t) auto-registers cleanup. Don't call AssertExpectations manually.
- Same package tests (
_test.go, not _test package). Prefer testing exported behavior; access unexported fields only when testing internal state that cannot be observed through the public API.
Context Propagation
Clock — Testable Time
Never call time.Now() directly in services or repositories.
package clock
import (
"context"
"time"
)
type ctxKey struct{}
func WithTime(ctx context.Context, t time.Time) context.Context {
return context.WithValue(ctx, ctxKey{}, t)
}
func Now(ctx context.Context) time.Time {
if t, ok := ctx.Value(ctxKey{}).(time.Time); ok {
return t
}
return time.Now().UTC()
}
In services: use clock.Now(ctx) for all timestamps.
In tests: inject a fixed time via context:
fixed := time.Date(2026, 3, 19, 12, 0, 0, 0, time.UTC)
ctx := clock.WithTime(context.Background(), fixed)
Logger — Context-Propagated slog
package slogx
import (
"context"
"log/slog"
)
type ctxKey struct{}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
func Logger(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
Middleware enriches the logger with request attributes (request ID, method, path). Every layer reads via slogx.Logger(ctx).
AOP Trace — Execution Tracing
Trace entry and exit of meaningful operations with identifying attributes from the method payload.
package aop
import (
"context"
"fmt"
"time"
"myapp/internal/shared/slogx"
)
func Trace(ctx context.Context, operation string, args ...any) func() {
logger := slogx.Logger(ctx)
logger.Debug(fmt.Sprintf("-> %s", operation), args...)
start := time.Now()
return func() {
logger.Debug(fmt.Sprintf("<- %s (%s)", operation, time.Since(start)), args...)
}
}
Usage: defer aop.Trace(ctx, "UserService.Create", "email", email)()
Attributes to include: entity IDs, lookup keys, business-meaningful values (amount, status). Exclude: full request bodies, secrets, large arrays (log len(items) instead).
Cross-Cutting Rules
- Every package follows the pair:
With*(ctx, value) context.Context + FromContext(ctx) / Logger(ctx) / Now(ctx)
- Middleware injects, layers read — handlers and services never call
WithLogger or WithTime themselves (except in tests)
- Tests override through context — inject fixed time, test logger via context. No global state mutation.
JSON Convention
All JSON field names use lowerCamelCase. Every exported struct field that appears in JSON must have an explicit json:"..." tag.
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsActive bool `json:"isActive"`
CreatedAt string `json:"createdAt"`
Password string `json:"-"`
}
Rules:
json:"-" for fields that must never be serialized (passwords, internal flags)
json:"field,omitempty" for optional fields omitted when zero-valued
- Acronyms follow camelCase:
json:"userId", json:"apiKey" — not json:"userID", json:"APIKey"
Refactoring Guidance
When reviewing code in recommend mode, identify deviations from these patterns and provide direction without rewriting:
Flag the gap:
"This service creates its own database connection instead of receiving a repository through the constructor."
Suggest the target:
"Consider extracting a UserRepository interface in the service package, moving the DB logic to a repository/ package with an unexported struct, and wiring them in a factory.go at the module root."
Do not:
- Rewrite the existing code unprompted
- Present the refactoring as mandatory in
recommend mode
- Attempt to refactor unrelated code while implementing a feature
In adopt mode, deviations in new code are errors — flag and fix. Existing code is still left alone unless the developer explicitly requests a refactoring plan.