ADR-0014: Domain-Driven Frontend Structure
Accepted Cross-ProjectDate: 2026-03-08
Context
The frontend needs to organize Vue components, stores, routes, types, and tests for 15+ business domains (projects, issues, sprints, epics, lanes, teams, users, time logs, etc.). Some domains are nested: issues belong to projects, sprints belong to projects, comments belong to issues.
Two common approaches exist:
- Technical layers —
components/,stores/,services/,types/at the top level, with all domains mixed within each layer - Vertical slices — each domain is a self-contained directory with its own components, store, types, routes, and mocks
The Problem for AI Agents
Without documentation, AI agents default to the technical-layer approach because it's more common in tutorials and starter templates. When asked to "add a new feature for labels," an AI would create components/LabelList.vue, stores/labels.ts, types/label.d.ts — scattering the feature across the codebase instead of creating a cohesive domains/labels/ directory.
Decision
The frontend uses vertical slices organized by business domain. Each domain is a self-contained directory under frontend/src/domains/.
Top-Level Domain Structure
domains/[name]/
├── store.ts # Adapter-store factory (~20 lines)
├── constants.ts # DOMAIN_NAME for REST URLs
├── types.d.ts # TypeScript types (Resource, Adapted, New)
├── route.ts # Vue Router config for this domain
├── components/ # Domain-specific Vue components
├── relations/ # Nested sub-domains (optional)
└── __mocks__/ # Vitest mocks
└── store.ts # Mock store matching real store APINested Relation Structure
Domains that belong to a parent domain live under relations/:
domains/projects/
├── store.ts
├── constants.ts
├── types.d.ts
├── route.ts
├── components/
├── __mocks__/
└── relations/
├── issues/ # Same structure as top-level domain
│ ├── store.ts
│ ├── constants.ts
│ ├── types.d.ts
│ ├── components/
│ └── __mocks__/
├── sprints/
├── epics/
├── lanes/
├── attachments/
└── systemPrompts/Current Domain Map
| Top-Level Domain | Relations |
|---|---|
auth/ | — |
github/ | — |
projects/ | issues/, sprints/, epics/, lanes/, attachments/, systemPrompts/ |
teams/ | — |
timeLogs/ | — |
users/ | — |
Store Pattern: Top-Level vs Relation
Top-level stores are singletons created at module scope:
// domains/teams/store.ts
const teamAdapter = (resource: TeamResource): Team => ({
...resourceAdapter(resource),
projects: () => relationshipAdapterConstructor<ProjectResource, Project>(resource.projectIds, projectStore),
members: () => relationshipAdapterConstructor<UserResource, User>(resource.memberIds, userStore),
});
export const teamStore = adapterStoreModuleFactory<TeamResource, Team, NewTeam>(TEAM_DOMAIN_NAME, teamAdapter);Relation stores are factory functions that create per-parent instances:
// domains/projects/relations/issues/store.ts
const generateRestURL = (projectId: number): string =>
`${PROJECT_DOMAIN_NAME}/${projectId}/${ISSUE_RELATION_NAME}`;
export const makeIssueStoreForProject = (projectId: number) => {
const issueAdapter = makeIssueAdapterForProject(projectId);
return adapterStoreModuleFactory<IssueResource, Issue, NewIssue>(
generateRestURL(projectId), issueAdapter
);
};Constants Pattern
Each domain defines its REST URL segment as a constant:
// domains/projects/constants.ts
export const PROJECT_DOMAIN_NAME = 'projects';
// domains/projects/relations/issues/constants.ts
export const ISSUE_RELATION_NAME = 'issues';REST URLs are composed from these constants: projects/${projectId}/issues.
Mock Pattern
Every domain has __mocks__/store.ts that mirrors the real store API:
// domains/projects/__mocks__/store.ts
export const projectStore = {
getAll: computed<Partial<Project>[]>(() => projectsRef.value),
getById: vi.fn<() => ComputedRef<Partial<ProjectResource> | undefined>>(() => computed(() => undefined)),
getOrFailById: vi.fn<() => Promise<Partial<Project>>>(),
generateNew: mockGenerateNew,
retrieveAll: vi.fn<() => Promise<void>>(),
};Tests import from the mock path, never from the real store.
Architecture Test Enforcement
frontend/tests/js/architecture/domain-structure.spec.ts enforces:
- Every top-level domain must have
types.d.tsand__mocks__/ - Every relation must have
constants.ts,types.d.ts, and__mocks__/ - Every
store.tsmust useadapterStoreModuleFactory(with explicit exceptions list)
Options Considered
| Option | Verdict | Reason |
|---|---|---|
Technical layers (components/, stores/, services/) | Rejected | With 15+ domains, each layer becomes a flat list of unrelated files. Adding a feature touches 4+ directories. AI agents struggle to understand which files belong together. |
| Vertical slices by business domain | Accepted | Self-contained domains. Adding is additive (create one directory), removing is subtractive (delete one directory). Architecture tests enforce the structure. |
Consequences
Positive
- New domains follow a predictable pattern — AI agents can generate the scaffold from any existing domain
- Import paths are intuitive:
domains/projects/relations/issues/storereads like the business relationship - Tests and mocks live next to the code they test — no cross-directory hunting
- Deleting a domain is one
rm -rf— no orphaned files in other directories - Architecture tests catch structural violations at CI time
Negative
- Deep nesting:
domains/projects/relations/issues/components/ShowIssueModal.vueis 5 levels deep - Cross-domain imports require reaching up to
../../or using path aliases - New developers must learn the convention before contributing (no standard Vue tutorial covers this)
- AI agents without access to this ADR will suggest flat-file structures
References
- Architecture test:
frontend/tests/js/architecture/domain-structure.spec.ts - Import boundary test:
frontend/tests/js/architecture/import-boundaries.spec.ts - Adapter-Store Pattern — explains the store factory used in each domain