Skip to content

Contributor guide ​

This document is the starting point for anyone adding new code to the Ovahol backend. It covers the module structure, import rules, SQL workflow, and the exact steps to follow when introducing a new module or a cross-module dependency.

Read modular-monolith.md and dependency-rules.md alongside this guide for the architectural reasoning behind these rules.


Codebase orientation ​

backend/
├── cmd/                    ← process entry points (api, worker, scheduler)
├── internal/
│   ├── app/
│   │   └── api/            ← composition root: wires modules, infra, routes
│   ├── modules/            ← 13 business modules (see module map)
│   ├── middleware/         ← HTTP middleware (JWT auth, RBAC, tenant isolation)
│   ├── platform/           ← infrastructure ports + generic utilities
│   ├── adapters/           ← concrete provider implementations
│   ├── common/             ← Ovahol-specific shared code (errors, roles, etc.)
│   ├── shared/             ← cycle-breaking value types only
│   ├── database/           ← DB pool, migrations, tenant-schema service
│   └── tenantctx/          ← tenant context helpers
└── repository/
    ├── sqlc.yml            ← sqlc config (one entry per module)
    ├── queries/            ← SQL query files (one folder per module)
    └── migrations/         ← schema migrations

The 13 modules ​

ModuleWhat it owns
identityAuth, users, invitations, roles, sessions
organizationsOrgs, org membership
facilitiesFacilities, membership, departments, locations
governanceGovernance profiles, capabilities, approval policies
devicesDevice catalog, device records, images, imports
lifecycleRecalls, incidents, transfers, refurbishments, retirement
maintenanceFault reports, work orders, PM schedules
procurementNeed requests, option appraisals, acquisitions, donations
counterpartiesVendors, donor orgs, donor projects
billingPlans, subscriptions, payments, webhooks
notificationsNotification records and email delivery
auditAudit event recording and queries
referenceStable shared lookup / reference data

The four infrastructure layers ​

LayerPathWhat goes here
Portsplatform/Provider-neutral interfaces, generic utilities (logger, retry, metrics, session, cache…)
Implementationsadapters/Concrete providers: Resend (mail), R2 (storage), Paystack (payment)
App-level sharedcommon/Ovahol-specific shared code: error taxonomy, RBAC role constants, plan codes, HTTP response envelope
Cycle-breakersshared/Stable value types with no module owner, used only to avoid import cycles

The golden rules ​

These are the rules the architecture tests enforce. Violating them fails CI.

1. A module may only import another module's root package or reader/ package ​

go
// ✅ Allowed — root package (public API)
import facilitiesModule "github.com/ovahol/ovahol/internal/modules/facilities"

// ✅ Allowed — reader/ package (approved type-sharing seam)
import facReader "github.com/ovahol/ovahol/internal/modules/facilities/reader"

// ❌ Forbidden — internal layers
import "github.com/ovahol/ovahol/internal/modules/facilities/repository"
import "github.com/ovahol/ovahol/internal/modules/facilities/service"
import "github.com/ovahol/ovahol/internal/modules/facilities/dto"
import "github.com/ovahol/ovahol/internal/modules/facilities/handler"
import "github.com/ovahol/ovahol/internal/modules/facilities/mapper"
import "github.com/ovahol/ovahol/internal/modules/facilities/routes"

2. Modules depend on platform interfaces, never on concrete adapters ​

go
// ✅ Allowed — narrow interface from platform
import "github.com/ovahol/ovahol/internal/platform/messaging"

// ❌ Forbidden — concrete implementation
import "github.com/ovahol/ovahol/internal/adapters/mail"

Concrete adapters are constructed in internal/app/api/ and injected as interface types. Modules never know which provider is running.

3. Composition happens in internal/app/api/, nowhere else ​

Modules do not construct other modules. Environment variables are read at startup, not inside services. Cross-module adapters live in app/api/, not inside any module's service layer.

4. DTOs do not cross module boundaries ​

