Skip to content

The Kendo Way — Architectural Patterns and Conventions

Summary

This report synthesizes the architectural patterns that define Kendo's codebase, distilled from 38 DECISIONS.md files (containing 224+ individual decisions), 4 CLAUDE.md files, and the master ARCHITECTURE.md. Kendo is a dark-themed project management application built on Vue 3.5 + TypeScript 5.9 frontend and Laravel 12 + PHP 8.4 backend, with database-per-tenant multi-tenancy and 100% test coverage requirements.

The codebase follows a small set of rigid, heavily-enforced patterns — explicit over implicit, immutability by default, single-responsibility Actions, and factory-injected services. These patterns are not suggestions; they are enforced by 15+ architecture test suites that reject violations at CI time. The 38 DECISIONS.md files reveal that nearly every feature decision is made by evaluating whether a proposed approach is consistent with existing patterns — consistency is the primary decision heuristic, ahead of elegance or performance.


1. Backend Patterns

1.1 The Action Pattern (ADR-0011)

The single most important backend pattern. All business logic lives in Action classes. Controllers are thin wrappers: validate, call action, return response.

Rules (enforced by tests/Arch/ActionsTest.php):

  • final readonly class with suffix Action
  • Single public method: execute() (private helpers allowed)
  • Constructor dependency injection only
  • No facades, no HTTP layer dependencies (no controllers, middleware, or form requests)
  • No static-through-instance calls ($this->model::where() is banned; use $this->model->newQuery()->where())
  • Transaction closures must use void return type
  • Every mutation wrapped in a database transaction via ConnectionInterface

Return patterns:

  • Model: return created/updated entity
  • Void: delete/update operations with no return value
  • Exception: guard clauses throw custom exceptions (e.g., LaneHasIssuesException)
  • Nullable: ?Model for optional results (controller decides HTTP response)

Evidence from decisions: The Action pattern is so ingrained that when new features need cross-cutting logic, the answer is always "extract a new Action" rather than adding to existing ones. KD-0292 (orphaned assignments) extracted FindOrphanedAssignmentsAction as a shared collaborator. KD-0349 extracted CopyAttachmentsAction for attachment transfer during report promotion. KD-0355 extracted AiGenerationPipeline as an injectable collaborator (not a base class) to respect the final readonly constraint.

1.2 The FormRequest-to-DTO Flow (ADR-0012)

No raw arrays cross the boundary between HTTP and domain layers.

Flow: FormRequest validates -> toDto() -> typed DTO -> Action

Enforcement: tests/Arch/FormRequestsTest.php and tests/Arch/DataTransferObjectsTest.php

Critical rule — cross-entity scoping: Tables owned by a project must always be scoped in exists validation rules. Never use string-format 'exists:lanes,id'. Always scope: Rule::exists('lanes', 'id')->where('project_id', $projectId). This pattern was explicitly reinforced in KD-0320 D4 (same-project validation for epic_id) and KD-0340 D7 (global search enumeration scoping).

1.3 The ResourceData Pattern (ADR-0009)

API responses use immutable, readonly DTOs with constructor-promoted properties.

Rules (enforced by tests/Arch/ResourcesTest.php):

  • No manual toArray() — serialization is reflection-based
  • from(Model): static factory handles relation loading and computed values
  • EAGER_LOAD constant is the single source of truth for relation loading
  • Resources are dumb serializers — no request access, no auth logic, no service calls
  • Different contexts use separate Resource classes, not conditional logic
  • Collections via ResourceData::collection() — no ResourceCollection classes
  • No nested collections (KD-0349 D7 explicitly rejects nesting AttachmentResourceData[] inside ReportResourceData)

Controller patterns:

  • Single: ResourceData::from($model)->toResponse() returns JsonResponse
  • Create: ResourceData::from($model)->toResponseWithStatus(201) returns JsonResponse
  • Collection: ResourceData::collection($models) returns array<int, ResourceData>

1.4 Two-Tier Authorization (ADR-0006)

The most architecturally complex area of the codebase, with 31+ decisions across KD-0315 and KD-0340 alone.

Tier 1 (Route): ->can() middleware on routes. Policy classes in app/Policies/. Answers: "Can this user access this resource?" Determined by User + Model from route bindings.

