This guide is intended for AI agents (Copilot, Cursor, Claude, Gemini etc.) working in the Domus repository.
- Strict Scope: Code only what is explicitly requested. No extra files, no unsolicited features.
- Stop & Ask: If anything is unclear, stop and ask. Zero assumptions.
- Brief: Keep explanations concise and focused.
- Read First: State which
AGENTS.mdsection and quote the relevant issue instruction before coding. - Verify Imports: Only import from paths that explicitly exist in the codebase or issue.
- ALWAYS follow
AGENTS.mdand focus on the current issue only.
MANDATORY: Before providing any code response, verify your implementation against this checklist.
- No
any: Ensure noexplicit anyis used. Useunknown, generics, or search@domus/corefor the correct type. - No
shadcnedits: NEVER touch files inapps/dash/src/shared/ui/shadcn/. Perform customizations by creating wrapper components or extending styles in other directories. - Result Pattern: Confirm all services return
Result<T>viaok()/fail()and never throw. - Server Actions: Return Go-style
Result<T>via[data, error]tuple and never throw. - Type-Only Imports: Check that all type imports use
import type. - TSDoc: Verify all exported symbols have a clear TSDoc summary.
- Soft Delete: Ensure
deletedAt IS NULLis handled in database queries. - Auth Responsibility: Check authentication in Proxy/Action and authorization in Service.
- Thin Exports: Ensure route components are logic-less exports.
- Naming: Use entity-only names for services/repositories (no
.service.ts). - Zod Best Practices: Use top-level Zod functions (
z.uuid(),z.uuidv7(),z.email(),z.url()) instead of chained string methods (z.string().uuid(), etc.) to support better tree-shaking and follow Zod 4 conventions.
Domus is a Progressive Web App (PWA) for digitizing the administration of Christ the King Parish Barong Tongkok — covering attendance tracking for organizational activities, financial bookkeeping, and parish membership management.
See → docs/prd.md
Monolith built on Next.js 16 App Router within a Turborepo monorepo and PNPM as package manager. All business logic is isolated in packages/core (Clean Architecture). The main app at apps/dash follows Feature-Sliced Design (FSD).
See → docs/tdd.md — System Overview · Tech Stack · Monorepo Architecture
See → docs/tdd.md — Naming Conventions
Key rules for file naming:
- Service files: name by entity only, no suffix —
service/parishioner.ts✅,❌service/parishioner.service.ts - Repository files: name by entity only, no suffix —
repository/parishioner.ts✅,❌repository/parishioner.repository.ts - React component files:
PascalCase(e.g.,ParishionerCard.tsx) - All other files:
kebab-caseas usual
See → docs/tdd.md — Clean Architecture
Services must not throw exceptions. All services return Result<T> via ok() / fail().
See → docs/tdd.md — Result Pattern
Server Actions must not throw exceptions — always return Result<T> (Go-style tuple) from @domus/core. Actions are responsible for:
- Fetching session using
getAuthSession(). - Handling errors from
getAuthSession()and returningfail(error). - Constructing
AuthContextfrom session. - Calling service and returning its
Result.
See → docs/tdd.md — Error Handling in Server Actions
Use the const object + as const pattern. Do not use TypeScript enum.
See → docs/tdd.md — Enum Conventions
All files inside app/ (pages and route handlers) must only contain thin exports — no wrappers, no logic.
See → docs/tdd.md — apps/dash Structure
These rules are enforced via Biome and apply across all packages and apps in the monorepo.
Never use any explicitly. Use proper types, generics, or unknown instead.
// ✅ Correct
function parse(input: unknown): string { ... }
// ❌ Wrong
function parse(input: any): string { ... }Always use import type when importing types or interfaces. This ensures they are erased at compile time and never included in the bundle.
// ✅ Correct
import type { Parishioner } from '@domus/core/entity/parishioner'
import type { AuthContext } from '@domus/core/entity/auth-context'
// ❌ Wrong
import { Parishioner } from '@domus/core/entity/parishioner'Exception: If a module export is used both as a value and a type in the same file, a regular import is acceptable.
Remove all unused imports. Biome will error on any import that is not referenced in the file.
// ✅ Correct — all imports are used
import { ok, fail } from '@domus/core/utils'
import type { Result } from '@domus/core/utils'
// ❌ Wrong — CoreError is imported but never used
import { ok, fail } from '@domus/core/utils'
import type { Result } from '@domus/core/utils'
import { CoreError } from '@domus/core/error'Imports are auto-organized by Biome via assist.actions.source.organizeImports. No manual ordering needed — just run biome check --write.
- Vitest — test runner for all packages.
- vitest-mock-extended — for mocking interfaces and classes.
Test files are colocated with the source file they test, using the .spec.ts suffix.
packages/core/src/error/core-error.ts
packages/core/src/error/error.spec.ts ✅
packages/core/src/service/parishioner.service.ts
packages/core/src/service/parishioner.spec.ts ✅
Use mock() to create typed mocks of repository interfaces:
import { mock } from 'vitest-mock-extended'
import type { IParishionerRepository } from '../contract/parishioner'
const repo = mock<IParishionerRepository>()
repo.findById.mockResolvedValue({
id: '1',
fullName: 'Budi Santoso',
// ...
})// packages/core/src/service/parishioner.spec.ts
import { describe, expect, it } from 'vitest'
import { mock } from 'vitest-mock-extended'
import type { IParishionerRepository } from '../contract/parishioner'
import { NotFoundError } from '../error'
import { createParishionerService } from './parishioner'
describe('ParishionerService', () => {
const repo = mock<IParishionerRepository>()
const service = createParishionerService(repo)
it('should return parishioner when found', async () => {
repo.findById.mockResolvedValue({ id: '1', fullName: 'Budi Santoso', userId: null, /* ... */ })
const [result, error] = await service.getParishioner('1')
expect(error).toBeNull()
expect(result?.fullName).toBe('Budi Santoso')
})
it('should return NotFoundError when parishioner does not exist', async () => {
repo.findById.mockResolvedValue(null)
const [result, error] = await service.getParishioner('999')
expect(result).toBeNull()
expect(error).toBeInstanceOf(NotFoundError)
})
})- Always mock repository interfaces — never use real database connections in unit tests.
- Each
describeblock focuses on one service or module. - Test both the happy path and error paths (e.g.
NotFoundError,ForbiddenError).
All exported symbols must have TSDoc comments. This applies across all layers — entity/, contract/, service/, repository/, error/, utils/.
| Symbol | Required |
|---|---|
| Exported functions & class methods | ✅ |
| Exported classes & interfaces | ✅ |
| Exported Zod schemas & inferred types | ✅ |
| Exported constants & enums | ✅ |
| Interface method signatures | ✅ |
| Internal helpers / private methods | ❌ |
index.ts re-exports |
❌ |
| Obvious getters where name + type is self-explanatory | ❌ |
Use TSDoc (/** */). Always include a one-line summary. Add @param, @returns, and @throws only when they add meaningful context beyond what the type signature already communicates.
// ✅ Exported service function — document it
/**
* Retrieves a parishioner by their ID.
*
* @param id - The parishioner's unique identifier.
* @returns `ok(parishioner)` if found, `fail(NotFoundError)` if not.
*/
export async function getParishioner(id: string): Promise<Result<Parishioner>> { ... }
// ✅ Exported interface — document the interface and non-obvious methods
/**
* Repository contract for parishioner data access.
*/
export interface IParishionerRepository {
findById(id: string): Promise<Parishioner | null>
/**
* Finds a parishioner linked to a specific user account.
* Returns `null` if the user has no associated parishioner record.
*/
findByUserId(userId: string): Promise<Parishioner | null>
}
// ✅ Exported Zod schema — document it
/**
* Zod schema and inferred type for a parishioner entity.
*/
export const ParishionerEntity = z.object({ ... })
// ❌ index.ts re-export — skip
export * from './parishioner'
// ❌ Internal helper — skip
function buildWhereClause(id: string) { ... }- Write in English.
- Keep comments concise — one clear sentence is better than a long paragraph.
- Do not restate the type signature in prose (e.g. avoid "takes a string and returns a promise").
- Do not add
@throws— Domus services use the Result pattern, not exceptions.
See → docs/erd.md
Key notes:
- All tables use soft delete via the
deletedAtfield. Always includeWHERE deletedAt IS NULLin default queries. → docs/erd.md — Soft Delete Strategy - Table names:
snake_case. Fields/columns:camelCase. Drizzle schema constants:camelCase.
Domus uses a hybrid role model — global roles and scoped roles per organization.
See → docs/tdd.md — Roles & Access Control · docs/prd.md — Users & Roles
Google OAuth via Better Auth. Only users with accountStatus: approved can access the application.
Auth responsibility is divided as follows:
- Next.js Proxy (
proxy.ts) — session validation,accountStatuscheck, routing by status - Server Action — retrieve session, construct
AuthContext, pass to service - Service — permission check using
AuthContextfor write/domain-specific ops
Auth check: permission in service, authentication in NextJS Proxy + Server Action.
See → docs/tdd.md — Authentication
See → docs/tdd.md — Deployment & Infrastructure
MIT — see → docs/tdd.md — License