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 migrationsThe 13 modules
| Module | What it owns |
|---|---|
identity | Auth, users, invitations, roles, sessions |
organizations | Orgs, org membership |
facilities | Facilities, membership, departments, locations |
governance | Governance profiles, capabilities, approval policies |
devices | Device catalog, device records, images, imports |
lifecycle | Recalls, incidents, transfers, refurbishments, retirement |
maintenance | Fault reports, work orders, PM schedules |
procurement | Need requests, option appraisals, acquisitions, donations |
counterparties | Vendors, donor orgs, donor projects |
billing | Plans, subscriptions, payments, webhooks |
notifications | Notification records and email delivery |
audit | Audit event recording and queries |
reference | Stable shared lookup / reference data |
The four infrastructure layers
| Layer | Path | What goes here |
|---|---|---|
| Ports | platform/ | Provider-neutral interfaces, generic utilities (logger, retry, metrics, session, cache…) |
| Implementations | adapters/ | Concrete providers: Resend (mail), R2 (storage), Paystack (payment) |
| App-level shared | common/ | Ovahol-specific shared code: error taxonomy, RBAC role constants, plan codes, HTTP response envelope |
| Cycle-breakers | shared/ | 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
// ✅ 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
// ✅ 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 manuallyDo 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.sqlFollow the naming style used in existing query files:
-- 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:
- 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:
- db_type: "timestamptz"
go_type:
import: "time"
type: "Time"Step 4 — Generate the repository
From the repository root:
make sqlcThis 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:
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.
// 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"`
}// 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:
// 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))
}// 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.
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:
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.goasModule/NewFromPool(pool). Registered incore_bootstrap.go. - Operational vertical slice (work orders, PM schedules, fault reports) — tenant-schema, many cross-module deps. Lives in
work_orders.goasWorkOrders/RegisterWorkOrders(rg, WorkOrdersDependencies{...}). Registered intenant_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:
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).
// 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
make migrate-new name=create_widgets_tableWrite the up and down SQL, then run:
make migrateCross-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.
// 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:
// 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:
// 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
- Write SQL in
repository/queries/<module>/. - Add or update the entry in
repository/sqlc.yml. - Run
make sqlcfrom the backend root — do NOT edit generated files. - If a generated type uses
pgtype.Timestamptzbut your DTO usestime.Time, add thetimestamptzoverride insqlc.yml:yamloverrides: - db_type: "timestamptz" go_type: import: "time" type: "Time" - If a generated type uses
pgtype.UUIDbut your code usesuuid.UUID, add theuuidoverride:yamloverrides: - db_type: "uuid" go_type: import: "github.com/google/uuid" type: "UUID" - For cross-module zero-uuid comparisons, use the sentinel pattern (
= '00000000-0000-0000-0000-000000000000'::uuid) rather thanIS NULLwhen the sqlc uuid override is active. - If you need a query that sqlc cannot express (dynamic filters, CTEs that exceed sqlc's support), write it in a
_extra.gofile in the samerepository/package. Seeinternal/modules/identity/repository/organization_membership_extra.go.
Architecture tests
Every module has architecture_test.go in its root directory. Run them all:
go test ./internal/modules/... -run DoesNotImportOtherModuleInternals -vThe 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
// ❌ 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
// ❌ identity/service/auth_repository_adapter.go
// holds *orgRepo.Queries, *facRepo.Queries — cross-module concrete typesAdapters 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
// ❌ 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 bootstrapReading environment variables inside a service
// ❌
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/andinternal/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 fromcommon/→platform/ - Architecture docs updated to reflect current state
Near-term improvements
lookupmodule cleanup — thereferencemodule was seeded from the oldlookupdomain. Some reference data still exposes legacy routes. Audit what should move to the owning module versus stay inreference.Architecture test for service layer — extend the devices pattern (
TestDevicesServicesUsePortsInsteadOfConcreteQueryContainers) to all modules: services must not depend on*repository.Queriesor*pgxpool.Pooldirectly.Tenant-scoped module repositories — some modules still use a shared
TenantRepositorywrapper for tenant-schema access. Each module should own its tenant-schema query loading independently.Worker and scheduler composition —
internal/app/worker/andinternal/app/scheduler/need the same modular composition treatment thatinternal/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/messagingis broker-neutral. When throughput or fan-out requirements justify it, add a Kafka adapter behind the existingEventPublisherinterface 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.