CoC — Convention over Configuration #
There are two ways to start a new project. The first: engineers must decide and configure every detail — file names, folder structure, naming formats, routing conventions, database mappings, how to inject dependencies — before writing a single line of business logic. The second: there are sensible defaults for all of that, and engineers only configure when there’s a real reason to deviate. Convention over Configuration (CoC) is the philosophy behind the second way. Its principle: systems should have sensible default conventions, so developers only make explicit decisions when the desired behavior differs from the convention. CoC isn’t about reducing flexibility — it’s about reducing unnecessary decisions. Every unnecessary decision made is time that could go to something more important. This article covers the CoC philosophy, how it works in Go and Dart, how to apply it in internal libraries and project structures, its concrete benefits for onboarding and team consistency, and when conventions should be violated.
What Is Convention over Configuration? #
CoC was popularized by Ruby on Rails, but the philosophy is much broader than one framework. The essence is:
If a class is named
User, then its database table isusers. If not explicitly declared, use the convention — don’t force developers to configure something that’s already obvious.
Rails made this famous, but the Go standard library, gRPC, protobuf, and many other modern tools all apply CoC quietly.
Examples of CoC in familiar ecosystems:
Tool / Framework Default convention
─────────────────── ──────────────────────────────────────────────
Go testing *_test.go files → automatically run by go test
Test*() functions → test cases
Benchmark*() functions → benchmarks
Example*() functions → verified examples
Go modules go.mod at the root → this is the module root
internal/ → packages can't be imported from outside the module
protobuf / gRPC message User → generates struct User in Go
field first_name → generates FirstName in Go (camelCase)
snake_case in proto → camelCase in generated code
GORM struct User → table users (automatic pluralization)
field ID → primary key
field CreatedAt → automatically filled on insert
field UpdatedAt → automatically filled on update
JSON encoding field Name string → "Name" (without tags)
field Name string `json:"name"` → "name" (with tags, overriding the convention)
Kubernetes deployment.yaml file → resource name from metadata.name
:latest image tag → IfNotPresent pull policy (default convention)
Docker Dockerfile at the root → docker build . works directly
Last CMD → default entrypoint
The consistent pattern: conventions apply by default, configuration is used to deviate. Not the other way around.
The Problems CoC Solves #
Without clear conventions, every small decision becomes cognitive load that drains the team’s energy:
Without CoC — every detail needs an explicit decision:
"Where should handler files go?"
→ 15-minute team discussion, and in the end each engineer has their own preference
"What should the table for the Order struct be named?"
→ "orders", "order", "tbl_orders", "Order" — four engineers, four opinions
"What JSON format for the created_at field?"
→ "created_at", "createdAt", "CreatedAt", "created" — inconsistent across endpoints
"What should the user handler file be named?"
→ "user_handler.go", "handler_user.go", "users.go", "user.go"
Result: an inconsistent codebase, slow onboarding,
code reviews full of style debates instead of substance
With CoC — the convention exists, decisions are only for exceptions:
"Where should handler files go?"
→ internal/api/ — project convention
"What table name for the Order struct?"
→ orders — snake_case plural, convention already exists
"What JSON format for created_at?"
→ snake_case — project API convention
"What should the user handler file be named?"
→ user_handler.go — handler naming convention
New engineers immediately know where to look and where to put things
flowchart TD
Q{"Does this\ndeviate from\nthe convention?"}
COC["Use the convention\n(no decision needed)"]
CONFIG["Explicit configuration\n(with a clear reason)"]
BENEFIT["CoC benefits:\n• Fewer decisions\n• Automatic consistency\n• Faster onboarding\n• Code reviews focus on substance"]
Q -->|"No"| COC
Q -->|"Yes, because of reason X"| CONFIG
COC --> BENEFIT
style COC fill:#5CB85C,color:#fff
style BENEFIT fill:#4C9BE8,color:#fffCoC in Go Naming and Code Structure #
Go itself is a consistent example of CoC. Conventions that don’t need configuring but are followed by the entire ecosystem:
// Go conventions that have become "unwritten law"
// 1. Package name = directory name (lowercase, one word)
// ✓ package user → in directory internal/user/
// ✓ package pricing → in directory internal/pricing/
// ✗ package UserService → not a Go convention
// 2. Interface with one method → interface name = method + "er"
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Stringer interface { String() string }
// This convention makes interface naming predictable and consistent
// 3. Constructor → New + type name
func NewUserService(repo UserRepository) *UserService { ... }
func NewOrderHandler(svc *OrderService) *OrderHandler { ... }
// Anyone reading Go code knows that NewX is a constructor
// 4. Error variables → Err + condition name
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
// This convention makes sentinel errors easy to find with grep
// 5. Unexported field → internal detail config, exported → public API
type UserService struct {
repo UserRepository // unexported = implementation detail
notifier Notifier // unexported = swappable without breaking changes
MaxRetries int // exported = part of the public API (rare, but exists)
}
// 6. Test file → same name as the tested file, with _test suffix
// user_service.go → user_service_test.go
// order_handler.go → order_handler_test.go
// 7. Integration tests → _integration_test.go suffix or build tags
// //go:build integration
These conventions aren’t configured anywhere — they exist by default in every Go developer’s mind because they’re documented and reinforced by standard tools (gofmt, golint, go vet).
CoC in Project Structure #
A project structure following conventions lets new engineers immediately know where to find things — without needing separate documentation.
A structure following the Go community convention (not official, but widely adopted):
project/
├── cmd/
│ └── api/
│ └── main.go ← application entry point
├── internal/ ← code that must not be imported from outside the module
│ ├── domain/ ← entities and business rules (knows no infrastructure)
│ │ ├── user/
│ │ │ ├── user.go ← User struct, Status, domain methods
│ │ │ └── errors.go ← ErrNotFound, ErrUnauthorized, etc.
│ │ └── order/
│ │ ├── order.go
│ │ └── errors.go
│ ├── service/ ← business logic, orchestration
│ │ ├── user_service.go
│ │ └── order_service.go
│ ├── repository/ ← data access, domain interface implementations
│ │ ├── postgres/
│ │ │ ├── user_repo.go
│ │ │ └── order_repo.go
│ │ └── redis/
│ │ └── session_repo.go
│ └── api/ ← HTTP handlers, middleware, router
│ ├── handler/
│ │ ├── user_handler.go
│ │ └── order_handler.go
│ ├── middleware/
│ │ ├── auth.go
│ │ └── logging.go
│ └── router.go
├── pkg/ ← code importable from outside (if any)
│ └── validator/
│ └── email.go
├── migrations/ ← database migration files
│ ├── 001_create_users.sql
│ └── 002_create_orders.sql
├── config/
│ └── config.go
├── go.mod
└── go.sum
Conventions in effect in this structure:
- Directory names reflect the layer/concern
- Handler files: {domain}_handler.go
- Service files: {domain}_service.go
- Repository files: {domain}_repo.go
- Migration files: {sequence}_{description}.sql
New engineers immediately know:
"Where is the user handler?" → internal/api/handler/user_handler.go
"Where is the order business logic?" → internal/service/order_service.go
"Where are the database migrations?" → migrations/
CoC in Databases and ORMs #
Consistent database conventions eliminate recurring decisions about table, column, and index naming.
// Database naming conventions applied consistently:
//
// Struct → Table:
// User → users (snake_case, plural)
// OrderItem → order_items (snake_case, plural)
// UserProfile → user_profiles
//
// Field → Column:
// ID → id (primary key)
// UserID → user_id (foreign key)
// CreatedAt → created_at
// UpdatedAt → updated_at
// DeletedAt → deleted_at (for soft deletes)
// FirstName → first_name
//
// Indexes:
// Primary key → idx_{table}_pkey (automatic)
// Foreign key → idx_{table}_{column}_fkey
// Unique → uniq_{table}_{column}
// Search → idx_{table}_{column}
// With GORM — these conventions are built in, no explicit config needed:
type User struct {
ID uint `gorm:"primarykey"`
Name string
Email string `gorm:"uniqueIndex"`
CreatedAt time.Time // automatically filled on insert
UpdatedAt time.Time // automatically filled on update
DeletedAt gorm.DeletedAt `gorm:"index"` // automatic soft delete
}
// Table: users — GORM pluralization convention
type Order struct {
ID uint
UserID uint // GORM knows this is a foreign key to users.id
User User // belongs to — automatic from the UserID field
Total float64
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
// Table: orders
// Without GORM — apply the same conventions manually and consistently:
const (
// Convention: queries use $N named parameters for PostgreSQL
queryFindUserByEmail = `
SELECT id, name, email, created_at, updated_at
FROM users
WHERE email = $1 AND deleted_at IS NULL
`
queryInsertUser = `
INSERT INTO users (name, email, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
RETURNING id, created_at, updated_at
`
queryFindOrdersByUserID = `
SELECT id, user_id, total, status, created_at
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC
`
// Convention: query constants named query{Operation}{Entity}By{Condition}
)
CoC in API Design #
Consistent API conventions make endpoints predictable — clients don’t need to read documentation to guess the right URL and method.
REST conventions that have become the de facto standard:
Resource HTTP Method URL Description
────────── ──────────── ───────────────────────── ─────────────────────
users GET /api/v1/users List all users
POST /api/v1/users Create a new user
GET /api/v1/users/:id Get a user by ID
PUT /api/v1/users/:id Update a user (full replace)
PATCH /api/v1/users/:id Update a user (partial)
DELETE /api/v1/users/:id Delete a user
orders GET /api/v1/orders List orders
POST /api/v1/orders Create a new order
GET /api/v1/orders/:id Get an order by ID
PATCH /api/v1/orders/:id/status Update status (nested action)
Consistent response conventions:
Success (200): Error (4xx/5xx):
{ {
"data": { ... }, "code": "USER_NOT_FOUND",
"meta": { "message": "user not found",
"page": 1, "trace_id": "abc-123"
"per_page": 10, }
"total": 100
}
}
Versioning conventions:
/api/v1/ → current version
/api/v2/ → new breaking version (v1 keeps running during the deprecation period)
Pagination conventions:
GET /api/v1/users?page=1&per_page=20
→ page and per_page always exist as query parameters
→ defaults: page=1, per_page=20, max per_page=100
// Implementing consistent response conventions
type SuccessResponse struct {
Data interface{} `json:"data"`
Meta *Meta `json:"meta,omitempty"`
}
type Meta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
}
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
TraceID string `json:"trace_id,omitempty"`
}
// Helpers that enforce response conventions — all handlers use these
func writeSuccess(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(SuccessResponse{Data: data})
}
func writeSuccessList(w http.ResponseWriter, data interface{}, meta Meta) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(SuccessResponse{Data: data, Meta: &meta})
}
func writeError(w http.ResponseWriter, status int, code, message, traceID string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{
Code: code,
Message: message,
TraceID: traceID,
})
}
// A handler following the convention — consistent with every other handler
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
p := parsePagination(r) // convention: parsePagination always exists
users, total, err := h.service.ListUsers(r.Context(), p)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
"failed to retrieve users", traceIDFrom(r))
return
}
writeSuccessList(w, users, Meta{Page: p.Page, PerPage: p.Limit, Total: total})
}
Building CoC into Internal Libraries #
When a team is large enough, building a small internal library or framework that enforces conventions is a highly profitable investment. New engineers can become productive faster because they don’t need to make decisions about things that already have conventions.
// Example: a small internal HTTP framework enforcing response conventions
// pkg/httpkit/handler.go
package httpkit
import (
"context"
"encoding/json"
"net/http"
)
// Handler is the convention for all handlers in this project
// Returning (interface{}, error) instead of writing directly to http.ResponseWriter
// This convention consistently separates business logic from HTTP concerns
type Handler func(ctx context.Context, r *http.Request) (interface{}, error)
// Adapt converts a Handler into an http.HandlerFunc following response conventions
func Adapt(h Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
traceID := traceIDFromContext(r.Context())
result, err := h(r.Context(), r)
if err != nil {
handleError(w, err, traceID)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(SuccessResponse{Data: result})
}
}
func handleError(w http.ResponseWriter, err error, traceID string) {
// Convention: all domain errors are translated to consistent HTTP statuses
var apiErr *APIError
if errors.As(err, &apiErr) {
writeErrorResponse(w, apiErr.HTTPStatus, apiErr.Code, apiErr.Message, traceID)
return
}
// Log the internal error, expose only a generic message to the client
slog.Error("unhandled error", "trace_id", traceID, "error", err)
writeErrorResponse(w, http.StatusInternalServerError,
"INTERNAL_ERROR", "an unexpected error occurred", traceID)
}
// Usage: handlers follow the convention without needing HTTP details
func (h *UserHandler) GetUser(ctx context.Context, r *http.Request) (interface{}, error) {
id := chi.URLParam(r, "id") // or r.PathValue("id") in Go 1.22+
if id == "" {
return nil, NewAPIError(http.StatusBadRequest, "MISSING_ID", "user id is required")
}
user, err := h.service.GetUser(ctx, id)
if err != nil {
return nil, err // Adapt() will handle the HTTP translation
}
return user, nil
}
// Router setup follows the convention
router.Get("/api/v1/users/{id}", httpkit.Adapt(userHandler.GetUser))
// Another example: an internal repository convention
// Every repository follows the same interface
// pkg/repository/base.go
package repository
import "context"
// Convention: all repositories implement this interface for basic CRUD operations
type Base[T any] interface {
FindByID(ctx context.Context, id string) (*T, error)
Save(ctx context.Context, entity T) error
Update(ctx context.Context, entity T) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter Filter) ([]T, int, error)
}
type Filter struct {
Page int
PerPage int
// Other fields are added per repository with embedding or composition
}
// A repository following the convention is immediately understood
// by any engineer already familiar with this project
type UserRepository struct {
db *sql.DB
}
// Because it follows the Base[User] convention, engineers immediately know
// which methods are available without opening the repository file
CoC in Flutter/Dart #
In Flutter, CoC is most felt in project structure and file naming. Dart conventions enforced by dart format and dart analyze:
// 1. File names: snake_case
// user_service.go, order_repository.go, payment_gateway.go
// 2. Type names: PascalCase (exported) or lowerCamelCase (unexported)
type UserService struct{}
type OrderRepository struct{}
type PaymentGateway struct{}
// 3. Variable and function names: mixedCaps
var userService = UserService{}
func createOrder() {}
// 4. Constants: mixedCaps (Go convention — no UPPER_SNAKE)
const maxRetryCount = 3
const defaultPageSize = 20
// 5. Private: lowercase first letter; public: uppercase
type UserService struct {
repository UserRepository // private — Go convention
logger Logger
}
// A layered Go project structure following conventions:
project/
├── cmd/
│ └── api/
│ └── main.go ← entry point
├── internal/ ← code that must not be imported from outside
│ ├── core/ ← shared utilities, knows no domain
│ │ ├── network/
│ │ │ └── api_client.go
│ │ ├── error/
│ │ │ └── exceptions.go
│ │ └── utils/
│ │ └── validators.go
│ ├── features/ ← features organized per domain
│ │ ├── auth/
│ │ │ ├── data/
│ │ │ │ ├── auth_repository_impl.go
│ │ │ │ └── auth_remote_source.go
│ │ │ ├── domain/
│ │ │ │ ├── auth_repository.go ← interface
│ │ │ │ └── user_entity.go
│ │ │ └── presentation/
│ │ │ ├── auth_screen.go
│ │ │ └── auth_view_model.go
│ │ └── order/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── shared/ ← components used by many features
│ ├── widgets/
│ │ ├── loading_indicator.go
│ │ └── error_view.go
│ └── theme/
│ └── app_theme.go
├── go.mod
// Go naming conventions:
// Screen (full page): UserProfileScreen, OrderListScreen
// Widget (component): UserAvatar, OrderCard, PriceTag
// ViewModel / Controller: UserProfileViewModel, OrderListController
// Repository interface: UserRepository (without Impl)
// Repository implementation: UserRepositoryImpl
// Go conventions that have become the standard:
// 1. File names: snake_case
// user_service.go, order_repository.go, payment_gateway.go
// 2. Type names: PascalCase (exported) or lowerCamelCase (unexported)
type UserService struct{}
type OrderRepository struct{}
type PaymentGateway struct{}
// 3. Variable and function names: mixedCaps
var userService = UserService{}
func createOrder() {}
// 4. Constants: mixedCaps (Go convention — no UPPER_SNAKE)
const maxRetryCount = 3
const defaultPageSize = 20
// 5. Private: lowercase first letter; public: uppercase
type UserService struct {
repository UserRepository // private — Go convention
logger Logger
}
// A layered Go project structure following conventions:
// project/
// ├── cmd/
// │ └── api/
// │ └── main.go ← entry point
// ├── internal/ ← code that must not be imported from outside
// │ ├── core/ ← shared utilities, knows no domain
// │ │ ├── network/
// │ │ │ └── api_client.go
// │ │ ├── error/
// │ │ │ └── exceptions.go
// │ │ └── utils/
// │ │ └── validators.go
// │ ├── features/ ← features organized per domain
// │ │ ├── auth/
// │ │ │ ├── data/
// │ │ │ │ ├── auth_repository_impl.go
// │ │ │ │ └── auth_remote_source.go
// │ │ │ ├── domain/
// │ │ │ │ ├── auth_repository.go ← interface
// │ │ │ │ └── user_entity.go
// │ │ │ └── presentation/
// │ │ │ ├── auth_screen.go
// │ │ │ └── auth_view_model.go
// │ │ └── order/
// │ │ ├── data/
// │ │ ├── domain/
// │ │ └── presentation/
// │ └── shared/ ← components used by many features
// │ ├── widgets/
// │ │ ├── loading_indicator.go
// │ │ └── error_view.go
// │ └── theme/
// │ └── app_theme.go
// ├── go.mod
// Go naming conventions:
// Screen (full page): UserProfileScreen, OrderListScreen
// Widget (component): UserAvatar, OrderCard, PriceTag
// ViewModel / Controller: UserProfileViewModel, OrderListController
// Repository interface: UserRepository (without Impl)
// Repository implementation: UserRepositoryImpl
When Conventions Should Be Violated #
CoC isn’t dogma. There are situations where deviating from a convention is the right decision — as long as the deviation is deliberate, documented, and has a clear reason.
JUSTIFIED DEVIATIONS FROM CONVENTIONS:
1. Performance-critical code
ORM conventions (GORM) produce suboptimal queries for
complex reports → write optimized raw SQL
→ Document: "Raw SQL is used here because the ORM can't
generate an efficient query plan for this aggregation"
2. Legacy system integration
Legacy database tables have inconsistent names (tbl_usr, USER_DATA)
→ Keep following conventions in Go code, map to legacy names in the repository
→ Don't let legacy names leak into the domain model
3. Specific technical requirements
Endpoints with non-standard behavior because of payment gateway
integration with special webhook requirements
→ Document why this endpoint differs from normal REST conventions
4. Domains with their own conventions
Finance/accounting has its own conventions (debit/credit, journals, ledgers)
→ Follow domain conventions, not generic technical ones
UNJUSTIFIED DEVIATIONS:
✗ "I prefer this name" — personal preference isn't a sufficient reason
✗ "In my old project we did it this way" — consistency with THIS project matters more
✗ "It's more flexible" — without a concrete use case needing that flexibility
✗ No explainable reason — the convention must be followed
A rule of thumb: if a deviation from the convention can't be explained
in one concrete sentence, don't do it.
CoC is most effective when the conventions are documented. Conventions living only in senior engineers’ heads — but never written down — are CoC that will crumble as the team turns over. Document conventions in an ADR (Architecture Decision Record), README, or CONTRIBUTING.md. Documented conventions can be enforced in code review; undocumented ones can only be enforced by whoever remembers them.
Documenting and Enforcing Conventions #
Conventions that aren’t enforced will be violated. There are several ways to ensure conventions are followed consistently:
1. AUTOMATED TOOLING (most effective):
gofmt / goimports → consistently formats code
golangci-lint → enforces naming conventions, error handling patterns
dart format → consistently formats Dart
dart analyze → static analysis with custom lint rules
Custom linters → can be built for project-specific conventions
2. TEMPLATES AND GENERATORS:
go generate → generates boilerplate following conventions
Template files → starter templates for new handlers, services, repositories
Makefile targets → "make new-handler NAME=user" → generates files with conventions
3. CODE REVIEW CHECKLISTS:
Do file names follow the {domain}_{layer}.go convention?
Do response shapes use the writeSuccess/writeError helpers?
Are errors wrapped with fmt.Errorf("functionName: %w", err)?
Do test files sit next to the files they test?
4. DOCUMENTATION:
CONTRIBUTING.md → conventions contributors must follow
ADR (Architecture Decision Record) → why this convention was chosen
Per-directory READMEs → explaining directory structure conventions
# A Makefile that helps enforce conventions
.PHONY: new-handler new-service lint test
# Generate a new handler following the convention
new-handler:
@if [ -z "$(NAME)" ]; then echo "Usage: make new-handler NAME=user"; exit 1; fi
@cp templates/handler.go.tmpl internal/api/handler/$(NAME)_handler.go
@cp templates/handler_test.go.tmpl internal/api/handler/$(NAME)_handler_test.go
@sed -i 's/{{NAME}}/$(NAME)/g' internal/api/handler/$(NAME)_handler.go
@echo "Created internal/api/handler/$(NAME)_handler.go"
# Enforce conventions with linters
lint:
golangci-lint run ./...
go vet ./...
# Run all tests following Go testing conventions
test:
go test -race -cover ./...
# Run only integration tests
test-integration:
go test -tags=integration -race ./...
CoC’s Relationship with Other Principles #
flowchart TD
COC["CoC\n(Convention over Configuration)"]
DRY2["DRY\nConventions are DRY\nat the decision level —\nthe same decision doesn't\nneed to be made repeatedly"]
KISS2["KISS\nSmart defaults\neliminate unnecessary\ncomplexity"]
YAGNI2["YAGNI\nConfiguration only\nwhen there's a real reason\nto deviate"]
SRP2["SRP\nEvery layer has a clear\nconvention about what\nbelongs inside it"]
COC -->|"is DRY at the\ndesign decision level"| DRY2
COC -->|"produces"| KISS2
COC -->|"synergizes with"| YAGNI2
COC -->|"reinforced by"| SRP2
style COC fill:#4C9BE8,color:#fff
style DRY2 fill:#5CB85C,color:#fff
style KISS2 fill:#5CB85C,color:#fff
style YAGNI2 fill:#5CB85C,color:#fff
style SRP2 fill:#5CB85C,color:#fffCoC is DRY at the design decision level: instead of duplicating the same decision in every place that needs it, a convention makes that decision once and applies it everywhere. YAGNI reinforces CoC from the opposite direction: don’t create configuration for something where the default convention is already sufficient.
Anti-Patterns at a Glance #
// ✗ Explicit configuration for something that already has a convention
type UserRepository struct {
db *sql.DB
tableName string // "users" — why configure it? the convention is clear
idField string // "id" — already obvious from the convention
}
// ✗ Naming that doesn't follow Go conventions without a reason
type userservicehandler struct{} // should be UserServiceHandler or split up
func (h *userservicehandler) doCreateUser() {} // should be CreateUser
// ✗ Response shapes inconsistent across endpoints
// GET /users → {"users": [...], "count": 10}
// GET /orders → {"data": [...], "total": 10, "page": 1}
// GET /products → [...] (direct array, no wrapper)
// ✗ Folder structures that don't reflect layer conventions
internal/
stuff/ // "stuff" isn't a meaningful layer name
things/ // same
misc/ // a util package under a different name
// ✗ Inconsistent error formats
return fmt.Errorf("error: %v", err) // no function context
return errors.New("something went wrong") // not specific
return fmt.Errorf("UserService.Create: %v", err) // function name, not "createUser"
// ✓ A consistent error convention:
return fmt.Errorf("createUser %s: save to db: %w", email, err)
// ✗ Tests not following Go conventions
func testCreateUser(t *testing.T) {} // lowercase — go test won't run it!
// ✓ func TestCreateUser(t *testing.T) {}
CoC Review Checklist #
NAMING:
□ Package names lowercase, one word, matching the directory name
□ One-method interfaces → name = method + "er" (Reader, Writer, Stringer)
□ Constructors → New + type name (NewUserService, NewOrderHandler)
□ Error variables → Err + condition name (ErrNotFound, ErrUnauthorized)
□ Test files → {filename}_test.go in the same directory
PROJECT STRUCTURE:
□ Directories reflect clear layers/concerns (domain, service, repository, api)
□ No "util", "misc", "stuff", "things" directories
□ Migration files → numeric sequence + description (001_create_users.sql)
API AND RESPONSES:
□ URLs follow REST conventions (plural nouns, nested for relationships)
□ Success responses use a consistent shape across all endpoints
□ Error responses use a consistent shape (code, message, trace_id)
□ Pagination parameters consistent (page, per_page) across all list endpoints
DATABASE:
□ Table names: snake_case, plural (users, order_items)
□ Column names: snake_case (user_id, created_at)
□ Primary keys always id
□ Timestamps always created_at and updated_at
DOCUMENTED CONVENTIONS:
□ Project conventions live in CONTRIBUTING.md or an ADR
□ Deviations from conventions have documented reasons
□ Linters or automated tools used to enforce conventions
Summary #
- CoC means systems have sensible defaults — developers only make explicit decisions when they want to deviate. Not reducing flexibility, but reducing unnecessary decisions.
- The problems it solves: without conventions, every small detail becomes a time-draining discussion. With conventions, new engineers immediately know where to look and where to put things — without separate documentation.
- Go already has built-in CoC: package naming,
NewXconstructors,Xerinterfaces,ErrXerrors,*_test.gofiles,internal/directories — all conventions understood by the entire ecosystem.- Project structure as CoC: consistent directory structures (
domain/,service/,repository/,api/) make every component’s location predictable and reduce navigation overhead.- APIs as CoC: consistent URLs, HTTP methods, response shapes, and pagination make endpoints predictable — clients don’t need to read documentation to guess the right format.
- Internal libraries as enforcers: small wrappers for HTTP responses, base repository interfaces, or code generators are how teams enforce conventions automatically, not by relying on individual discipline.
- CoC needs documentation: conventions living only in senior engineers’ heads will crumble as the team turns over. Write them in CONTRIBUTING.md, ADRs, or per-directory READMEs.
- Tooling is the best enforcer:
gofmt,golangci-lint,dart format, and custom linters enforce conventions without relying on manual code review.- Deviations must be deliberate and documented: personal preference isn’t a sufficient reason. Valid deviations have concrete technical reasons explainable in one sentence.
- Relationship with other principles: CoC is DRY at the design decision level, synergizes with YAGNI (configure only when there’s a real reason), and is reinforced by SRP (every layer has clear conventions).