Modular monolith
Objective
Keep Ovahol as one product and one coordinated backend codebase while giving each business capability explicit ownership of its behaviour, data access, and delivery adapters.
The architecture should make module boundaries visible in code, prevent accidental cross-module coupling, support separate API, worker, and scheduler processes, and allow infrastructure adapters to evolve without rewriting business logic. Valkey is a required runtime dependency; Kafka remains a deferred adapter.
Migration status
The migration from the legacy layer-first structure (internal/domains/core, internal/domains/tenant, internal/domains/lookup) to the module-first structure is complete. All 13 business modules are in place under internal/modules/. The old domain packages have been deleted.
Cross-module coupling is enforced by architecture tests in every module. No module currently imports another module's internal layer.
Target structure
The Go module remains under backend. Go executable entry points remain under cmd, while their composition logic lives under internal/app.
backend/
├── cmd/
│ ├── api/
│ ├── worker/
│ └── scheduler/
├── internal/
│ ├── app/
│ │ ├── api/
│ │ ├── worker/
│ │ └── scheduler/
│ ├── modules/
│ │ ├── identity/
│ │ ├── organizations/
│ │ ├── facilities/
│ │ ├── governance/
│ │ ├── devices/
│ │ ├── lifecycle/
│ │ ├── maintenance/
│ │ ├── procurement/
│ │ ├── counterparties/
│ │ ├── billing/
│ │ ├── notifications/
│ │ ├── audit/
│ │ └── reference/
│ ├── platform/ ← infrastructure ports + generic cross-cutting utilities
│ │ ├── config/
│ │ ├── database/
│ │ ├── cache/
│ │ ├── session/
│ │ ├── idempotency/
│ │ ├── messaging/
│ │ ├── ratelimit/
│ │ ├── lease/
│ │ ├── securitycounter/
│ │ ├── distributedstate/
│ │ ├── logger/
│ │ ├── metrics/
│ │ ├── retry/
│ │ ├── circuitbreaker/
│ │ └── observability/
│ ├── adapters/ ← concrete provider implementations
│ │ ├── mail/
│ │ ├── storage/
│ │ ├── payment/
│ │ └── fx/
│ ├── common/ ← Ovahol-specific shared application code
│ │ ├── errors/
│ │ ├── roles/
│ │ ├── plans/
│ │ ├── features/
│ │ ├── config/
│ │ ├── response/
│ │ ├── utils/
│ │ └── pagination.go
│ └── shared/ ← stable value types with no module owner
└── tests/The repository may continue to contain separately built frontend applications. The term modular monolith in these documents specifically describes the Ovahol backend and its business modules.
Module shape
A module owns a complete vertical slice. For example:
internal/modules/devices/
├── module.go ← public API: New(), Dependencies struct, RegisterRoutes()
├── dto/
├── handler/
├── routes/
├── service/
├── repository/
├── mapper/
└── reader/ ← thin type-alias layer for cross-module consumptionThese directory names are a starting convention, not a requirement to create empty layers. A module should only create packages that it needs. If the module grows substantially, it may introduce cohesive subpackages such as catalog, records, and lifecycle within the module boundary.
The important ownership rule is that device DTOs, routes, handlers, services, repositories, queries, domain types, and tests belong to the devices module. They do not belong to global DTO or repository packages.
The reader/ sub-package
A module may expose a reader/ sub-package containing only Go type aliases (type Foo = repo.Foo) pointing at its repository-generated types. This is the approved mechanism for sharing database row and parameter types with another module without breaking the boundary rule that forbids importing a module's repository/ package directly.
facilities/reader/reader.go
type GetFacilityByIDRow = facRepo.GetFacilityByIDRow
type CreateFacilityParams = facRepo.CreateFacilityParams
type ListFacilitiesByOrganizationRow = facRepo.ListFacilitiesByOrganizationRow
...Because these are true aliases (=), no conversion is needed at call sites. The consuming module imports facilities/reader, not facilities/repository. The architecture tests treat reader as an allowed cross-module import target.
Application composition
internal/app is the composition boundary. It constructs infrastructure, constructs modules, supplies module dependencies, registers transports, and controls process lifecycle.
cmd/api
└── internal/app/api
├── devices module
├── maintenance module
├── identity module
└── platform implementationsModules do not construct other modules and do not read process environment variables. Configuration and concrete infrastructure are supplied at startup.
The API, worker, and scheduler may run as separate operating-system processes while sharing the same modules and release. This remains a modular monolith; process separation does not make the modules microservices.
Data ownership
Initially, modules continue to share the existing PostgreSQL deployment and tenant-schema strategy. Physical database separation is not required for modularity.
Logical ownership is required:
- each table and query has one owning module;
- only the owning module writes its tables;
- another module obtains data through the owner's public API or a narrow port;
- cross-module transactions are exceptional and documented;
- migrations remain coordinated with the Ovahol release.
Existing direct cross-domain queries may remain temporarily while a capability is being migrated. Transitional access must not become the pattern for new features.
Communication
Use the least complex communication mechanism that preserves ownership:
- A direct in-process call through a narrow interface for immediate reads or decisions.
- An in-process event for a non-critical reaction within the same process.
- A PostgreSQL transactional outbox for reliable asynchronous reactions.
- Kafka only when an independently deployed consumer, throughput, replay, or operational requirement justifies it.
Business modules must not depend on Gin, Valkey, Kafka, or a specific mail, storage, or payment provider unless the code is an adapter contained inside the module or platform layer.
Valkey is required platform infrastructure for distributed rate limiting, idempotency, caching, scheduler leases, and transient job coordination. The business capability remains owned by its application or module: for example, the scheduler decides when preventive-maintenance reminders are due while a Valkey lease prevents multiple scheduler replicas from executing the same due run.
Migration principles
- Move one coherent vertical slice at a time.
- Preserve public HTTP paths and payloads during structural migrations.
- Avoid simultaneous package, database-schema, and API redesigns.
- Add a boundary test before relying on directory conventions alone.
- Delete transitional packages once their final consumers have moved.
- Do not create one module per handler or service.
- Prefer explicit duplication of small transport types over sharing unstable DTOs between modules.
- Keep
sharedsmall; business concepts belong to their owning modules.
Completion criteria
The migration is complete. All criteria are met:
- ✓ No business capability remains in broad
core,tenant, orlookupcontainers - ✓ Every capability has a named owning module (13 modules)
- ✓ Cross-module imports follow dependency rules
- ✓ API, worker, and scheduler composition happens under
internal/app - ✓ Business modules depend on infrastructure through narrow interfaces
- ✓ Module boundaries are enforced by architecture tests in all 13 modules