Organizing large-scale projects
Building large-scale applications with Aurelia 2 requires careful planning and organization. This guide provides best practices for structuring your projects, managing dependencies, and scaling your applications effectively.
Core Principles
Before diving into specific patterns, it's important to understand the core principles that guide these recommendations:
Separation of Concerns - Keep different aspects of your application isolated from each other
Scalability - Structure that grows with your team and application complexity
Maintainability - Code that's easy to understand, modify, and debug
Testability - Architecture that facilitates comprehensive testing
Performance - Structure that enables optimization without major refactoring
Project Structure Patterns
Feature-Based Architecture (Recommended)
Feature-based organization is recommended over technical-layer organization for several reasons:
Improved Cohesion: Related code stays together, making it easier to understand and modify features
Better Scalability: Teams can work on features independently without stepping on each other
Easier Code Splitting: Features naturally align with lazy-loading boundaries
Reduced Coupling: Features can be developed, tested, and deployed more independently
Here's how to structure a feature-based application:
my-aurelia-app/
├── src/
│ ├── features/
│ │ ├── user/
│ │ │ ├── components/ # Feature-specific components
│ │ │ │ ├── user-list.ts
│ │ │ │ ├── user-list.html
│ │ │ │ ├── user-detail.ts
│ │ │ │ └── user-detail.html
│ │ │ ├── services/ # Feature-specific services
│ │ │ │ └── user-state.ts
│ │ │ ├── models/ # Domain models
│ │ │ │ └── user.ts
│ │ │ └── index.ts # Feature module export
│ │ ├── products/
│ │ │ ├── components/
│ │ │ ├── services/
│ │ │ └── index.ts
│ │ └── orders/
│ │ ├── components/
│ │ ├── services/
│ │ └── index.ts
│ ├── shared/ # Shared across features
│ │ ├── components/
│ │ │ ├── spinner.ts
│ │ │ ├── error-list.ts
│ │ │ └── index.ts
│ │ ├── services/
│ │ │ ├── api.ts
│ │ │ ├── auth.ts
│ │ │ └── index.ts
│ │ ├── resources/
│ │ │ ├── value-converters/
│ │ │ ├── binding-behaviors/
│ │ │ └── custom-attributes/
│ │ └── index.ts # Barrel export
│ ├── styles/
│ │ ├── global.css
│ │ └── themes/
│ ├── main.ts # Application entry point
│ └── app.ts # Root component
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── package.json
└── tsconfig.json
Monorepo Structure
A monorepo structure is beneficial for large organizations because:
Code Sharing: Easy to share components, utilities, and types across applications
Atomic Changes: Can make coordinated changes across multiple packages in a single commit
Consistent Tooling: Single set of build tools, linting rules, and dependencies
Better Refactoring: IDE support for renaming and refactoring across all packages
Why Turbo?
Turbo is recommended for monorepo orchestration because:
Intelligent Caching: Only rebuilds what changed, dramatically speeding up builds
Parallel Execution: Runs tasks across packages in parallel when possible
Remote Caching: Teams can share build artifacts, reducing CI/CD times
Pipeline Management: Declaratively define task dependencies between packages
For enterprise applications with multiple teams or deployable units:
enterprise-app/
├── packages/
│ ├── core/ # Core framework extensions
│ │ ├── src/
│ │ │ ├── services/
│ │ │ ├── interfaces/
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── shared-ui/ # Shared component library
│ │ ├── src/
│ │ │ ├── components/
│ │ │ ├── styles/
│ │ │ └── index.ts
│ │ └── package.json
│ ├── admin-app/ # Admin application
│ │ ├── src/
│ │ │ ├── features/
│ │ │ └── main.ts
│ │ └── package.json
│ ├── customer-app/ # Customer application
│ │ ├── src/
│ │ │ ├── features/
│ │ │ └── main.ts
│ │ └── package.json
│ └── api-client/ # Shared API client
│ ├── src/
│ └── package.json
├── tools/ # Build tools and scripts
├── docs/ # Documentation
├── turbo.json # Turbo configuration
├── package.json
└── tsconfig.json
Application Bootstrap Patterns
Basic Bootstrap
// src/main.ts
import { Aurelia, StandardConfiguration } from 'aurelia';
import { MyApp } from './app';
await new Aurelia()
.register(StandardConfiguration)
.app({
host: document.querySelector('app-root'),
component: MyApp
})
.start();
Advanced Bootstrap with Configuration
// src/main.ts
import { Aurelia, LoggerConfiguration, LogLevel } from 'aurelia';
import { RouterConfiguration } from '@aurelia/router';
import * as GlobalResources from './shared';
import { registerFeatures } from './features';
const au = new Aurelia();
// Configure logging
au.register(LoggerConfiguration.create({
level: LogLevel.debug,
sinks: [ConsoleSink]
}));
// Configure router
au.register(RouterConfiguration.customize({
useUrlFragmentHash: false,
resolutionMode: 'static',
navigationSyncStates: ['guardedUnload', 'swapped', 'completed']
}));
// Register global resources
au.register(GlobalResources);
// Register feature modules
au.register(registerFeatures);
// Start application
await au.app({
host: document.querySelector('app-root'),
component: () => import('./app')
}).start();
State Management Architecture
State Management Options in Aurelia 2
Aurelia 2 provides two main approaches to state management:
DI-Based Services (Recommended for most cases)
Simple, testable, and TypeScript-friendly
No additional libraries or patterns to learn
Perfect for component-level and feature-level state
Works great with Aurelia's reactive binding system
@aurelia/state (For complex global state)
Redux-like state management with reactive bindings
Provides
@fromState
decorator for component bindingsMemoized selectors for computed values
Action-based state updates with reducers
When to Use Each Approach
DI-Based Services
Use when:
State is scoped to a feature or component
You need simple CRUD operations
Testing is a priority
Team prefers familiar OOP patterns
You want minimal complexity
@aurelia/state
Use when:
You need truly global application state
Multiple unrelated components need the same state
You want predictable state updates through actions
Complex state relationships require memoized selectors
You need to debug state changes systematically
DI-Based State Management Pattern (Recommended)
Create dedicated state services using dependency injection:
// src/features/user/services/user-state.ts
import { DI, resolve } from '@aurelia/kernel';
import { IApiClient } from '../../../shared/services/api';
export interface IUserState {
readonly users: User[];
readonly currentUser: User | null;
readonly isLoading: boolean;
readonly error: string | null;
loadUsers(): Promise<void>;
loadUser(id: string): Promise<void>;
createUser(user: Partial<User>): Promise<void>;
}
export const IUserState = DI.createInterface<IUserState>('IUserState', x =>
x.singleton(UserState)
);
class UserState implements IUserState {
private api = resolve(IApiClient);
users: User[] = [];
currentUser: User | null = null;
isLoading = false;
error: string | null = null;
// Request deduplication prevents multiple identical API calls
private pendingRequests = new Map<string, Promise<any>>();
async loadUsers(): Promise<void> {
const key = 'loadUsers';
// Deduplicate concurrent requests
// This prevents race conditions when multiple components
// request the same data simultaneously
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key);
}
const promise = this.executeLoadUsers();
this.pendingRequests.set(key, promise);
try {
await promise;
} finally {
this.pendingRequests.delete(key);
}
}
private async executeLoadUsers(): Promise<void> {
this.isLoading = true;
this.error = null;
try {
this.users = await this.api.get<User[]>('/users');
} catch (error) {
this.error = error.message;
throw error;
} finally {
this.isLoading = false;
}
}
async loadUser(id: string): Promise<void> {
// Similar implementation with deduplication
}
async createUser(user: Partial<User>): Promise<void> {
// Implementation
}
}
Using @aurelia/state for Complex Scenarios
@aurelia/state
provides Redux-like state management with reactive bindings:
// src/features/shopping/state/cart-state.ts
import { Store, fromState, createStateMemoizer } from '@aurelia/state';
// Define the state shape
interface CartState {
items: CartItem[];
taxRate: number;
}
// Initial state
const initialState: CartState = {
items: [],
taxRate: 0.08
};
// Action handlers
const cartReducer = (state: CartState, action: any): CartState => {
switch (action.type) {
case 'ADD_ITEM':
const existing = state.items.find(item => item.productId === action.product.id);
if (existing) {
return {
...state,
items: state.items.map(item =>
item.productId === action.product.id
? { ...item, quantity: item.quantity + action.quantity }
: item
)
};
}
return {
...state,
items: [...state.items, { ...action.product, quantity: action.quantity }]
};
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.productId !== action.productId)
};
default:
return state;
}
};
// Memoized selectors for computed values
const selectSubtotal = createStateMemoizer(
(state: CartState) => state.items,
(items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const selectTax = createStateMemoizer(
(state: CartState) => ({ items: state.items, taxRate: state.taxRate }),
({ items, taxRate }) => items.reduce((sum, item) => sum + item.price * item.quantity, 0) * taxRate
);
const selectTotal = createStateMemoizer(
(state: CartState) => state,
(state) => {
const subtotal = selectSubtotal(state);
const tax = selectTax(state);
return subtotal + tax;
}
);
// Configure in main.ts
import { StateDefaultConfiguration } from '@aurelia/state';
au.register(StateDefaultConfiguration.init(initialState, cartReducer));
Using DI-Based State in Components
// src/features/user/components/user-list.ts
import { IUserState } from '../services/user-state';
import { resolve } from '@aurelia/kernel';
export class UserList {
// The resolve() helper is a cleaner alternative to constructor injection
// It works because Aurelia creates components through DI
private userState = resolve(IUserState);
async attaching(): Promise<void> {
// Load data during the attaching lifecycle
// This blocks rendering until data is loaded
await this.userState.loadUsers();
}
get users() {
// Aurelia's binding system will automatically update
// the view when userState.users changes
return this.userState.users;
}
get isLoading() {
return this.userState.isLoading;
}
}
Using @aurelia/state in Components
// src/features/shopping/components/cart-summary.ts
import { fromState, Store, createStateMemoizer } from '@aurelia/state';
import { CartState } from '../state/cart-state';
export class CartSummary {
// Use @fromState decorator to bind to state properties
@fromState<CartState>(state => state.items)
items: CartItem[];
@fromState<CartState>(state => state.taxRate)
taxRate: number;
// Use selectors for computed values
@fromState<CartState>(selectSubtotal)
subtotal: number;
@fromState<CartState>(selectTax)
tax: number;
@fromState<CartState>(selectTotal)
total: number;
constructor(private store: Store<CartState>) {}
addItem(product: Product, quantity = 1) {
this.store.dispatch({
type: 'ADD_ITEM',
product,
quantity
});
}
removeItem(productId: string) {
this.store.dispatch({
type: 'REMOVE_ITEM',
productId
});
}
}
Template Usage with @aurelia/state
<!-- src/features/shopping/components/cart-summary.html -->
<div class="cart-summary">
<h3>Cart Summary</h3>
<div class="items">
<div repeat.for="item of items & state" class="cart-item">
<span>${item.name}</span>
<span>Qty: ${item.quantity}</span>
<span>${item.price | currency}</span>
<button click.dispatch="{ type: 'REMOVE_ITEM', productId: item.productId }">
Remove
</button>
</div>
</div>
<div class="totals">
<div>Subtotal: ${subtotal | currency}</div>
<div>Tax: ${tax | currency}</div>
<div>Total: ${total | currency}</div>
</div>
</div>
Comparison: When to Use Each
Learning Curve
Low
Medium
Boilerplate
Minimal
Medium
Computed Values
Manual
Memoized Selectors
State Scope
Feature/Component
Global
Testing
Excellent
Good
Performance
Good
Excellent
Best For
Most Use Cases
Complex Global State
Routing Patterns
Why Lazy Loading Routes?
Lazy loading routes is crucial for large applications because:
Faster Initial Load: Users only download code for the pages they visit
Better Caching: Browser can cache route bundles separately
Reduced Memory Usage: Components are only instantiated when needed
Natural Code Splitting: Each route becomes its own bundle
Static Route Configuration
// src/app.ts
import { route } from '@aurelia/router';
import { IUserState } from './features/user/services/user-state';
@route({
routes: [
{
id: 'home',
path: '',
component: () => import('./features/home/home'),
title: 'Home'
},
{
path: 'users',
component: () => import('./features/user/user-layout'),
children: [
{
path: '',
component: () => import('./features/user/components/user-list'),
title: 'Users'
},
{
path: ':id',
component: () => import('./features/user/components/user-detail'),
title: 'User Detail'
}
]
},
{
path: 'admin',
component: () => import('./features/admin/admin-layout'),
data: { requiresAuth: true, roles: ['admin'] }
}
]
})
export class App {
// Root component logic
}
Navigation Guards
// src/shared/auth/auth-hook.ts
import { lifecycleHooks, ILifecycleHooks, IRouteViewModel } from '@aurelia/router';
import { IAuthService } from '../services/auth';
import { resolve } from '@aurelia/kernel';
@lifecycleHooks()
export class AuthHook implements ILifecycleHooks<IRouteViewModel, 'canLoad'> {
private auth = resolve(IAuthService);
canLoad(vm: IRouteViewModel, params: Params, next: RouteNode): boolean | NavigationInstruction {
const requiresAuth = next.data?.requiresAuth;
const requiredRoles = next.data?.roles || [];
if (requiresAuth && !this.auth.isAuthenticated) {
return this.auth.returnUrl = next.computeAbsolutePath(), 'login';
}
if (requiredRoles.length && !this.auth.hasRoles(requiredRoles)) {
return 'unauthorized';
}
return true;
}
}
// Register globally
au.register(AuthHook);
Resource Management
Understanding Aurelia Resources
Resources in Aurelia 2 include:
Custom Elements: Reusable components
Custom Attributes: Behaviors attached to elements
Value Converters: Transform values in bindings
Binding Behaviors: Modify binding behavior
These need to be registered so Aurelia's template compiler can find them. You have two options:
Global Registration: Available everywhere in your app
Local Registration: Available only within a specific component or feature
Global Resource Registration
Use global registration for resources that are used frequently across your application:
// src/shared/index.ts
export * from './components/spinner';
export * from './components/error-list';
export * from './components/confirm-dialog';
export * from './resources/value-converters/format-date';
export * from './resources/value-converters/format-currency';
export * from './resources/binding-behaviors/debounce';
export * from './resources/custom-attributes/tooltip';
// src/main.ts
import * as GlobalResources from './shared';
au.register(GlobalResources);
Feature Module Pattern
Feature modules encapsulate all code for a specific domain. This pattern provides:
Clear Boundaries: Each feature is self-contained
Easy Testing: Can test features in isolation
Team Ownership: Teams can own entire features
Gradual Migration: Can migrate features incrementally
// src/features/user/index.ts
import { IContainer } from '@aurelia/kernel';
import { IUserState, UserState } from './services/user-state';
import { UserPermissionService } from './services/user-permission';
export function configureUserFeature(container: IContainer): void {
container.register(
// Services
IUserState,
UserPermissionService,
// Feature-specific resources
// These are registered locally to the feature
);
}
// src/features/index.ts
import { IContainer } from '@aurelia/kernel';
import { configureUserFeature } from './user';
import { configureProductFeature } from './products';
import { configureOrderFeature } from './orders';
export function registerFeatures(container: IContainer): void {
configureUserFeature(container);
configureProductFeature(container);
configureOrderFeature(container);
}
Build Configuration
Why Vite?
Vite is the recommended build tool for Aurelia 2 applications because:
Lightning Fast HMR: Near-instant hot module replacement during development
ESM-First: Native ES modules in development, optimized bundles for production
Zero Config: Works out of the box with sensible defaults
Built-in Optimizations: Automatic code splitting, tree shaking, and minification
First-Class TypeScript Support: No additional configuration needed
Modern Build with Vite
// vite.config.ts
import { defineConfig } from 'vite';
import aurelia from '@aurelia/vite-plugin';
export default defineConfig({
plugins: [aurelia()],
build: {
rollupOptions: {
output: {
manualChunks: {
'aurelia': ['aurelia', '@aurelia/router'],
'vendor': ['date-fns', 'axios']
}
}
}
},
resolve: {
alias: {
'@': '/src',
'@shared': '/src/shared',
'@features': '/src/features'
}
}
});
Environment Configuration
Why Environment-Specific Configuration?
Different environments require different settings:
Development: Verbose logging, local API endpoints, disabled analytics
Staging: Production-like but with test data and endpoints
Production: Optimized settings, real endpoints, enabled analytics
Using DI for environment configuration provides:
Type safety for configuration values
Easy mocking in tests
Single source of truth
Runtime configuration validation
// src/config/environment.ts
interface IEnvironment {
production: boolean;
apiUrl: string;
features: {
analytics: boolean;
debugLogging: boolean;
};
}
const environments: Record<string, IEnvironment> = {
development: {
production: false,
apiUrl: 'http://localhost:3000/api',
features: {
analytics: false,
debugLogging: true
}
},
staging: {
production: false,
apiUrl: 'https://staging-api.example.com',
features: {
analytics: true,
debugLogging: false
}
},
production: {
production: true,
apiUrl: 'https://api.example.com',
features: {
analytics: true,
debugLogging: false
}
}
};
export const environment = environments[import.meta.env.MODE] || environments.development;
// Register as singleton
export const IEnvironment = DI.createInterface<IEnvironment>('IEnvironment', x =>
x.instance(environment)
);
Testing Strategies
Why @aurelia/testing?
Aurelia provides its own testing utilities because:
Lifecycle Management: Properly handles component lifecycle during tests
Fixture Creation: Easy setup of components with dependencies
DOM Assertions: Built-in helpers for testing rendered output
DI Integration: Seamlessly mock services through DI
Async Handling: Proper handling of Aurelia's async operations
Component Testing
// tests/unit/features/user/user-list.spec.ts
import { createFixture } from '@aurelia/testing';
import { UserList } from '../../../../src/features/user/components/user-list';
import { IUserState } from '../../../../src/features/user/services/user-state';
describe('UserList', () => {
it('should display users', async () => {
const mockUserState: Partial<IUserState> = {
users: [
{ id: '1', name: 'John Doe' },
{ id: '2', name: 'Jane Smith' }
],
isLoading: false,
loadUsers: async () => {}
};
const { assertText } = await createFixture(
'<user-list></user-list>',
{ id: 'app' },
[
UserList,
{ register: IUserState, useValue: mockUserState }
]
).started;
assertText('John Doe');
assertText('Jane Smith');
});
});
Service Testing
// tests/unit/features/user/user-state.spec.ts
import { DI } from '@aurelia/kernel';
import { UserState } from '../../../../src/features/user/services/user-state';
import { IApiClient } from '../../../../src/shared/services/api';
describe('UserState', () => {
it('should deduplicate concurrent requests', async () => {
const container = DI.createContainer();
const mockApi = {
get: jest.fn().mockResolvedValue([{ id: '1', name: 'Test User' }])
};
container.register({ register: IApiClient, useValue: mockApi });
const userState = container.get(UserState);
// Make concurrent requests
const [result1, result2] = await Promise.all([
userState.loadUsers(),
userState.loadUsers()
]);
// Should only make one API call
expect(mockApi.get).toHaveBeenCalledTimes(1);
expect(userState.users).toHaveLength(1);
});
});
Performance Optimization
Why Focus on Performance?
Large applications must consider performance from the start:
User Experience: Faster apps have better engagement and conversion
SEO Impact: Page speed affects search rankings
Mobile Users: Many users on slower connections or devices
Scalability: Performance problems compound as apps grow
Code Splitting with Dynamic Imports
Code splitting breaks your application into smaller chunks that load on demand:
// src/features/admin/index.ts
export async function configureAdminFeature(container: IContainer): Promise<void> {
// Lazy load admin dependencies
const [
{ AdminService },
{ AdminAnalytics },
{ AdminResources }
] = await Promise.all([
import('./services/admin-service'),
import('./services/admin-analytics'),
import('./resources')
]);
container.register(
AdminService,
AdminAnalytics,
...AdminResources
);
}
Bundle Analysis
// package.json
{
"scripts": {
"analyze": "vite build --mode analyze",
"analyze:stats": "vite-bundle-visualizer"
}
}
Micro-Frontend Architecture
When to Use Micro-Frontends?
Consider micro-frontends when:
Multiple Teams: Different teams own different parts of the application
Independent Deployment: Need to deploy features independently
Technology Diversity: Teams want to use different frameworks/versions
Massive Scale: Application is too large for a single codebase
Trade-offs
Pros:
Team autonomy
Independent deployments
Fault isolation
Technology flexibility
Cons:
Increased complexity
Potential duplication
Cross-module communication challenges
Larger overall bundle size
Shell Application
// shell/src/main.ts
import { Aurelia } from 'aurelia';
import { ModuleFederationPlugin } from '@module-federation/enhanced';
const au = new Aurelia();
// Register remote modules
au.register({
register(container: IContainer) {
container.register(
Registration.singleton(IRemoteModuleLoader, RemoteModuleLoader)
);
}
});
// Configure module federation
au.register(ModuleFederationPlugin.configure({
name: 'shell',
remotes: {
userModule: 'userModule@http://localhost:3001/remoteEntry.js',
productModule: 'productModule@http://localhost:3002/remoteEntry.js'
}
}));
Remote Module
// user-module/src/bootstrap.ts
import { Aurelia } from 'aurelia';
import { IUserFeature } from './user-feature';
export async function mount(container: IContainer, config: RemoteConfig): Promise<void> {
const childContainer = container.createChild();
childContainer.register(
IUserFeature,
UserRoutes,
UserResources
);
await childContainer.get(IUserFeature).activate(config);
}
Decision Guide: Which Architecture to Choose?
Single Repository
Choose when:
Small to medium team (< 20 developers)
Single deployable application
Rapid prototyping needed
Simpler deployment pipeline preferred
Monorepo
Choose when:
Multiple related applications
Significant code sharing needed
Large team with good tooling
Consistent standards important
Micro-Frontends
Choose when:
Very large organization (100+ developers)
Teams need full autonomy
Independent deployment critical
Different tech stacks required
Best Practices Summary
Architecture Principles
Organize by features/domains, not technical layers
Use dependency injection for all services
Keep components focused on presentation
Implement proper separation of concerns
State Management
Use singleton services for application state
Implement request deduplication for concurrent calls
Handle loading and error states consistently
Keep state close to where it's used
Performance
Implement code splitting at route boundaries
Use dynamic imports for heavy dependencies
Monitor bundle sizes with analysis tools
Lazy load features when possible
Type Safety
Use TypeScript interfaces for all services
Avoid
any
type - create specific typesLeverage DI interfaces for better abstraction
Use strict TypeScript configuration
Testing
Test components with @aurelia/testing fixtures
Mock services through DI registration
Test state management logic separately
Focus on testing critical business logic and user workflows
Code Organization
Use barrel exports for feature modules
Keep consistent file naming conventions
Group related functionality together
Maintain clear module boundaries
Build & Deployment
Use modern build tools (Vite preferred)
Configure environment-specific settings
Implement proper code splitting
Monitor and optimize bundle sizes
By following these patterns and practices, you can build scalable, maintainable Aurelia 2 applications that grow with your team and business requirements. The key is to start with a solid foundation and evolve the architecture as needed while maintaining consistency across the codebase.
Last updated
Was this helpful?