ng-arch-kit
v1.0.0
Published
Angular Schematics collection for feature-first architecture with Standalone Components and Signal Store
Maintainers
Readme
ng-arch-kit
Angular Schematics collection for generating feature-first architecture with Standalone Components and Signal Store
Overview
ng-arch-kit is a powerful Angular Schematics collection that generates a complete, scalable feature-first architecture for Angular CLI applications. It promotes best practices including:
- 🏗️ Standalone Components - Modern Angular architecture without NgModules
- 📁 Feature-First Organization - Logical grouping by business domain
- 🔄 Signal Store - State management with
@ngrx/signals - 🎭 Smart/Dumb Components - Clear separation of container and presentational components
- 🚧 Architectural Boundaries - Enforced layer dependencies via ESLint
Installation
npm install ng-arch-kit --save-dev
# Peer dependencies
npm install @ngrx/signals @ngrx/operatorsQuick Start
Generate a complete feature:
ng generate ng-arch-kit:feature library books \
--entity Book \
--route books \
--withFacade \
--withUi list,filters \
--crudThis creates the following structure:
src/app/features/library/books/
├── feature/
│ ├── books.routes.ts # Feature routes
│ ├── books-page.container.ts # Smart container component
│ ├── books-detail.container.ts
│ ├── books-shell.component.ts
│ └── index.ts
│
├── data-access/
│ ├── +state/
│ │ ├── books.store.ts # Signal Store
│ │ └── books.facade.ts # Optional facade layer
│ ├── books.api.ts # HTTP API service
│ ├── books.models.ts # TypeScript interfaces
│ └── index.ts
│
├── ui/
│ ├── books-list.component.ts # Dumb/presentational
│ ├── books-filters.component.ts
│ └── index.ts
│
└── util/
├── books.query.ts # Pure utility functions
└── index.tsArchitecture
Layer Dependencies
┌─────────────────────────────────────────────────┐
│ FEATURE │
│ (Smart/Container Components, Routes, Shell) │
│ │
│ ┌──────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ data-access │ │ ui │ │ util │ │
│ └──────────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────┐
│ data-access │ │ ui │ │ util │
│ (Store, │ │ (Dumb │ │ (Pure │
│ Facade, │──────│ Comps) │───│ Funcs) │
│ API, DTOs) │ │ │ │ │
└──────────────┘ └──────────┘ └──────────┘
│ ▲
└───────────────────────────────┘Import Rules
| Layer | Can Import From | |-------------|---------------------------| | feature | data-access, ui, util | | data-access | util | | ui | util (NO store/facade!) | | util | external libs only |
Smart vs Dumb Components
Smart/Container Components (.container.ts)
Located in the feature/ folder, these components:
- ✅ Inject the store or facade
- ✅ React to route parameters
- ✅ Trigger data loading effects
- ✅ Handle user interactions
- ✅ Pass data down to dumb components
@Component({
selector: 'app-books-page',
standalone: true,
imports: [BooksListComponent, BooksFiltersComponent],
template: `
<app-books-filters
[activeFilters]="facade.filters()"
(filtersChange)="onFiltersChange($event)">
</app-books-filters>
<app-books-list
[books]="facade.filteredBooks()"
(select)="onSelect($event)">
</app-books-list>
`
})
export class BooksPageComponent {
protected readonly facade = inject(BooksFacade);
// ...
}Dumb/Presentational Components (.component.ts)
Located in the ui/ folder, these components:
- ✅ Receive all data via
@input()signals - ✅ Emit all events via
@output() - ❌ NO store/facade injection
- ❌ NO routing
- ❌ NO HTTP calls
@Component({
selector: 'app-books-list',
standalone: true,
template: `
@for (book of books(); track book.id) {
<div (click)="select.emit(book)">{{ book.name }}</div>
}
`
})
export class BooksListComponent {
readonly books = input.required<Book[]>();
readonly select = output<Book>();
}Schematics
ng g ng-arch-kit:feature
Generate a complete feature with all layers.
ng g ng-arch-kit:feature <domain> <name> [options]Arguments
| Argument | Description |
|----------|-------------|
| domain | The domain/module name (e.g., 'library', 'admin') |
| name | The feature name (e.g., 'books', 'users') |
Options
| Option | Alias | Default | Description |
|--------|-------|---------|-------------|
| --entity | -e | Singular of name | Entity name (e.g., 'Book') |
| --route | -r | Feature name | Route path |
| --withFacade | -f | false | Generate facade layer |
| --withUi | | 'list' | Comma-separated UI components |
| --crud | | true | Generate CRUD operations |
| --skipTests | | false | Skip test file generation |
Examples
# Basic feature
ng g ng-arch-kit:feature admin users
# Feature with facade and multiple UI components
ng g ng-arch-kit:feature library books \
--entity Book \
--withFacade \
--withUi list,filters,card
# Feature without CRUD
ng g ng-arch-kit:feature dashboard analytics --crud falseng g ng-arch-kit:ui
Add a dumb/presentational UI component to an existing feature.
ng g ng-arch-kit:ui <domain> <feature> <name> [options]Options
| Option | Description |
|--------|-------------|
| --entity | Entity type the component works with |
| --inputs | Input properties (e.g., 'item:Book,selected:boolean') |
| --outputs | Output events (e.g., 'select:Book,delete:string') |
Example
ng g ng-arch-kit:ui library books book-card \
--inputs "book:Book,selected:boolean" \
--outputs "select:Book,edit:Book"ng g ng-arch-kit:route
Add a child route to an existing feature.
ng g ng-arch-kit:route <domain> <feature> <path> [options]Options
| Option | Default | Description |
|--------|---------|-------------|
| --component | Derived from path | Component name |
| --lazy | true | Lazy load the component |
| --container | true | Generate as container/smart component |
Example
# Add edit route
ng g ng-arch-kit:route library books "edit/:id" --component books-edit
# Add a non-lazy presentational route
ng g ng-arch-kit:route library books "new" --lazy false --container falseSignal Store
The generated Signal Store includes:
State
interface BooksState {
entities: Book[];
selectedId: string | null;
loading: boolean;
error: string | null;
filters: BookFilters;
}Derived State (Computed Selectors)
withComputed((store) => ({
selectedBook: computed(() => /* ... */),
filteredBooks: computed(() => /* ... */),
totalCount: computed(() => /* ... */),
stats: computed(() => /* ... */),
}))Actions with Async Effects
withMethods((store, api = inject(BooksApiService)) => ({
load: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap(() => api.getAll().pipe(
takeUntil(cancelLoad$), // Request cancellation
tapResponse({
next: (entities) => patchState(store, { entities, loading: false }),
error: (error) => patchState(store, { error: error.message, loading: false }),
})
))
)
),
create: rxMethod<Book>(/* ... */),
update: rxMethod<Book>(/* ... */),
delete: rxMethod<string>(/* ... */),
}))Optional Facade Layer
When using --withFacade, a facade class is generated that:
- Provides a simplified API - Intent-based methods like
save()instead ofcreate()/update() - Exposes view models - Aggregated computed signals for components
- Decouples components from store - Easier testing and refactoring
@Injectable({ providedIn: 'root' })
export class BooksFacade {
private readonly store = inject(BooksStore);
// View model
readonly vm = computed(() => ({
books: this.store.entities(),
filteredBooks: this.store.filteredBooks(),
loading: this.store.loading(),
error: this.store.error(),
}));
// Intent-based methods
load(): void { this.store.load(); }
save(book: Book): void {
book.id ? this.store.update(book) : this.store.create(book);
}
delete(id: string): void { this.store.delete(id); }
}ESLint Boundary Rules
Copy the provided ESLint configuration to enforce architectural boundaries:
cp node_modules/ng-arch-kit/eslint-boundaries.config.json .eslintrc.jsonOr merge the rules into your existing ESLint configuration.
This enforces:
- ❌ UI components cannot import from
data-access - ❌
data-accesscannot import fromfeatureorui - ❌
utilcannot import from any app layer - ❌ No cross-feature imports
Route Auto-Registration
The feature schematic automatically patches src/app/app.routes.ts:
export const routes: Routes = [
{
path: 'books',
loadChildren: () =>
import('./features/library/books/feature')
.then(m => m.BOOKS_ROUTES)
},
// Other routes...
];Best Practices
1. Keep UI Components Pure
// ✅ Good - Dumb component
@Component({ /* ... */ })
export class BookCardComponent {
readonly book = input.required<Book>();
readonly select = output<Book>();
}
// ❌ Bad - Dumb component with dependencies
@Component({ /* ... */ })
export class BookCardComponent {
private readonly store = inject(BooksStore); // Don't do this!
}2. Use Barrel Files
Always import from the layer's index.ts:
// ✅ Good
import { BooksFacade, Book } from '../data-access';
// ❌ Bad
import { BooksFacade } from '../data-access/+state/books.facade';3. Keep Business Logic in Store/Facade
// ✅ Store handles the logic
store.filteredBooks = computed(() => {
return applyFilters(store.entities(), store.filters());
});
// Container component just uses it
template: `<app-list [items]="facade.filteredBooks()"></app-list>`4. Use Computed Signals for Derived State
// ✅ Good - Computed from state
readonly stats = computed(() => ({
total: this.store.entities().length,
filtered: this.store.filteredBooks().length,
}));
// ❌ Bad - Calculating in template
template: `{{ store.entities().length }} items`Naming Conventions
| Type | Pattern | Example |
|------|---------|---------|
| Smart/Container Component | *-page.container.ts, *-detail.container.ts | books-page.container.ts |
| Dumb/UI Component | *.component.ts | books-list.component.ts |
| Shell Component | *-shell.component.ts | books-shell.component.ts |
| Store | *.store.ts | books.store.ts |
| Facade | *.facade.ts | books.facade.ts |
| API Service | *.api.ts | books.api.ts |
| Models | *.models.ts | books.models.ts |
| Utilities | *.query.ts | books.query.ts |
Code Quality Conventions
Directory Naming: The +state Prefix
The +state/ folder uses a + prefix convention from the Nx/NgRx ecosystem:
data-access/
+state/ ← Internal state management
books.store.ts
books.facade.ts
index.ts ← Public API (barrel file)
books.api.ts
books.models.tsWhy +state?
- Signals "internal" - Files here shouldn't be imported directly from outside
data-access - Visual marker - The
+clearly indicates "don't import from here directly" - Sort order - Appears at the top of directory listings (before alphabetical folders)
- Convention - Widely used in Nx workspaces and NgRx projects
Barrel Files (index.ts)
Each layer exposes a public API through its index.ts file:
// data-access/index.ts - Public API
export { BooksStore } from './+state/books.store';
export { BooksFacade } from './+state/books.facade';
export type { Book, BookFilters } from './books.models';
export { BooksApiService } from './books.api';Rules:
- ✅ Always import from the barrel:
import { Book } from '../data-access' - ❌ Never import internal paths:
import { Book } from '../data-access/+state/books.store'
Type-Only Exports
Use export type for interfaces and type aliases to support isolatedModules:
// ✅ Correct
export type { Book, BookId, BookFilters } from './books.models';
export { isBook } from './books.models'; // Runtime value
// ❌ Causes errors with isolatedModules
export { Book, BookId, isBook } from './books.models';Component Suffixes
| Suffix | Purpose | Location | Injects Store? |
|--------|---------|----------|----------------|
| .container.ts | Smart/container component | feature/ | ✅ Yes |
| .component.ts | Dumb/presentational component | ui/ | ❌ No |
| -shell.component.ts | Layout wrapper with router outlet | feature/ | Optional |
Output Naming
Avoid DOM event names for outputs to prevent conflicts:
// ✅ Good - Custom name
readonly itemSelect = output<Book>();
readonly itemDelete = output<string>();
// ❌ Avoid - Conflicts with DOM events
readonly select = output<Book>(); // 'select' is a DOM event
readonly change = output<any>(); // 'change' is a DOM eventSignal Store Patterns
Request Cancellation
Always cancel pending requests when starting new ones:
withMethods((store, api = inject(BooksApiService)) => {
const cancelLoad$ = new Subject<void>();
return {
load: rxMethod<void>(
pipe(
tap(() => {
cancelLoad$.next(); // Cancel previous request
patchState(store, { loading: true });
}),
switchMap(() =>
api.getAll().pipe(
takeUntil(cancelLoad$), // Allow cancellation
tapResponse({ /* ... */ })
)
)
)
),
};
})Entity ID Types
Use branded types for entity IDs to improve type safety:
// models.ts
export type BookId = string; // Semantic alias
export interface Book {
id: BookId; // Clear intent
name: string;
}
// Usage - prevents mixing different ID types
select(bookId: BookId): void { /* ... */ }CSS/SCSS Conventions
Generated styles follow these patterns:
| Class Pattern | Purpose |
|---------------|---------|
| .{feature}-page | Page-level container |
| .{feature}-list | List container |
| .list-item | Individual list items |
| .empty-state | No-data state |
| .loading-indicator | Loading spinner |
| .error-message | Error display |
| .btn-primary, .btn-danger | Action buttons |
Accessibility
Generated components include accessibility features:
<!-- Keyboard navigation -->
<li
tabindex="0"
(click)="itemSelect.emit(item)"
(keydown.enter)="itemSelect.emit(item)"
(keydown.space)="$event.preventDefault(); itemSelect.emit(item)">Testing Conventions
| File Pattern | Purpose |
|--------------|---------|
| *.spec.ts | Unit tests (Jasmine/Jest) |
| *.cy.ts | Component tests (Cypress) |
Test files are co-located with implementation files.
Import Order
Organize imports in this order:
// 1. Angular core
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
// 2. Angular modules
import { RouterModule } from '@angular/router';
// 3. Third-party libraries
import { signalStore } from '@ngrx/signals';
// 4. Local app imports (relative)
import { BooksFacade, Book } from '../data-access';
import { BooksListComponent } from '../ui';Example App
See the /example directory for a complete working example with the library/books feature.
To run the example:
- Create a new Angular app:
ng new my-app - Navigate to the app:
cd my-app - Install dependencies:
npm install @ngrx/signals @ngrx/operators - Link ng-arch-kit:
npm link ../path-to/ng-arch-kit - Generate a feature:
ng g ng-arch-kit:feature library books --withFacade --withUi list,filters - Start the dev server:
ng serve - Navigate to
http://localhost:4200/books
Troubleshooting
"Cannot find module '@ngrx/signals'"
Install the required peer dependencies:
npm install @ngrx/signals @ngrx/operators"Could not find app.routes.ts"
Make sure you're using an Angular 17+ application with standalone components and file-based routing. The schematic looks for src/app/app.routes.ts.
Feature not loading
Verify the route was added to app.routes.ts and that the feature path is correct.
Contributing
Contributions are welcome! Please read our contributing guide for details.
License
MIT
