Backend
This page covers conventions for contributing to the Go backend. Read Architecture first if you haven’t already.
Adding a New Domain Entity
Follow this sequence when adding a new entity (e.g. document):
- Domain —
internal/domain/<name>/entity.go: entity struct, enums, errors - Repository interface — add to
internal/repository/interfaces.go - Repository implementation —
internal/repository/<group>/<name>.go - Use case —
internal/usecase/<group>/<name>_usecase.go+ types file - Handler —
internal/handler/<group>/<name>.go - Routes — wire into
internal/server/routes.go - DI — wire repository + use case in
internal/app/container.go - Migration —
migrations/<seq>_create_<name>s_table.up.sql - Mocks — add interface to
go:generateininternal/repository/interfaces.go, runjust generate-mocks - Tests — unit test the use case with mocks; integration test the repository with testcontainers
Naming Conventions
- Package names: short, lowercase, singular —
user,project,support - Repository files:
internal/repository/<group>/<entity>.go(e.g.repository/person/person.go) - Handler files:
internal/handler/<group>/<entity>.go(e.g.handler/project/person.go) - Use case files:
internal/usecase/<group>/<entity>_usecase.go - Entity IDs:
ulid.ULIDin structs,stringin DTOs (via.String()) - Unexported repo structs:
type userRepository struct { db *sqlx.DB } - Constructor:
func New(db *sqlx.DB) repository.UserRepository
Handler Pattern
Bind, call, respond.
func (h *PersonHandler) Create(c *gin.Context) {
req, ok := handler.BindJSON[CreatePersonRequest](c)
if !ok {
return
}
projectID := c.Param("project_id")
userID, _ := middleware.UserIDFrom(c)
out, err := h.personUC.Create(c.Request.Context(), usecase.CreatePersonInput{
ProjectID: projectID,
CreatedBy: userID.String(),
// ... fields from req
})
if err != nil {
handler.HandleError(c, err)
return
}
c.JSON(http.StatusCreated, out)
}handler.BindJSON[T] and handler.HandleError are defined in internal/handler/errors.go and imported by all handler subdirectory packages. BindJSON decodes the request body and writes a 400 response on failure, returning false so the handler can return immediately.
Use Case Pattern
type CreatePersonInput struct {
ProjectID string
FirstName string
LastName string
// ...
}
type CreatePersonOutput struct {
PersonID string `json:"person_id"`
}
func (uc *PersonUseCase) Create(ctx context.Context, in CreatePersonInput) (*CreatePersonOutput, error) {
person := &domain.Person{
ID: ulid.New(),
ProjectID: in.ProjectID,
FirstName: in.FirstName,
// ...
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := uc.repo.Create(ctx, person); err != nil {
return nil, err
}
return &CreatePersonOutput{PersonID: person.ID.String()}, nil
}Error Handling
Domain errors are defined in internal/domain/<name>/errors.go:
var (
ErrPersonNotFound = errors.New("person not found")
ErrPersonExists = errors.New("person already exists")
)The handler maps domain errors to HTTP status codes in internal/handler/errors.go. Add new errors there when needed. Do not return raw database errors from use cases.
Migrations
Forward-only only — no .down.sql files. Filename pattern:
<seq>_<description>.up.sqlWhere <seq> is a zero-padded 6-digit number. Create with:
observer migrate create <description>
# or
just migrate-create <description>Dependency Injection
All wiring happens in internal/app/container.go. The pattern:
// 1. Create repo (each domain group has its own package)
personRepo := repoperson.New(sqlxDB)
// 2. Create use case
personUC := ucproject.NewPersonUseCase(personRepo, ...)
// 3. Store in container
c.PersonUC = personUCThen in internal/server/routes.go, inject into the handler:
personHandler := projecthandler.NewPersonHandler(container.PersonUC, ...)Code Style
- No decorative comment separators (
//-----,//=====) - Docstrings only on exported symbols
- Complex logic: prefer a Mermaid diagram in a module README over inline comments
gofmtformatting enforced — runjust fmtbefore committing- Linting:
just lint(golangci-lint)