Tier 2 (Action): Gate::authorize() inside Actions. Permission classes in app/Policies/Interactions/. Answers: "Can this user perform this specific operation in this context?" Requires runtime data beyond route bindings.

Rule of thumb: User + Model only -> Tier 1. Needs runtime request data -> Tier 2.

The permission model: 16 resources x 4 actions (Create boolean, Read boolean, Update scope [None/Own/All], Delete scope [None/Own/All]). Custom roles with any permission combination.

Bypass rules (KD-0315 D17):

  1. Admin bypass: $user->isAdmin() in policy before() hooks
  2. Project owner bypass: owners get full access to all 12 project-scoped resources (KD-0315 D17). Implemented as early return in CheckPermission::check(). Does NOT apply to tenant-scoped resources.

Key decisions that shaped the permission system:

  • KD-0315 D1: Auto-incrementing integer IDs for roles (not ULIDs), because every other model uses integers
  • KD-0315 D4: Replace all isAdmin checks with can() permission checks to enable custom roles
  • KD-0315 D10: Remove synthetic AppSettings resource, add Roles as real resource
  • KD-0315 D13: Auto-grant read when update/delete is set (enforced both frontend and backend)
  • KD-0315 D16: Force can_read: true on 12 of 16 resources; only 4 sensitive resources have toggleable read
  • KD-0340 D1: IDOR fixes via custom validation rules in FormRequest, not in Actions or Controllers
  • KD-0340 D6: NotificationPolicy is the only policy where admin bypass was removed (notifications are personal data)

1.5 Cascade Deletion (ADR-0002)

No ON DELETE CASCADE or ON DELETE SET NULL in migrations. Foreign keys serve as guards — they throw on constraint violations, not silently clean up. All deletion logic must be explicit in the Action. Model declares what it owns, Action executes the cascade, tests verify completeness.

Evidence: KD-0270 D2 chose "always cascade with confirmation" for GitHub repo unlinking, but the cascade happens explicitly in DeleteProjectGithubRepoAction, not via database FK cascades.

1.6 Config Attribute Injection (ADR-0016)

All config access in container-resolved classes must use #[Config('key')] attribute. config() helper is prohibited except in ServiceProvider register()/boot(). Enforced by tests/Arch/ConfigTest.php.

1.7 Audit Logging (ADR-0001)

Append-only per-entity audit tables with SHA-256 hash chaining for tamper detection. Triggered explicitly in Actions (not observers). Point-in-time user snapshots stored on each record. Architecture tests enforce that Actions touching auditable models depend on the corresponding audit logger.

Three AI logging channels (ADR-0003): AiOutboundLogger (LLM calls), AiMcpLogger (MCP tool invocations), AiRunLogger (AI run completions). All with hash chaining.

1.8 Enum Conventions

Backend: Integer-backed enums (BackedEnum: int). KD-0341 D17 explicitly switched OAuthProviderEnum from string-backed to int-backed with slug() methods, because every other backend enum uses integers.

Frontend: enumCollection pattern with numeric values and slug fields. KD-0315 D6-D7 created PermissionActionEnum using the same enumCollection pattern as TypeEnum, PriorityEnum, etc.

1.9 Migration Conventions

  • No ON DELETE CASCADE or ON DELETE SET NULL in post-cutoff migrations (ADR-0002)
  • Undeployed migrations can be modified in place (KD-0315 D3: "never modify deployed migrations" applies only to production/staging)
  • Auto-incrementing integer IDs everywhere (KD-0315 D1 switched ULIDs to integers for consistency)

2. Frontend Patterns

2.1 Domain-Driven Structure (ADR-0014)

Vertical slices organized by business domain, not by technical layer. Two apps (tenant + central) share the same domain pattern.

Each domain contains: store.ts, constants.ts, types.d.ts, route.ts, components/, relations/, __mocks__/

Import boundaries (enforced by frontend/tests/arch/domain-structure.spec.ts):

  • @shared/ must not import from @tenant/ or @central/
  • Tenant and central never import from each other
  • Cross-domain imports go through @shared/, never directly between domains
  • Cross-sibling imports between relations under the same parent domain are acceptable (KD-0280 D3: reports importing from issues)