HTTP request/response types are private to their module. If two modules need the same shape, duplicate it. Small duplication is far cheaper than accidental coupling through a shared DTO package.

5. Generated database row types stay inside their module ​

A service in maintenance must not receive a facilities/repository.GetFacilityByIDRow parameter directly. Use the reader/ pattern (see below) or define a caller-owned interface with only the fields you need.


Adding a new module: step by step ​

Use notifications as your reference — it is the simplest complete module.

Step 1 — Create the directory structure ​

Only create packages you actually need. Start minimal.

internal/modules/widgets/
├── module.go              ← public facade (always required)
├── architecture_test.go   ← boundary enforcement (always required)
├── doc.go                 ← one-paragraph description (always required)
├── dto/
│   └── widget.go
├── handler/
│   └── widget.go
├── routes/
│   └── widget.go
├── service/
│   └── widget.go
├── mapper/
│   └── widget.go
└── repository/            ← generated by sqlc; do not write manually

Do not create a package just because the convention lists it. If your module has no mapper, skip the directory.

Step 2 — Write SQL queries ​

Create a queries folder in repository/queries/:

repository/queries/widgets/
└── widget.sql

Follow the naming style used in existing query files:

sql
-- name: GetWidget :one
SELECT id, name, facility_id, created_at
FROM tenant.widgets
WHERE id = @id;

-- name: ListWidgets :many
SELECT id, name, facility_id, created_at
FROM tenant.widgets
WHERE facility_id = @facility_id
ORDER BY created_at DESC;

-- name: CreateWidget :one
INSERT INTO tenant.widgets (id, name, facility_id)
VALUES (@id, @name, @facility_id)
RETURNING *;

Step 3 — Add a sqlc entry ​

Open repository/sqlc.yml and add a new sql block:

yaml
- engine: "postgresql"
  queries: "queries/widgets"
  schema: "./migrations"
  gen:
    go:
      emit_json_tags: true
      package: "repository"
      out: "../internal/modules/widgets/repository"
      sql_package: "pgx/v5"
      overrides:
        - db_type: "uuid"
          go_type:
            import: "github.com/google/uuid"
            type: "UUID"

Add the timestamptz override if your queries return timestamp columns:

yaml
        - db_type: "timestamptz"
          go_type:
            import: "time"
            type: "Time"

Step 4 — Generate the repository ​

From the repository root:

bash
make sqlc

This generates internal/modules/widgets/repository/ with db.go, models.go, and widget.sql.go. Do not edit these files manually.

If you need behaviour that sqlc cannot express (e.g. a dynamic filter), add a _extra.go file in the same package. See internal/modules/identity/repository/organization_membership_extra.go for an example.

Step 5 — Write the service ​

Define a narrow interface for everything the service receives from outside:

go
package service

import (
    "context"
    "github.com/google/uuid"
    widgetRepo "github.com/ovahol/ovahol/internal/modules/widgets/repository"
)

// WidgetRepository is the narrow port the service depends on.
// It is satisfied by *widgetRepo.Queries but the service never names that type.
type WidgetRepository interface {
    GetWidget(ctx context.Context, id uuid.UUID) (widgetRepo.GetWidgetRow, error)
    ListWidgets(ctx context.Context, facilityID uuid.UUID) ([]widgetRepo.ListWidgetsRow, error)
    CreateWidget(ctx context.Context, arg widgetRepo.CreateWidgetParams) (widgetRepo.CreateWidgetRow, error)
}

type widgetService struct {
    repo WidgetRepository
}

func NewWidgetService(repo WidgetRepository) *widgetService {
    return &widgetService{repo: repo}
}

The service may import its own module's packages freely. It must not import another module's repository, service, dto, handler, mapper, or routes.

Step 6 — Write DTOs and mapper ​

DTOs belong in widgets/dto/. They are plain structs — no database types, no Gin types. The mapper converts between the generated repository rows and the DTOs.

go
// dto/widget.go
package dto

