Skip to content

Dependency rules ​

These rules define how packages may depend on each other in the target modular monolith. Automated architecture tests enforce the migrated subset of these rules while transitional exceptions are removed.

Allowed dependency direction ​

text
cmd
 │
 ▼
app composition
 ├──────────────► platform implementations
 │
 ▼
module transport
 │
 ▼
module application/service
 │
 ▼
module domain

An application composition root may know all modules and concrete platform implementations. A business module may know its own packages, small shared types, and interfaces required to perform its work.

Module ownership ​

A module owns its:

  • domain types and business invariants;
  • application services and use cases;
  • HTTP DTOs, handlers, and routes;
  • persistence interfaces and implementations;
  • SQL queries and table access;
  • event definitions;
  • tests.

Code outside the module must not bypass this ownership.

Cross-module calls ​

Allowed:

text
maintenance/service
    └── calls a narrow DeviceRecords interface
            └── implemented by the devices module

Forbidden:

text
maintenance/service
    ├── imports devices/repository
    ├── imports devices/service
    ├── imports devices/dto
    └── queries devices-owned tables directly

Prefer caller-owned interfaces: the consuming module defines the smallest contract it needs. A provider exposes a stable public facade through its root module.go. Public module contracts must use purpose-built contract types, not transport DTOs or generated database rows.

Import policy ​

ImportPolicy
cmd/* to internal/app/*Allowed
internal/app/* to modules and platformAllowed
internal/app/* to internal/adapters/*Allowed — composition root wires concrete providers
Module to its own subpackagesAllowed
Module to another module's documented public API (root package)Allowed
Module to another module's reader/ sub-packageAllowed — approved seam for sharing generated types
Module to another module's dto, handler, routes, service, repository, mapper, or infrastructureForbidden
Module to platform/ interfaces or utilitiesAllowed
Module to adapters/ concrete implementationsForbidden — depend on the platform interface instead
Module to common/Allowed
Module to shared/Allowed
Domain package to Gin, pgx, Valkey, Kafka, mail, storage, or payment SDKsForbidden
Platform package to a business moduleForbidden
Shared package to a business moduleForbidden

DTO rules ​

  • HTTP request and response DTOs are private to the owning module's transport boundary.
  • DTOs are not shared between modules.
  • Application commands and results are distinct from HTTP DTOs when transport concerns would otherwise leak inward.
  • Generated database models do not leave the owning repository boundary.
  • Event payloads are versioned contracts and are not reused as HTTP DTOs.

Small duplication between module boundaries is acceptable. It prevents an unrelated HTTP change from forcing changes in another module.

Database rules ​

  • Each table and SQL query has one owning module.
  • Only the owner writes its tables.
  • Cross-module reads go through an API/port unless a documented transitional exception exists.
  • Cross-module foreign keys may remain where they protect data integrity, but they do not grant package-level ownership.
  • Cross-module transactions require an explicit design decision because they couple module lifecycles.
  • Tenant isolation applies at every repository and cache boundary.

Infrastructure rules ​

  • Environment variables are loaded at application startup, not throughout business services.
  • Modules depend on narrow capabilities such as IdempotencyStore, ObjectStore, or EventPublisher; they do not receive a general-purpose Valkey or Kafka client by default.
  • Valkey is the required production implementation for distributed rate limiting, idempotency reservations, caches, scheduler leases, and transient job coordination. Process-local implementations may be used only as test fakes or documented migration adapters.
  • PostgreSQL remains the source of truth for durable business state.
  • Cache misses and cache invalidation behaviour are part of the owning module's design.
  • Reliable asynchronous publication uses the transactional outbox.
  • Valkey-backed queues or pub/sub do not replace the outbox for work that must survive restart, eviction, or infrastructure loss.
  • Identity uses Valkey-backed refresh-session, rotation, replay-detection, revocation, and security-counter ports. PostgreSQL retains durable session and audit records; access JWT validation may remain local where an online revocation check is not required.

Events ​

  • Events describe completed business facts, not generic CRUD operations.
  • Event names and payload versions are explicit.
  • Event envelopes include event ID, tenant ID where applicable, occurrence time, correlation ID, and causation ID where available.
  • Publishing and consuming are idempotent.
  • Module event definitions contain no Kafka-specific types.

Transitional exceptions ​

During migration, old core, tenant, and lookup code will coexist with new modules. A transitional adapter may import legacy code to preserve behaviour. New modules must not expose legacy repositories or DTOs as their public API.

Every exception should identify:

  • the forbidden dependency being tolerated;
  • why it is necessary;
  • the module that will ultimately own it;
  • the extraction slice that removes it.

No new feature should add a dependency from one broad legacy domain into another when a narrow interface can be introduced instead.

Enforcement ​

Architecture tests live at internal/modules/<module>/architecture_test.go. All 13 modules have a test. Each test walks its module's source files using the Go parser and rejects any file that imports another module's internal layer (dto, handler, infrastructure, mapper, repository, routes, or service).

The reader/ sub-package is explicitly allowed: the test checks parts[1] against the forbidden layer set, and "reader" is not in that set.

The Devices module additionally enforces that its services do not depend on concrete *repository.Queries containers or *pgxpool.Pool directly.

These tests run as part of go test ./internal/modules/... and are required to pass in CI. Adding a new cross-module dependency requires either using the reader/ seam, defining a caller-owned interface, or updating the forbidden layer list with a deliberate documented exception.

Ovahol developer documentation