2.2 The Adapter-Store Pattern (ADR-0009)

Custom reactive state management (not Pinia). Each domain has a store.ts using adapterStoreModuleFactory(). The adapter handles REST communication and camelCase/snake_case conversion.

Store API: getAll, getById, getOrFailById, generateNew, retrieveAllItem API: mutable, reset(), update(), delete(), create()

Items are frozen by default. Editing requires calling .mutable to get a writable copy, then .update() to sync back. This is the immutability-by-default principle in action.

Custom store methods follow the same extension pattern: KD-0236 D4 added clearAll() to AttachmentStoreModule following the existing upload extension pattern.

2.3 The Service Factory Pattern

All shared services created via factory functions. Each app instantiates its own service instances with app-specific configuration.

Factories: createAdapterStoreModule(), createHttpService(), createStorageService(), createRouterService(), createPageLength(), createMarkdownRenderer()

Per-app instances: src/apps/tenant/services/ and src/apps/central/services/ call shared factories with their own config.

When to use factories vs singletons: Services that need per-app configuration (different API base URLs, storage prefixes) use factories. Services with identical behavior in both apps (toast, modal, error) are singletons in @shared/services/ (KD-0204 D1).

The factory decision is deeply consistent. KD-0194 explicitly chose factories over provide/inject because "zero provide/inject usage exists in the codebase" and the factory pattern is used by "6+ services." KD-0195 D3 chose a factory for the markdown renderer. KD-0301 D1 chose props over provide/inject for MenuTabs. The codebase has zero provide/inject usage — it is an anti-pattern.

2.4 Component Code Style

<script setup lang="ts">
// 1. Types
// 2. External imports
// 3. Internal imports
// 4. Props & Emits
// 5. Reactive state
</script>

Rules:

  • type over interface (always, enforced by convention)
  • Avoid any — use unknown
  • Explicit return types for complex functions
  • Props destructured via const {user} = defineProps<{...}>()
  • v-model with named models for two-way bindings (KD-0194 D3: PaginationBar uses v-model:page-length)

2.5 The "Dumb Component, Smart Parent" Pattern

Components should be presentational; parents orchestrate. This pattern is reinforced repeatedly:

  • KD-0280 D5: AI generation moved to page level, IssueForm becomes a pure form
  • KD-0280 D6: Shared IssueForm replaces inline promote form in ReportDetail
  • KD-0280 D7: Dismiss button belongs on the report card, not inside the shared form
  • KD-0301 D1: MenuTabs becomes a pure presentational component via props
  • KD-0349 D1: AttachmentGrid expects properly typed store data, not inline hacks

2.6 Multi-App Architecture (KD-0190)

Two Vue apps sharing a common layer, served by a single Vite instance.

Key decisions:

  • Explicit path aliases: @shared/, @tenant/, @central/ — no generic @/ (KD-0190 D2)
  • Separate CentralUser model in central database (KD-0190 D1)
  • No WebSocket/Echo for central app (KD-0190 D5)
  • Same kendo design system shared via @shared/ (KD-0190 D6)
  • Single Vite instance for dev and build (KD-0190 D10)
  • Test directory mirrors source structure: tests/js/apps/tenant/... (KD-0190 D8)

2.7 Route Conventions

  • Tenant app uses Dutch route paths via createRouteSettingsFactory translation factory
  • Central app uses English paths with createStandardRouteConfig directly (KD-0302 D1)
  • Route meta: permission: {resource, action} for auth gating (KD-0315 D5), replacing the older requiresAdmin pattern
  • canInProject(resource, action, projectOwnerId, entityOwnerId?) for project-scoped checks (KD-0315 D21)
  • Domains get DomainLayout wrapping; standalone pages (pricing, login, invited) skip it (KD-0302 D2)
  • SidebarLink uses currentRoute.matched.some() for active detection (KD-0302 D3)

2.8 CSS and Layout Patterns

  • UnoCSS for utility classes
  • CSS custom properties (not TS constants) for layout dimensions (KD-0304 D1)
  • :style bindings to bypass UnoCSS specificity issues (KD-0304 D3)
  • Mobile padding applied at App.vue level, not per-layout (KD-0304 D2)
  • Formatting handled by oxfmt via PostToolUse hooks — no manual formatting