type WidgetResponse struct {
    ID         string `json:"id"`
    Name       string `json:"name"`
    FacilityID string `json:"facility_id"`
    CreatedAt  string `json:"created_at"`
}
go
// mapper/widget.go
package mapper

import (
    "github.com/ovahol/ovahol/internal/modules/widgets/dto"
    widgetRepo "github.com/ovahol/ovahol/internal/modules/widgets/repository"
)

func ToWidgetResponse(row widgetRepo.GetWidgetRow) dto.WidgetResponse {
    return dto.WidgetResponse{
        ID:         row.ID.String(),
        Name:       row.Name,
        FacilityID: row.FacilityID.String(),
        CreatedAt:  row.CreatedAt.Format(time.RFC3339),
    }
}

Step 7 — Write handler and routes ​

Handlers receive a service interface, never a concrete service type:

go
// handler/widget.go
package handler

import (
    "net/http"
    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/ovahol/ovahol/internal/common/response"
    "github.com/ovahol/ovahol/internal/modules/widgets/dto"
    "github.com/ovahol/ovahol/internal/modules/widgets/service"
)

type WidgetHandler struct {
    svc service.WidgetService
}

func NewWidgetHandler(svc service.WidgetService) *WidgetHandler {
    return &WidgetHandler{svc: svc}
}

func (h *WidgetHandler) Get(c *gin.Context) {
    id, err := uuid.Parse(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, response.Error("invalid id"))
        return
    }
    widget, err := h.svc.Get(c.Request.Context(), id)
    if err != nil {
        c.JSON(http.StatusInternalServerError, response.Error(err.Error()))
        return
    }
    c.JSON(http.StatusOK, response.OK(widget))
}
go
// routes/widget.go
package routes

import (
    "github.com/gin-gonic/gin"
    "github.com/ovahol/ovahol/internal/modules/widgets/handler"
)

func RegisterWidgetRoutes(rg *gin.RouterGroup, h *handler.WidgetHandler) {
    g := rg.Group("/widgets")
    g.GET("/:id", h.Get)
    g.GET("", h.List)
    g.POST("", h.Create)
}

Step 8 — Write module.go ​

The module facade is the only public API surface. Bootstrap imports this; nothing else should.

go
package widgets

import (
    "github.com/gin-gonic/gin"
    "github.com/jackc/pgx/v5/pgxpool"

    "github.com/ovahol/ovahol/internal/modules/widgets/handler"
    widgetRepo "github.com/ovahol/ovahol/internal/modules/widgets/repository"
    "github.com/ovahol/ovahol/internal/modules/widgets/routes"
    "github.com/ovahol/ovahol/internal/modules/widgets/service"
)

type Module struct {
    handler *handler.WidgetHandler
}

func New(queries *widgetRepo.Queries) *Module {
    svc := service.NewWidgetService(queries)
    return &Module{handler: handler.NewWidgetHandler(svc)}
}

// NewFromPool is a convenience constructor for the common case where
// the module owns a simple public-schema repository.
func NewFromPool(pool *pgxpool.Pool) *Module {
    return New(widgetRepo.New(pool))
}

func (m *Module) RegisterRoutes(rg *gin.RouterGroup) {
    routes.RegisterWidgetRoutes(rg, m.handler)
}

If your module depends on other modules or platform services, use a Dependencies struct instead of individual parameters:

go
type Dependencies struct {
    Queries     *widgetRepo.Queries
    AuditLogger *middleware.AuditLogger
    Mailer      mail.Mailer         // platform interface, not adapters type
}

func New(deps Dependencies) *Module { ... }

One module, multiple surfaces ​

A single module may have more than one public surface with different dependency profiles. In that case, give each surface its own file and its own constructor — module.go does not have to carry everything.

maintenance is the reference example. Its two surfaces are:

  • Reference data (failure codes, work order types) — public-schema, DB only. Lives in module.go as Module / NewFromPool(pool). Registered in core_bootstrap.go.
  • Operational vertical slice (work orders, PM schedules, fault reports) — tenant-schema, many cross-module deps. Lives in work_orders.go as WorkOrders / RegisterWorkOrders(rg, WorkOrdersDependencies{...}). Registered in tenant_bootstrap.go.

