Last Updated: 2026-02-11
Purpose: Architectural patterns and conventions for Aurelia v2 applications
Core Principle: Group files by business capability (e.g., /features/products, /features/orders) rather than technical layer (e.g., /views, /models).
- Feature Slices: Group all components, templates, and domain-specific logic related to a business unit in a single directory.
- Sub-Feature Nesting: Use nested features for complex modules (e.g.,
/features/administration/users,/features/administration/settings). - Encapsulation by Default: Each feature directory should contain its own localized custom elements, value converters, and styles to avoid global namespace pollution.
- Declarative Routing: Define routes directly on components to mirror directory structure on disk, maintaining "vertical slice" architecture.
- Hierarchical AI Documentation: Use a root
Agents.mdfor global framework guardrails and directory-specificAgents.mdfiles to provide just-in-time context.
src/
├── features/ # Business capability slices
│ ├── products/ # Product management feature
│ │ ├── index.ts # Feature entry point (exports components)
│ │ ├── product-list.ts # ViewModel
│ │ ├── product-list.html # Template
│ │ ├── components/ # Feature-specific components
│ │ │ └── product-card/
│ │ └── services/ # Feature-specific services if needed
│ ├── administration/ # Admin feature (sub-features)
│ │ ├── users/
│ │ └── settings/
│ └── orders/
├── models/ # DTOs (API contracts)
│ ├── contact-dto.ts
│ ├── target-dto.ts
│ └── program-dto.ts
├── entities/ # Models (business logic)
│ ├── contact/
│ ├── target/
│ └── program/
├── services/ # Infrastructure services
│ ├── api.service.ts
│ └── auth.service.ts
└── shared/ # Cross-cutting utilities
└── components/ # Reusable UI components
| Term | Definition | Location |
|---|---|---|
| DTO (Data Transfer Object) | Response Model - API contract interfaces (PascalCase properties) | src/models/ |
| Model | Internal business logic classes (camelCase properties) | src/entities/ |
| ViewModel | View-specific state management (component classes) | src/views/ |
Key Principle: Services return Models, not DTOs. DTOs are never exposed to components.
┌─────────────────────────────────────────────────────────────┐
│ Components (Views) │
│ src/views/*.ts (ViewModels) │
│ ↓ uses │
│ ┌────────────┐ │
│ │ Models │ │
│ src/entities/{entity}/ (Business Logic) │
│ ↓ converts │
│ ┌────────────┐ │
│ │ DTOs │ │
│ src/models/ (API Contracts) │
│ ↓ calls │
│ ┌─────────────┐ │
│ │ Backend API │ │
└─────────────────────────────────────────────────────────────┘
- API Response → DTO (interface, PascalCase)
- DTO → Model (class, fromDTO() static factory)
- Model → Component (typed, camelCase)
- Component → Model (via bindings)
- Model → DTO (toDTO() instance method)
- DTO → API Request (PascalCase)
src/
├── models/ # DTOs (API contracts)
│ ├── entity-dto.ts
│ ├── collection-dto.ts
│ └── configuration-dto.ts
├── entities/ # Models (business logic)
│ ├── entity/
│ │ ├── entity.model.ts
│ │ ├── entity.service.ts
│ │ └── nested-model.model.ts
│ ├── collection/
│ └── configuration/
├── services/ # Infrastructure services
│ ├── api.service.ts # Generic HTTP client
│ └── auth.service.ts
└── views/ # ViewModels
└── entity-page.ts
src/
├── features/ # Business capability slices
│ ├── products/ # Product management feature
│ │ ├── index.ts # Feature entry (exports all public components)
│ │ ├── product-list.ts # ViewModel
│ │ ├── product-list.html # Template
│ │ ├── components/ # Feature-specific components
│ │ │ └── product-card/
│ │ └── services/ # Feature-specific services if needed
│ ├── contacts/ # Contact management feature
│ │ ├── contact-list.ts
│ │ ├── contact-list.html
│ │ └── contact-detail.ts
│ ├── targets/
│ ├── programs/
│ └── administration/ # Complex features with sub-features
│ ├── users/
│ └── settings/
├── models/ # DTOs (API contracts) - SHARED ACROSS ALL FEATURES
│ ├── contact-dto.ts
│ ├── target-dto.ts
│ ├── program-dto.ts
│ ├── collection-dto.ts
│ └── configuration-dto.ts
├── entities/ # Models (business logic) - SHARED ACROSS ALL FEATURES
│ ├── contact/
│ ├── target/
│ ├── program/
│ ├── collection/
│ └── configuration/
├── services/ # Infrastructure services - SHARED ACROSS ALL FEATURES
│ ├── api.service.ts
│ └── auth.service.ts
├── shared/ # Cross-cutting utilities
│ └── components/ # Reusable UI components
└── app.ts # Application entry point
| Type | Pattern | Example |
|---|---|---|
| DTO interface | {entity}-dto.ts |
contact-dto.ts |
| Model class | {entity}.model.ts |
contact.model.ts |
| Service class | {entity}.service.ts |
contact.service.ts |
| Service interface | I{Entity}Service |
IContactService |
| ViewModel | {entity}-page.ts |
contacts-page.ts |
Core Principle: Keep state "close to where it is used" by leveraging DI-based services as the primary state management strategy. Avoid cross-cutting state propagation patterns like Event Aggregator.
Benefits of Service-as-Store:
- Explicit Dependencies: Services declare their dependencies explicitly via DI, making relationships clear
- Testable State: State is encapsulated in services with clear interfaces, easier to test
- Type Safety: TypeScript interfaces define what state and methods are available
- No Magic: Components know exactly what services they use, no implicit event subscriptions
When to Use Service-as-Store:
- Entity-specific state (e.g., contact list, form data)
- Cached data with TTL
- User preferences and settings
- Feature flags and configuration
When NOT to Use Service-as-Store:
- Cross-component UI state not tied to business logic
- Complex global state with multiple reducers (consider
@aurelia/state) - Transient, per-request data
Abstract data access using a repository layer (e.g., ContactRepository) to keep services agnostic of data source.
export interface IContactRepository {
findById(id: string): Promise<ContactModel | null>;
findAll(params: QueryParams): Promise<ContactModel[]>;
save(model: ContactModel): Promise<ContactModel>;
delete(id: string): Promise<void>;
}
export const IContactRepository = DI.createInterface<IContactRepository>(
'IContactRepository',
x => x.singleton(ContactRepository)
);
export class ContactService {
private repository = resolve(IContactRepository);
private apiService = resolve(IApiService);
async getContacts(): Promise<ContactModel[]> {
return this.repository.findAll({ page: 1, pageSize: 50 });
}
}Aurelia v2 recommends DI.createInterface() for service tokens:
import { DI } from 'aurelia';
import { Registration } from 'aurelia';
// 1. Define interface
export interface IContactService {
getContacts(page: number, pageSize: number): Promise<{
data: ContactModel[];
total: number;
page: number;
}>;
getContact(id: string): Promise<ContactModel>;
}
// 2. Create interface token with default implementation
export const IContactService = DI.createInterface<IContactService>(
'IContactService',
x => x.singleton(ContactService) // Auto-register as singleton
);
// 3. Export type for convenience
export type IContactService = IContactService;Benefits of Interfaces:
- Loose coupling (depend on abstraction, not concrete)
- Better testability (inject mocks easily)
- Flexible (swap implementations without changing consumers)
Prefer resolve() for property injection:
import { resolve } from 'aurelia';
export class ContactPage implements IRouteViewModel {
// ✅ Property injection (recommended)
private contactService = resolve(IContactService);
private apiService = resolve(IApiService);
// ❌ Constructor injection (still works but less preferred)
constructor(private contactService: IContactService) {}
}| Strategy | Usage | Example |
|---|---|---|
singleton() |
Shared state, API clients | x => x.singleton(ApiService) |
transient() |
Stateless, disposable | x => x.transient(ValidatorService) |
instance() |
Pre-configured object | x => x.instance(config) |
lazy(Token): Defer resolution until demand for better startup performancenewInstanceForScope(Token): Create a scoped instance for a specific component branch (e.g., a validation controller for a form)optional(Token): Provide a graceful fallback if a service is not registered
import { lazy, optional } from 'aurelia';
export class MyComponent {
// Defer creation until first use
private heavyService = lazy(IHeavyService);
// Graceful fallback if not registered
private optionalService = optional(IOptionalService);
}| Strategy | Usage | Example |
|---|---|---|
singleton() |
Shared state, API clients | x => x.singleton(ApiService) |
transient() |
Stateless, disposable | x => x.transient(ValidatorService) |
instance() |
Pre-configured object | x => x.instance(config) |
Manual Registration (if no default):
Aurelia.register(
Registration.singleton(ICustomService, CustomServiceImplementation)
);import { ContactDTO } from '../../models/contact-dto';
export class ContactModel {
constructor(
public id: string,
public fullName: string,
public email: string,
public status: ContactStatus = 'active',
public createdAt: string = new Date().toISOString()
) {}
// Static factory: DTO → Model
public static fromDTO(dto: ContactDTO): ContactModel {
return new ContactModel(
dto.Id.toString(),
dto.FullName,
dto.Email,
'active'
);
}
// Instance method: Model → DTO
public toDTO(): ContactDTO {
return {
Id: parseInt(this.id, 10),
Email: this.email,
FullName: this.fullName
};
}
}
export type ContactStatus = 'active' | 'inactive' | 'archived';Handle PascalCase (DTO) → camelCase (Model) conversions:
public static fromDTO(dto: ContactDTO): ContactModel {
return new ContactModel(
dto.Id.toString(), // PascalCase → camelCase
dto.FullName,
dto.Email
);
}
public toDTO(): ContactDTO {
return {
Id: parseInt(this.id, 10), // camelCase → PascalCase
Email: this.email,
FullName: this.fullName
};
}For complex entities with nested objects, create separate model classes:
// TargetModel with nested AddressModel
export class TargetModel {
constructor(
public id: string,
public name: string,
public address?: AddressModel // Nested model
) {}
static fromDTO(dto: TargetDTO): TargetModel {
return new TargetModel(
dto.ID.toString(),
dto.Target,
dto.AddressRef ? AddressModel.fromDTO(dto.AddressRef) : undefined
);
}
toDTO(): TargetDTO {
return {
ID: parseInt(this.id, 10),
Target: this.name,
AddressRef: this.address?.toDTO()
};
}
}
export class AddressModel {
constructor(
public line1?: string,
public city?: string
) {}
static fromDTO(dto: AddressRefDTO): AddressModel {
return new AddressModel(dto.Line1, dto.City);
}
toDTO(): AddressRefDTO {
return { Line1: this.line1, City: this.city };
}
}For Collections and Configuration entities with dynamic fields, use any:
export class ProgramTypeModel {
constructor(
public id: string,
public name: string,
public customFields: Record<string, any> = {} // Dynamic fields
) {}
static fromDTO(dto: ProgramTypeDTO): ProgramTypeModel {
return new ProgramTypeModel(dto.Id.toString(), dto.Name);
}
// No toDTO - read-only entities don't send to server
}Implement pending request maps in state services to prevent redundant simultaneous API calls.
export class ContactListStateService {
private pendingRequests = new Map<string, Promise<ContactModel[]>>();
async getContacts(forceRefresh = false): Promise<ContactModel[]> {
const cacheKey = 'contacts:all';
if (!forceRefresh && this.pendingRequests.has(cacheKey)) {
return this.pendingRequests.get(cacheKey)!;
}
const promise = this.loadContacts();
this.pendingRequests.set(cacheKey, promise);
try {
return await promise;
} finally {
this.pendingRequests.delete(cacheKey);
}
}
}For most cases, a singleton DI service is the preferred state management strategy. Use @aurelia/state only for complex, truly global state.
When to use @aurelia/state:
- Cross-component state not tied to services
- Complex state with multiple reducers
- State that needs time-travel debugging
When to use singleton services:
- Entity-specific state (e.g., contact list)
- Form state (e.g., validation, submission status)
- Cached data with TTL
Avoid Cross-Cutting Event Propagation: The Event Aggregator pattern (publishing and subscribing to application-wide events) is identified as a potential source of inconsistency and should be avoided in favor of DI-based services.
Why Avoid Event Aggregator:
- Implicit Dependencies: Hard to track which components subscribe to which events
- Type Safety: Event payloads are often untyped (
any), breaking type safety - Debugging: Difficult to trace event flow through the application
- Coupling: Creates implicit dependencies that aren't declared in constructors
Use Instead: Service-as-Store Pattern
// ❌ AVOID: Event Aggregator
export class ContactList {
private eventAggregator;
constructor() {
this.eventAggregator = resolve(IEventAggregator);
this.eventAggregator.subscribe('contact:selected', this.handleContactSelected);
}
handleContactSelected(event) {
// What type is event.payload?
// Where does this event come from?
}
}
// ✅ PREFER: DI-based service
export class ContactList {
private contactState = resolve(IContactStateService); // Explicit dependency
selectContact(contactId: string) {
this.contactState.setSelectedContact(contactId); // Typed method
// Type-safe, explicit dependencies
}
}Use the dependencies property in @customElement decorator to declare only resources (value converters, attributes) needed by that component, enabling efficient tree-shaking.
@customElement('my-component', {
name: 'my-component',
dependencies: [MyValueConverter, MyCustomAttribute]
})
export class MyComponent {}Local Resource Registration:
Use the dependencies property in @customElement decorator to declare only resources needed by that component, enabling efficient tree-shaking.
Import vs Require:
| Pattern | Syntax | Use Case |
|---|---|---|
| Import | <import from="./value-converter"></import> |
Feature-specific resources |
| Require | <require from="./value-converter"></require> |
Discouraged in Aurelia 2 |
| Global | Registered in main.ts | Cross-cutting shared resources |
Example: Feature-Specific Resource Registration
<!-- product-card.html -->
<template>
<import from="./price-formatter"></import>
<div class="card">
<span>${price | priceFormatter}</span>
</div>
</template>
<customElement('product-card', {
name: 'product-card',
dependencies: [PriceFormatter] // Only what this component needs
})
export class ProductCard {}Direct Component Imports:
Use direct component imports instead of string-based module paths and PLATFORM.moduleName for better tree-shaking.
// ✅ Preferred: Direct import
import { ProductList } from './product-list';
import { ProductDetail } from './product-detail';
@route({
routes: [
{ path: '', component: ProductList },
{ path: ':id', component: ProductDetail }
]
})
// ❌ Discouraged: String module path
import { ProductList } from './product-list';
import { ProductDetail } from './product-detail';
@route({
routes: [
{ path: '', component: () => import('./product-list').get('default') },
{ path: ':id', component: () => import('./product-detail').get('default') }
]
})Use @au-compose decorator for component composition instead of the deprecated <compose> element.
import { compose } from 'aurelia';
@compose({
template: `<div class="loading"><slot></slot></div>`
})
export class LoadingIndicator {}Before creating new components, check src/components/:
- Button, Alert, Card, Modal, Input, Navigation, DataTable, FileUpload, Spinner
- Layout: Header, Footer, Grid, Flex, Sidebar, CardGrid
- Utility: Search, Pagination, ListView
import { bindable } from 'aurelia';
export type ButtonVariant = 'primary' | 'secondary' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
@customElement('ui-button')
export class Button {
@bindable public variant: ButtonVariant = 'primary';
@bindable public size: ButtonSize = 'md';
@bindable public disabled: boolean = false;
public get classes(): string {
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-500 text-white',
danger: 'bg-red-500 text-white'
};
return `rounded ${variantClasses[this.variant]} ${this.getSizeClasses()}`;
}
private getSizeClasses(): string {
const sizeMap: Record<ButtonSize, string> = {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
};
return sizeMap[this.size];
}
}Use appropriate binding commands for different scenarios:
| Mode | Syntax | Direction | Use Case |
|---|---|---|---|
| Two-way | value.bind="property" |
model ↔ view | Forms |
| To view | value.to-view="property" |
model → view | Read-only |
| From view | value.from-view="property" |
view → model | User input |
| One-time | value.one-time="property" |
model → view once | Static values |
Default to .bind for most cases, .two-way for forms, and .to-view for performance-critical data.
Replace .delegate with .trigger:
Use .trigger for almost all event handling in v2, as it efficiently handles event bubbling for dynamic content and feels more natural with Shadow DOM.
export class Button {
@bindable public onClick?: (event: MouseEvent) => void;
public handleClick(event: MouseEvent): void {
if (this.disabled) {
event.preventDefault();
return;
}
// Emit custom event with .trigger
this.dispatchEvent(
new CustomEvent('click', {
detail: { value: 'clicked' },
bubbles: true
})
);
this.onClick?.(event);
}
}Event Modifiers: Use Aurelia event modifiers to declaratively handle conditions and prevent default behaviors directly in the template.
| Modifier | Example | Purpose |
|---|---|---|
@click:capture |
@click.capture |
Handle in capture phase |
@click:delegate |
@click.delegate |
Use delegate (native events) |
@click:self |
@click.self |
Only on element itself |
@click:stop |
@click.stop |
Stop propagation |
@click:prevent |
@click.prevent |
Prevent default behavior |
@click:ctrl+enter |
@click.ctrl+enter.prevent |
Handle key combinations |
<!-- Declarative event handling with modifiers -->
<input type="text"
@keydown.enter.prevent="onEnter()"
@click:ctrl+enter="onCtrlEnter()"
value.bind="userInput" />Example - Form Submission with Validation:
<form submit.trigger="handleSubmit($event)">
<input type="text" name="username" value.bind="credentials.username">
<input type="password" name="password" value.bind="credentials.password">
<button type="submit" @click:ctrl+enter.trigger="handleSubmit($event)">
Login
</button>
</form><button class.bind="classes"
click.trigger="handleClick($event)"
disabled.bind="disabled">
<slot></slot>
</button>| Mode | Syntax | Direction |
|---|---|---|
| Two-way (default) | value.bind="property" |
model ↔ view |
| To view | value.to-view="property" |
model → view |
| From view | value.from-view="property" |
view → model |
| One-time | value.one-time="property" |
model → view once |
// Core Aurelia
import { resolve, DI, Registration } from 'aurelia';
import { route } from '@aurelia/router';
import { IRouteContext, ICurrentRoute, IRouterEvents, IDisposable } from '@aurelia/router';
// Custom element
import { customElement, bindable, useShadowDOM, slotted } from 'aurelia';
// App types
import type { IContactService } from '../entities/contact/contact.service';
import { ContactModel } from '../entities/contact/contact.model';
import { ContactDTO } from '../models/contact-dto';export class MyViewModel {
// Property injection (recommended)
private contactService = resolve(IContactService);
private authService = resolve(IAuthService);
// Constructor injection (alternative)
constructor(private apiService = resolve(IApiService)) {}
}@customElement('my-element')
export class MyElement {
@bindable public input: string = '';
@bindable public options: string[] = [];
public get computedValue(): string {
return this.input.toUpperCase();
}
}@route({
path: 'parent',
routes: [
{ path: 'child', component: () => import('./child'), title: 'Child' },
{ path: '', redirectTo: 'child' }
]
})
export class ParentLayout {
private navModel = resolve(IRouteContext)().routeConfigContext.navigationModel;
}Quick Reference: @docs/Aurelia.md (framework API details)
Aurelia DeepWiki: https://deepwiki.com/aurelia/aurelia
Aurelia Documentation: https://docs.aurelia.io
Aurelia GitHub: https://github.com/aurelia/aurelia