3. Cross-Cutting Patterns

3.1 The Consistency Heuristic

Across all 38 DECISIONS.md files, the single most common reason for choosing one option over another is consistency with existing codebase patterns. Examples:

  • KD-0194 D1: Factory pattern chosen because "consistent with every other service in the codebase"
  • KD-0195 D3: Factory for markdown because "consistent with every other service"
  • KD-0204 D1: Singletons for toast/modal because they need no per-app config (unlike factories)
  • KD-0301 D1: Props over provide/inject because "no new patterns introduced"
  • KD-0315 D1: Integer IDs for roles because "every other model uses auto-incrementing integers"
  • KD-0341 D17: Int-backed enum because "all other backend enums use int-backed enums"
  • KD-0349 D1: Store pattern for report attachments because "consistent with issue/epic/comment pattern"
  • KD-0349 D7: No nested ResourceData collections because "no precedent"

The pattern is: when in doubt, do what the codebase already does. Novel approaches require explicit justification.

3.2 The YAGNI Principle

Features are scoped aggressively. If there is no current need, it does not get built:

  • KD-0195 D4: MentionSuggestions moved to tenant (not made generic) because "central app doesn't use RichTextArea. YAGNI."
  • KD-0265 D2: Multi-report-to-one-issue linking deferred because "ship faster, real usage informs the right UX later"
  • KD-0289 D1: Full subscribers/watchers system rejected (20-25 files) in favor of creator+assignee pattern (4-5 files)
  • KD-0294 D1: Configurable retention period rejected in favor of hardcoded 180 days

3.3 Backend Stays Lean, Frontend Does More

A clear preference for pushing logic to the frontend when backend changes are unnecessary:

  • KD-0183 D1: Multi-file upload via parallel frontend calls, zero backend changes
  • KD-0183 D4: Attachment limit enforced frontend-only (internal tool, bypassing via API is negligible risk)
  • KD-0276 D1: Creator filter is client-side only (issues already loaded in memory)
  • KD-0280 D1: AI story generation for reports reuses existing issue endpoints, frontend maps the payload

3.4 The API Contract Pattern