The rule is the same: use a Dependencies struct whenever a surface has external deps. The struct just lives on the file that owns that surface, not always on module.go.

Step 9 — Add the architecture test ​

Copy this file verbatim into internal/modules/widgets/architecture_test.go and replace widgets / Widgets:

go
package widgets_test

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
    "io/fs"
    "path/filepath"
    "runtime"
    "strconv"
    "strings"
    "testing"
)

const moduleImportPrefix = "github.com/ovahol/ovahol/internal/modules/"

var internalLayerNames = map[string]bool{
    "dto":            true,
    "handler":        true,
    "infrastructure": true,
    "mapper":         true,
    "repository":     true,
    "routes":         true,
    "service":        true,
}

func TestWidgetsDoesNotImportOtherModuleInternals(t *testing.T) {
    moduleDir := widgetsModuleDir(t)

    walkGoFiles(t, moduleDir, func(path string, file *ast.File, imports map[string]string) {
        for _, importPath := range imports {
            if !strings.HasPrefix(importPath, moduleImportPrefix) {
                continue
            }
            relative := strings.TrimPrefix(importPath, moduleImportPrefix)
            parts := strings.Split(relative, "/")
            if len(parts) < 2 || parts[0] == "widgets" {
                continue
            }
            if internalLayerNames[parts[1]] {
                t.Errorf("%s imports another module's internal layer %q; use that module's root API or reader/ package", relativePath(moduleDir, path), importPath)
            }
        }
    })
}

func walkGoFiles(t *testing.T, root string, inspect func(string, *ast.File, map[string]string)) {
    t.Helper()
    err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
        if walkErr != nil {
            return walkErr
        }
        if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
            return nil
        }
        fileSet := token.NewFileSet()
        file, err := parser.ParseFile(fileSet, path, nil, 0)
        if err != nil {
            return fmt.Errorf("parse %s: %w", path, err)
        }
        imports := make(map[string]string, len(file.Imports))
        for _, spec := range file.Imports {
            importPath, err := strconv.Unquote(spec.Path.Value)
            if err != nil {
                return fmt.Errorf("unquote import in %s: %w", path, err)
            }
            alias := filepath.Base(importPath)
            if spec.Name != nil {
                alias = spec.Name.Name
            }
            imports[alias] = importPath
        }
        inspect(path, file, imports)
        return nil
    })
    if err != nil {
        t.Fatal(err)
    }
}

func widgetsModuleDir(t *testing.T) string {
    t.Helper()
    _, filename, _, ok := runtime.Caller(0)
    if !ok {
        t.Fatal("resolve widgets module directory")
    }
    return filepath.Dir(filename)
}

func relativePath(root, path string) string {
    relative, err := filepath.Rel(root, path)
    if err != nil {
        return path
    }
    return relative
}

Step 10 — Wire into bootstrap ​

Open internal/app/api/core_bootstrap.go (for public-schema modules) or internal/app/api/tenant_bootstrap.go (for tenant-schema modules).

go
// In RegisterCore():
widgetsAPI := widgetsModule.NewFromPool(db)

// In the private routes block:
widgetsAPI.RegisterRoutes(private)

Run go build ./... to confirm everything compiles.

Step 11 — Add a migration ​

bash
make migrate-new name=create_widgets_table

Write the up and down SQL, then run:

bash
make migrate

Cross-module dependencies ​

Decision tree ​

You need data or behaviour owned by another module.
│
├── Is it a one-off read of a few fields?
│       → Define a caller-owned interface in your service package.
│         The interface lives in YOUR module, not the provider's.
│
├── Do you need to pass a generated row type as a parameter?
│       → Use the provider module's reader/ package (type alias).
│         Do NOT import the provider's repository/ directly.
│
├── Is the type needed by 3+ modules and has no clear owner?
│       → Consider adding it to shared/ (keep this package tiny).
│
└── Do you need to call the provider's service methods?
        → Depend on the provider's root-package interface, implemented
          by the provider module. Wire the adapter in app/api/.

