PFC #5 - Page-Feature Composition: Real-World Examples
Complete dashboard implementation from start to finish
TL;DR
See PFC in action with a real admin dashboard. Three features (user management, analytics, notifications) are composed on one page. Complete walkthrough of folder structure, feature implementation, facades, containers, and final page composition. This is what clean Angular architecture looks like in production.
Welcome to the final post in the Page-Feature Composition series. We’ve covered the philosophy, page orchestration, feature architecture, and migration strategies.
Now let’s put it all together with a complete, real-world example.
We’re building an admin dashboard that displays:
Recent users with quick actions
Analytics overview with charts
Real-time notifications feed
Three features, one page, zero spaghetti code.
Series Context: This is the final part of the Page-Feature Composition series. If you haven’t read parts 1-4, start there for the foundational concepts. This post shows everything working together.
The Dashboard Requirements
Our admin dashboard needs to:
Show recent users
Display the last 10 active users
Quick view/edit/delete actions
Click to navigate to full user management
Display analytics
Total users, active sessions, revenue
Simple chart showing trends
Refresh on demand
Show notifications
Real-time notification feed
Mark as read functionality
Badge count on unread items
Page responsibilities
Arrange features in a grid layout
Handle navigation when users click items
That’s it!
Let’s build it.
Project Structure
Here’s our complete folder organization:
src/app/
├── features/
│ ├── user-management/
│ │ ├── components/
│ │ │ └── user-card/
│ │ ├── containers/
│ │ │ └── user-management-container/
│ │ ├── store/
│ │ │ └── facade.ts
│ │ ├── services/
│ │ │ └── user-api.service.ts
│ │ ├── models/
│ │ │ └── user.interface.ts
│ │ ├── user-management.facade.ts
│ │ ├── user-management.module.ts
│ │ └── index.ts
│ │
│ ├── analytics/
│ │ ├── components/
│ │ │ └── stats-card/
│ │ ├── containers/
│ │ │ └── analytics-container/
│ │ ├── store/
│ │ ├── services/
│ │ ├── analytics.facade.ts
│ │ ├── analytics.module.ts
│ │ └── index.ts
│ │
│ └── notifications/
│ ├── components/
│ │ └── notification-item/
│ ├── containers/
│ │ └── notifications-container/
│ ├── store/
│ ├── services/
│ ├── notifications.facade.ts
│ ├── notifications.module.ts
│ └── index.ts
│
├── pages/
│ └── admin-dashboard/
│ ├── admin-dashboard.page.ts
│ ├── admin-dashboard.html
│ ├── admin-dashboard.scss
│ └── admin-dashboard.module.ts
│
└── shared/
└── components/Three focused features. One orchestrating page. Clean separation.
Feature 1: User Management
What It Does
Handles everything related to displaying and managing users in “compact” mode for the dashboard.
Models
If it’s being used only by the model, keep inside the feature; otherwise, move to shared (as in here)
// shared/models/user.interface.ts
export interface User {
id: string;
name: string;
email: string;
avatar: string;
lastActive: Date;
status: ‘active’ | ‘inactive’;
}Store Facade (Internal)
// features/user-management/store/facade.ts
@Injectable({ providedIn: ‘root’ })
export class UserStoreFacade {
readonly users$ = this.store.select(selectUsers);
readonly isLoading$ = this.store.select(selectIsLoading);
constructor(private store: Store) {}
loadRecentUsers(): void {
this.store.dispatch(loadRecentUsers());
}
deleteUser(userId: string): void {
this.store.dispatch(deleteUser({ userId }));
}
}Container
// features/user-management/containers/user-management-container/user-management.container.ts
@Component({
selector: ‘app-user-management-container’,
templateUrl: ‘./user-management.html’
})
export class UserManagementContainer implements OnInit {
@Input() mode: ‘full’ | ‘compact’ = ‘full’;
@Input() maxItems?: number;
@Output() userSelected = new EventEmitter<string>();
users$ = this.storeFacade.users$;
isLoading$ = this.storeFacade.isLoading$;
constructor(private storeFacade: UserStoreFacade) {}
ngOnInit(): void {
this.storeFacade.loadRecentUsers();
}
get displayUsers$() {
return this.users$.pipe(
map(users => this.maxItems ? users.slice(0, this.maxItems) : users)
);
}
onUserClick(userId: string): void {
this.userSelected.emit(userId);
}
onUserDelete(userId: string): void {
if (confirm(’Delete this user?’)) {
this.storeFacade.deleteUser(userId);
}
}
}Presentational Component
// features/user-management/components/user-card/user-card.component.ts
@Component({
selector: ‘app-user-card’,
templateUrl: ‘./user-card.html’,
styleUrls: [’./user-card.scss’]
})
export class UserCardComponent {
@Input() user: User;
@Input() compact = false;
@Output() click = new EventEmitter<string>();
@Output() delete = new EventEmitter<string>();
onClick(): void {
this.click.emit(this.user.id);
}
onDelete(event: Event): void {
event.stopPropagation();
this.delete.emit(this.user.id);
}
}Feature Facade (Public API)
// features/user-management/user-management.facade.ts
@Injectable()
export class UserManagementFacade {
readonly isLoading$ = this.storeFacade.isLoading$;
constructor(private storeFacade: UserStoreFacade) {}
refreshUsers(): void {
this.storeFacade.loadRecentUsers();
}
}Public Exports
// features/user-management/index.ts
export * from ‘./user-management.module’;
export * from ‘./user-management.facade’;That’s the complete user-management feature. Self-contained, testable, reusable.
Feature 2: Analytics (Simplified)
What It Does
Displays key metrics and a simple chart. Handles data loading and formatting.
Container
// features/analytics/containers/analytics-container/analytics.container.ts
@Component({
selector: ‘app-analytics-container’,
templateUrl: ‘./analytics.html’
})
export class AnalyticsContainer implements OnInit {
@Input() mode: ‘full’ | ‘summary’ = ‘full’;
stats$ = this.storeFacade.stats$;
isLoading$ = this.storeFacade.isLoading$;
constructor(private storeFacade: AnalyticsStoreFacade) {}
ngOnInit(): void {
this.storeFacade.loadStats();
}
refresh(): void {
this.storeFacade.loadStats();
}
}Stats Card Component
// features/analytics/components/stats-card/stats-card.component.ts
@Component({
selector: ‘app-stats-card’,
templateUrl: ‘./stats-card.html’,
styleUrls: [’./stats-card.scss’]
})
export class StatsCardComponent {
@Input() label: string;
@Input() value: number;
@Input() icon: string;
@Input() trend?: ‘up’ | ‘down’;
}The analytics feature is complete. Simple, focused, reusable.
Feature 3: Notifications (Simplified)
What It Does
Displays real-time notifications, allows marking as read, and shows unread count.
Container
// features/notifications/containers/notifications-container/notifications.container.ts
@Component({
selector: ‘app-notifications-container’,
templateUrl: ‘./notifications.html’
})
export class NotificationsContainer implements OnInit {
@Input() maxItems = 10;
notifications$ = this.storeFacade.notifications$;
unreadCount$ = this.storeFacade.unreadCount$;
constructor(private storeFacade: NotificationsStoreFacade) {}
ngOnInit(): void {
this.storeFacade.loadNotifications();
}
get displayNotifications$() {
return this.notifications$.pipe(
map(items => items.slice(0, this.maxItems))
);
}
onMarkAsRead(id: string): void {
this.storeFacade.markAsRead(id);
}
onMarkAllRead(): void {
this.storeFacade.markAllAsRead();
}
}Notification Item Component
// features/notifications/components/notification-item/notification-item.component.ts
@Component({
selector: ‘app-notification-item’,
templateUrl: ‘./notification-item.html’,
styleUrls: [’./notification-item.scss’]
})
export class NotificationItemComponent {
@Input() notification: Notification;
@Output() markRead = new EventEmitter<string>();
onMarkRead(): void {
this.markRead.emit(this.notification.id);
}
}The notifications feature is complete. Handles its own complexity.
The Page: Bringing It All Together
Now the magic happens. The page composes features.
Page Component
// pages/admin-dashboard/admin-dashboard.page.ts
@Component({
templateUrl: ‘./admin-dashboard.html’,
styleUrls: [’./admin-dashboard.scss’]
})
export class AdminDashboardPage {
constructor(private router: Router) {}
onUserSelected(userId: string): void {
this.router.navigate([’/users’, userId]);
}
}That’s it. 8 lines. No business logic. Just navigation.
Page Template
<!-- pages/admin-dashboard/admin-dashboard.html -->
<div class=”dashboard-layout”>
<header class=”dashboard-header”>
<h1>Admin Dashboard</h1>
<p>Welcome back! Here’s your overview.</p>
</header>
<div class=”dashboard-grid”>
<!-- Analytics Feature -->
<section class=”analytics-section”>
<app-analytics-container mode=”summary”></app-analytics-container>
</section>
<!-- User Management Feature -->
<section class=”users-section”>
<div class=”section-header”>
<h2>Recent Users</h2>
<a routerLink=”/users”>View all</a>
</div>
<app-user-management-container
mode=”compact”
[maxItems]=”10”
(userSelected)=”onUserSelected($event)”>
</app-user-management-container>
</section>
<!-- Notifications Feature -->
<section class=”notifications-section”>
<div class=”section-header”>
<h2>Notifications</h2>
<span class=”unread-badge”>3</span>
</div>
<app-notifications-container
[maxItems]=”5”>
</app-notifications-container>
</section>
</div>
</div>Pure composition. Three features, one layout, zero logic.
Page Styling
// pages/admin-dashboard/admin-dashboard.scss
.dashboard-layout {
padding: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.analytics-section {
grid-column: 1 / -1; // Full width
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}Layout is a page concern. Features don’t care where they’re placed.
Page Module
// pages/admin-dashboard/admin-dashboard.module.ts
@NgModule({
declarations: [AdminDashboardPage],
imports: [
CommonModule,
RouterModule.forChild([
{ path: ‘’, component: AdminDashboardPage }
]),
// Import feature modules
UserManagementModule,
AnalyticsModule,
NotificationsModule
]
})
export class AdminDashboardModule { }Lazy-loaded page imports the features it needs.
What We’ve Achieved
Let’s look at what this architecture gives us:
Reusability
User Management Feature can be used:
- Admin dashboard (compact mode, 10 users)
- Full user management page (full mode, all users)
- User selection dialog (selection mode)
- Profile sidebar (compact mode, 1 user)
Same feature, different contexts, zero modifications.
Analytics Feature works on:
- Admin dashboard (summary mode)
- Analytics page (full mode with charts)
- Mobile app dashboard (summary mode)
Notifications Feature appears:
- Dashboard (compact, 5 items)
- Header dropdown (compact, 3 items)
- Notifications page (full, all items)
Testability
describe(’UserManagementContainer’, () => {
it(’should load users on init’, () => {
const facade = jasmine.createSpyObj(’UserStoreFacade’, [’loadRecentUsers’]);
const container = new UserManagementContainer(facade);
container.ngOnInit();
expect(facade.loadRecentUsers).toHaveBeenCalled();
});
});Test page composition:
describe(’AdminDashboardPage’, () => {
it(’should navigate when user selected’, () => {
const router = jasmine.createSpyObj(’Router’, [’navigate’]);
const page = new AdminDashboardPage(router);
page.onUserSelected(’user-123’);
expect(router.navigate).toHaveBeenCalledWith([’/users’, ‘user-123’]);
});
});Clean, simple tests.
Team Workflow
Parallel development:
- Developer A: User management feature
- Developer B: Analytics feature
- Developer C: Notifications feature
- Developer D: Dashboard page composition
No conflicts. No waiting. Pure parallelization.
Maintainability
Six months later, you need to add a filter to user management. Where do you look? The user-management feature. Not scattered across five different pages.
Need to update the analytics chart? Analytics feature. One place. Clear boundary.
Bug in notifications? Notifications feature. Isolated problem, isolated fix.
Alternative Page Layouts
Because features are composable, you can create different layouts easily.
Mobile Dashboard (Vertical Stack)
<!-- pages/mobile-dashboard/mobile-dashboard.html -->
<div class=”mobile-layout”>
<app-analytics-container mode=”summary”></app-analytics-container>
<app-notifications-container [maxItems]=”3”></app-notifications-container>
<app-user-management-container mode=”compact” [maxItems]=”5”></app-user-management-container>
</div>Same features, different order, mobile-specific layout.
Executive Dashboard (Different Focus)
<!-- pages/executive-dashboard/executive-dashboard.html -->
<div class=”executive-layout”>
<app-analytics-container mode=”full”></app-analytics-container>
<!-- No users, no notifications - just analytics -->
</div>Pick and choose features as needed.
Custom Client Dashboard
<!-- pages/client-dashboard/client-dashboard.html -->
<div class=”client-layout”>
<!-- Different features for client view -->
<app-project-status-container></app-project-status-container>
<app-billing-summary-container></app-billing-summary-container>
<app-support-tickets-container></app-support-tickets-container>
</div>Create new dashboards by combining and customizing features.
Key Patterns Demonstrated
Container/Component Split
Containers handle logic and state. Components are pure presenters. This makes components highly reusable and easy to test.
Facade Pattern
Each feature exposes a simple facade. Pages use it for programmatic actions. Internal complexity stays hidden.
Input/Output Communication
Features expose inputs for configuration and outputs for events. Pages coordinate but don’t implement.
Mode-Based Behavior
Features support different modes via inputs. Same feature, different contexts, no code duplication.
Lazy Loading
Pages are lazy-loaded routes. Features are imported by pages that need them. Optimal bundle sizes.
Common Questions
”Isn’t this over-engineered for a simple dashboard?”
For a toy project, yes. For a production app that will grow, no. The structure supports growth without complexity explosion.
”What if features need to share data?”
Let the page coordinate. Feature A emits event, page passes data to Feature B via input. Keep features independent.
”Can features use each other’s components?”
No. Features are independent. If you need shared components, put them in `shared/`. If two features overlap significantly, maybe they’re one feature.
”How do I handle authentication?”
Core concern, not a feature. Put it in `core/auth/`. Features can inject auth services when needed.
”What about shared utilities?”
``shared/` folder. Pipes, utility functions, truly generic components. Not business-domain-specific.
Summary
This is what PFC looks like in production. Three focused features. One orchestrating page. Clean separation. Easy testing. True reusability.
What we built:
✅ User management feature (complete CRUD capability)
✅ Analytics feature (stats and charts)
✅ Notifications feature (real-time feed)
✅ Admin dashboard page (pure composition)
✅ Alternative layouts (mobile, executive, client)
What we achieved:
✅ Features work in multiple contexts without modification
✅ Page is <10 lines of business logic
✅ Tests are simple and isolated
✅ Team can work in parallel
✅ Maintenance is predictable
What you should do next:
Build one feature this way. See how it feels. Experience the benefits firsthand. Then build another. And another. Before you know it, you’ll have a scalable, maintainable Angular application.
Series Wrap-Up
We’ve covered a lot across five posts:
PFC #1: The philosophy and high-level structure
PFC #2: Pages as orchestrators
PFC #3: Building bulletproof features
PFC #4: Migration strategies
PFC #5: Complete real-world example
You now have everything you need to adopt PFC in your projects.
Start small. Build one feature. Show your team. Let the benefits speak for themselves.
Happy composing! 🎯
This concludes the Page-Feature Composition series. Follow better-frontend.dev for more practical Angular architecture insights.
The Series:
- PFC #1 - The Philosophy
- PFC #2 - Pages as Orchestrators
- PFC #3 - Building Features
- PFC #4 - Migration Strategies
- PFC #5 - Real-World Examples ← You are here