HTTP status codes are semantic:

  • 422: Validation errors (request body invalid)
  • 409: Conflict with current resource state (KD-0270 D1: can't delete because of dependencies)
  • 403: Authorization failure
  • 201: Created (via toResponseWithStatus(201))

Feature flags: Laravel Pennant for tenant-level feature gating. Web routes use EnsureFeatureActive middleware. MCP tools check Feature::for($tenant)->active() directly (KD-XXXX D2). Feature flag state delivered to frontend via ProfileResourceData.features (KD-0286 D1), not via a separate endpoint.

Token/scope model: Passport OAuth tokens with read + write scopes. CLI and MCP share the same token infrastructure but use different OAuth grant types (Device Flow for CLI, Authorization Code + PKCE for MCP — KD-0237 D8).

3.5 Multi-Tenancy Affects Everything

Database-per-tenant. DIY implementation, no external package.

Routing: Subdomain-based. IdentifyCentral runs first (marks central.* requests). IdentifyTenant resolves subdomain to tenant DB.

Impact on every feature:

  • OAuth callbacks need exchange tokens because session cookies are subdomain-scoped (KD-0341 D3)
  • Feature flags are per-tenant, resolved via Pennant (KD-0286)
  • CLI stores tenant URL in config (KD-0237 D4)
  • Scheduled commands must iterate all tenants via TenantSwitcher (KD-0294 D4)
  • Central and tenant models are completely separate (separate databases, separate auth guards)
  • Browser tab title uses tenant name from profile data (KD-0283)

3.6 The "Enrich Profile, Don't Add Endpoints" Pattern

When the frontend needs a small piece of data available at boot time, it gets added to the existing GET /auth/user profile response rather than creating a new endpoint:

  • KD-0286 D1: Feature flags added to ProfileResourceData instead of separate GET /api/features
  • KD-0283 D1: Tenant name added to ProfileResourceData instead of separate GET /api/tenant

3.7 PR and Branching Strategy

  • Base branch for PRs: development (not main)
  • Large features split into multiple plans/issues (KD-0186 D6: split into 3 incremental issues)
  • Related small changes bundled into single PRs (KD-0320 D2: three related issues in one PR)
  • Chained PRs for multi-phase work (KD-0190 D3: 3 chained PRs for multi-app architecture)
  • Git worktrees for isolated feature work

4. Testing Patterns

4.1 Test Philosophy

  • 100% coverage on backend unit tests (Actions) and frontend component/store tests
  • TDD flow: write tests before implementation (per CLAUDE.md Plan Mode rules)
  • Architecture tests enforce structural patterns at CI time (15+ arch test files)
  • Mutation testing via Infection on Actions (composer test:mutation)

4.2 Backend Testing

  • Pest PHP for all backend tests
  • Mockery for mocking dependencies
  • Actions tested with mocked dependencies (not integration tests)
  • Architecture tests in tests/Arch/ enforce every structural convention:
    • ActionsTest.php: final readonly, single execute(), no facades
    • ControllersTest.php: no authorize(), must depend on Actions, no direct Eloquent writes
    • FormRequestsTest.php: no authorize() method
    • ResourcesTest.php: ResourceData patterns
    • RoutesAuthorizationTest.php: all auth routes have ->can() middleware
    • InteractionPermissionsTest.php: 8 tests for Tier 2 permissions
    • MigrationsTest.php: rejects cascadeOnDelete()/nullOnDelete()
    • AuditTest.php: Actions touching auditable models depend on loggers
    • ConfigTest.php: no config() helper in container-resolved classes

4.3 Frontend Testing

3-tier mock system:

  1. Centralized mock lists in tests/mocks/lists/ (imported globally in setup.ts)
  2. Source-level __mocks__/ directories co-located with source files
  3. Inline mocks in test files for one-off overrides

Key rules:

  • Always use :pipeline test scripts (the base scripts use vitest watch which never exits)
  • Tests mirror source directory structure: tests/js/apps/tenant/domains/...
  • AAA format (Arrange-Act-Assert)
  • Load /vue-vitest-testing skill before writing tests

Page Integration Tests (ADR-0017):

  • Real child components mounted (no mocking child components)
  • Only HTTP transport layer mocked (in-memory mock-server)
  • Stores, translation, router, and auth all run real
  • Separate vitest config and coverage thresholds

4.4 Permission Testing (KD-0340)

Permissions are tested at multiple layers:

  • Backend: architecture tests enforce route-level ->can() on all authenticated routes
  • Backend: policy unit tests with permission matrix
  • Frontend: component tests verify can()/canInProject() guards on buttons and routes
  • Security audit identified 10 gaps (KD-0340) — IDOR, privilege escalation, unscoped validation — all fixed in one PR

5. Anti-Patterns (Explicitly Rejected)

The DECISIONS.md files are as valuable for what they reject as for what they accept.

5.1 Never: Provide/Inject

Zero usage in the codebase. Explicitly rejected in:

  • KD-0194 D1: "Zero provide/inject usage exists in codebase; introduces a new pattern for one use case"
  • KD-0194 D4: "No existing provide/inject patterns; overkill for a single boolean"
  • KD-0301 D1: "Introduce new pattern not used anywhere in codebase; implicit dependency"

5.2 Never: ON DELETE CASCADE

All cascading is explicit in Actions (ADR-0002). Enforced by tests/Arch/MigrationsTest.php.

5.3 Never: Facades in Actions

Actions use constructor DI only. No Auth::, DB::, Gate:: facades. Gate is injected as Illuminate\Contracts\Auth\Access\Gate (the contract, not the facade).

5.4 Never: Nested ResourceData Collections

KD-0349 D7 explicitly rejected nesting AttachmentResourceData[] inside ReportResourceData. The pattern is: lean parent resource + dedicated endpoint for child collections.

5.5 Never: Raw Arrays Crossing Layer Boundaries

FormRequest -> DTO -> Action. No exceptions (ADR-0012).

5.6 Never: Pinia (or any third-party state management)

Custom adapter-store pattern. Pinia was never adopted and is not used anywhere.

5.7 Never: interface (TypeScript)

Always type. Convention is universal across the frontend.

5.8 Never: ULIDs or UUIDs for Primary Keys

All models use auto-incrementing integers. KD-0315 D1 explicitly switched from ULIDs back to integers for consistency.

5.9 Never: Soft Deletes (with one exception)

Only the User model uses SoftDeletes. All other deletions are hard deletes. KD-0294 D2 explicitly rejected soft deletes for notifications with extensive rationale.

5.10 Superseded Decisions

Some decisions were made and later reversed:

  • KD-0236 D2: Per-attachment ownership check superseded by D5 (simplified authorization deferred to separate issue)
  • KD-0315 D8: Users.read gating for navbar superseded by D11 (each sidebar link uses its own resource)
  • KD-0277 D3: Always use GitHub App token (superseded initial fallback approach)

6. Pattern Decision Tree

When building a new feature in Kendo, the decisions follow a predictable path:

Backend

  1. Where does business logic go? -> Action (always)
  2. How does data enter the Action? -> FormRequest -> DTO
  3. How does data leave the Action? -> ResourceData (model-backed) or void
  4. How is auth handled? -> Tier 1 (->can() middleware) for resource access, Tier 2 (Gate::authorize()) for contextual permissions
  5. How are deletions handled? -> Explicit cascade in the Action, no FK cascading
  6. How is config accessed? -> #[Config('key')] attribute
  7. Is there an audit trail? -> Yes, if the entity is auditable. Logger injected into Action.

Frontend

  1. Where does the feature live? -> Domain directory under the correct app
  2. How is state managed? -> Adapter-store pattern with adapterStoreModuleFactory()
  3. How are shared services accessed? -> Factory instances from @tenant/services/ or @central/services/
  4. How are permissions checked? -> can() for tenant resources, canInProject() for project resources
  5. How is the component structured? -> Smart parent with dumb presentational children
  6. Should I use provide/inject? -> No. Props or factory.
  7. Should I use Pinia? -> No. Adapter-store.

Cross-Cutting

  1. Should I add a new endpoint for this data? -> Prefer enriching existing responses (e.g., ProfileResourceData)
  2. Should I make this configurable? -> Not until there is a concrete need (YAGNI)
  3. Should I build for both apps? -> Only if both apps actually use it. Otherwise, scope to one app.
  4. How should I split this work? -> One plan per logically independent piece, chained PRs if needed

7. Quantitative Pattern Distribution

Across all 38 DECISIONS.md files (224+ decisions):

Decision TypeCountExamples
Consistency with existing pattern~65Factory over inject, integer IDs, int-backed enums
Scope reduction / YAGNI~30Defer watchers, skip configurable retention, frontend-only limits
Security / authorization~25IDOR fixes, password-required flows, scoped validation
Component architecture~20Dumb component/smart parent, shared vs domain-specific
API design~15Enrich profile, single endpoint, semantic status codes
Data model~15Separate models vs shared, column placement, cascade strategy
PR / delivery strategy~10Chained PRs, bundled issues, split plans
Rejected / superseded~10Provide/inject, ULIDs, soft deletes, string enums
Performance / infrastructure~5Synchronous processing, no partitioning, client-side filtering

8. Open Questions

  1. Architecture test coverage for frontend domain boundaries — The ratcheted violation list in ESLint suggests there are known boundary violations being grandfathered. How many remain, and is there a plan to eliminate them?

  2. Page Integration Tests rollout — ADR-0017 mentions "phased rollout by shared component usage count." What is the current coverage of integration tests vs the target?

  3. Audit logging expansion — Only 5 entities have audit loggers. The system is designed for more. Which entities are next in line?

  4. Feature flag lifecycle — Pennant flags are added for new features but there is no documented process for removing them once a feature is stable and rolled out to all tenants.

  5. The adapter-store's scalability ceiling — The custom reactive state management works well today. At what point (if ever) would the codebase benefit from a more established solution, and what would trigger that evaluation?

  6. Multi-tenant scheduled commands — KD-0294 D4 noted that ReapStaleAiRunsCommand does not iterate tenants (a gap). Has this been fixed, and are there other commands with the same issue?

  7. Ownership transfer implications — KD-0315 D19 added a dedicated transferOwnership ability. How does ownership transfer interact with the orphaned assignments system (KD-0292)?