Pattern A — caller-owned interface ​

Your module defines the exact contract it needs. The provider satisfies it without knowing your module exists.

go
// maintenance/service/work_order.go

// DeviceRecords is a narrow interface over the Devices module capabilities
// that WorkOrderService actually needs.
type DeviceRecords interface {
    GetDeviceRecordByID(ctx context.Context, id uuid.UUID) (DeviceRecordInfo, error)
    UpdateDeviceRecordStatus(ctx context.Context, id uuid.UUID, status string) error
}

// DeviceRecordInfo carries only the fields maintenance needs.
// It is NOT a devices DTO or repository row.
type DeviceRecordInfo struct {
    ID         uuid.UUID
    Name       string
    FacilityID uuid.UUID
    Status     string
}

If devices.FacilityDeviceRecords already has matching method signatures, Go's implicit interface satisfaction means you pass it directly — no adapter needed. A bootstrap adapter is only required when you need to compose multiple types into one interface, or when signatures genuinely differ and a translation layer is needed (see Pattern C below).

Pattern B — the reader/ package ​

When a module needs to receive or return generated database row types from another module's repository (typically for write-path params like CreateFacilityParams), use the provider module's reader/ sub-package.

reader/ contains only Go type aliases — no logic, no new types:

go
// facilities/reader/reader.go
package reader

import facRepo "github.com/ovahol/ovahol/internal/modules/facilities/repository"

type (
    GetFacilityByIDRow              = facRepo.GetFacilityByIDRow
    CreateFacilityParams            = facRepo.CreateFacilityParams
    CreateFacilityRow               = facRepo.CreateFacilityRow
    ListFacilitiesByOrganizationRow = facRepo.ListFacilitiesByOrganizationRow
)

Because = aliases are identical types, no conversion is needed:

go
// identity/service/auth.go
import facReader "github.com/ovahol/ovahol/internal/modules/facilities/reader"

// facReader.CreateFacilityParams IS facRepo.CreateFacilityParams — no cast needed
row, err := qtx.CreateFacility(ctx, facReader.CreateFacilityParams{...})

When to add types to an existing reader/: if you need a type that is not yet aliased there, add it to the provider module's reader/reader.go.

When to create a new reader/: when your module's repository types are needed by another module and the module does not have a reader/ yet, create internal/modules/yourmodule/reader/reader.go following the pattern above.

reader/ is NOT for interfaces. It only aliases concrete generated types. For interfaces, use Pattern A.

Pattern C — bootstrap adapters ​

When two modules' types are structurally similar but not identical, or when you need to compose multiple module repositories to satisfy one interface, write an adapter in internal/app/api/.

See internal/app/api/queries_adapter.go and identity_adapter.go for existing examples. Adapters are private to the api package; they are the only place that may import multiple modules' repository packages at once.


SQL and sqlc workflow ​

  1. Write SQL in repository/queries/<module>/.
  2. Add or update the entry in repository/sqlc.yml.
  3. Run make sqlc from the backend root — do NOT edit generated files.
  4. If a generated type uses pgtype.Timestamptz but your DTO uses time.Time, add the timestamptz override in sqlc.yml:
    yaml
    overrides:
      - db_type: "timestamptz"
        go_type:
          import: "time"
          type: "Time"
  5. If a generated type uses pgtype.UUID but your code uses uuid.UUID, add the uuid override:
    yaml
    overrides:
      - db_type: "uuid"
        go_type:
          import: "github.com/google/uuid"
          type: "UUID"
  6. For cross-module zero-uuid comparisons, use the sentinel pattern (= '00000000-0000-0000-0000-000000000000'::uuid) rather than IS NULL when the sqlc uuid override is active.
  7. If you need a query that sqlc cannot express (dynamic filters, CTEs that exceed sqlc's support), write it in a _extra.go file in the same repository/ package. See internal/modules/identity/repository/organization_membership_extra.go.

Architecture tests ​

Every module has architecture_test.go in its root directory. Run them all:

bash
go test ./internal/modules/... -run DoesNotImportOtherModuleInternals -v

The test walks all non-test .go files in the module and fails if any file imports <other_module>/{dto,handler,infrastructure,mapper,repository,routes,service}.

reader/ imports pass because "reader" is not in the forbidden set.

Add the test before wiring into bootstrap. This way CI catches violations immediately rather than after the module has accumulated callers.


Common mistakes ​

Importing another module's repository directly ​

go
// ❌ maintenance/service/work_order.go
import facRepo "github.com/ovahol/ovahol/internal/modules/facilities/repository"

Fix: import facilities/reader for generated types, or define a caller-owned interface for behaviour.

Putting the adapter inside a module's service package ​

go
// ❌ identity/service/auth_repository_adapter.go
// holds *orgRepo.Queries, *facRepo.Queries — cross-module concrete types

Adapters that hold multiple modules' *Queries structs belong in internal/app/api/. Service packages must not know about other modules' concrete repository types.

Using a platform interface but importing the adapter ​

go
// ❌ maintenance/service/work_order.go
import "github.com/ovahol/ovahol/internal/adapters/mail"

// ✅ correct
import "github.com/ovahol/ovahol/internal/platform/..."
// receive mail.Mailer (or a narrower interface) injected from bootstrap

Reading environment variables inside a service ​

go
// ❌
func (s *widgetService) doSomething() {
    secret := os.Getenv("WIDGET_SECRET")
}

Environment variables are loaded once at startup in platform/config or common/config. Pass configuration as a typed struct to the module constructor.

Creating a new module per handler or table ​

A module is a business capability, not a technical slice. Group related handlers under one module (maintenance owns work orders, PM schedules, and fault reports — not three separate modules).


Roadmap ​

Done ✅ ​

  • All 13 business modules migrated from the legacy domains/ structure
  • Old internal/domains/ and internal/repository/ packages deleted
  • Architecture tests in all 13 modules, all green
  • reader/ pattern established and documented
  • Bootstrap adapters consolidated in internal/app/api/
  • Shared governance value types in internal/shared/governance/
  • Infrastructure renamed: infra/ → adapters/, generic utilities moved from common/ → platform/
  • Architecture docs updated to reflect current state

Near-term improvements ​

  • lookup module cleanup — the reference module was seeded from the old lookup domain. Some reference data still exposes legacy routes. Audit what should move to the owning module versus stay in reference.

  • Architecture test for service layer — extend the devices pattern (TestDevicesServicesUsePortsInsteadOfConcreteQueryContainers) to all modules: services must not depend on *repository.Queries or *pgxpool.Pool directly.

  • Tenant-scoped module repositories — some modules still use a shared TenantRepository wrapper for tenant-schema access. Each module should own its tenant-schema query loading independently.

  • Worker and scheduler composition — internal/app/worker/ and internal/app/scheduler/ need the same modular composition treatment that internal/app/api/ received.

  • Event-driven notifications — notification delivery is currently triggered inline from service calls. Move to the transactional outbox (platform/messaging) so delivery retries survive process restarts.

Longer-term ​

  • Kafka adapter — platform/messaging is broker-neutral. When throughput or fan-out requirements justify it, add a Kafka adapter behind the existing EventPublisher interface without changing module code.

  • Process separation — the API, worker, and scheduler are wired to share modules and release as one binary. Process separation (three binaries reading from the same release) is possible without changing module code because composition is already isolated in internal/app/.

  • Per-module database schemas — the architecture supports physical database separation per module when operational requirements justify it. Today all modules share the same PostgreSQL deployment. Moving a module to its own schema or database is a bootstrap change, not a module change, because modules never reference each other's table names.

Ovahol developer documentation