PFC #3 - Page-Feature Composition: Building Bulletproof Features
The internal architecture that actually scales
TL;DR
Features are self-contained mini-applications. Use the container/component pattern (smart/dumb), expose a clean facade, keep internal services private, and manage state with NgRx or signals. Only export what pages need via index.ts. This structure makes features truly reusable across different page contexts without modification.
In PFC #1, we learned the philosophy. In PFC #2, we mastered page orchestration. Now it’s time for the main event: building features that actually work.
This is where the rubber meets the road. Get feature architecture right, and your app scales beautifully. Get it wrong, and you’re back to the spaghetti code we’re trying to escape.
Series Context: This is Part 3 of the Page-Feature Composition series. Parts 1 and 2 covered the “why” and “how pages work.” This post is all about “how features work internally.”
The Feature Mission
A feature has one job: to own a complete business capability.
Not “render a card.” Not “call an API.” Own the entire user management capability. Or product catalog. Or order processing. Everything related to that domain lives in one place.
What Makes a Good Feature?
Self-contained - Everything it needs lives inside the feature folder
Focused - One business domain, not “everything users-related ever”
Reusable - Works in different page contexts without modification
Testable - Can be tested in isolation
Encapsulated - Internal details hidden from pages
Think of features as npm packages you could theoretically publish. They should be that independent.
Feature Anatomy
Let’s break down a real feature structure:
features/user-management/
├── components/ # Dumb UI
│ ├── user-card/
│ │ ├── user-card.component.ts
│ │ ├── user-card.component.html
│ │ └── user-card.component.scss
│ └── user-form/
├── containers/ # Smart orchestrators
│ └── user-management-container/
│ ├── user-management.container.ts
│ └── user-management.html
├── services/ # Feature-specific services
│ └── user-api.service.ts
├── store/ # State management
│ ├── actions.ts
│ ├── reducers.ts
│ ├── selectors.ts
│ ├── effects.ts
│ └── facade.ts # Internal store wrapper
├── models/
│ └── user.interface.ts
├── user-management.facade.ts # PUBLIC API
├── user-management.module.ts
└── index.ts # Public exports onlyEach folder has a specific purpose. Let’s explore them.
Containers vs Components
This is the heart of feature architecture. Get this right, and everything else falls into place.
Containers (Smart Components)
Containers orchestrate feature logic. They know about services, state, and business rules.
// src/app/features/user-management/containers/user-management-container/user-management.container.ts
import { Component, OnInit } from ‘@angular/core’;
import { Observable } from ‘rxjs’;
import { User } from ‘../../models/user.interface’;
import { UserStoreFacade } from ‘../../store/facade’;
@Component({
selector: ‘app-user-management-container’,
templateUrl: ‘./user-management.html’
})
export class UserManagementContainer implements OnInit {
users$: Observable<User[]> = this.storeFacade.users$;
isLoading$: Observable<boolean> = this.storeFacade.isLoading$;
constructor(private storeFacade: UserStoreFacade) {}
ngOnInit(): void {
this.storeFacade.loadUsers();
}
onUserSelect(user: User): void {
this.storeFacade.selectUser(user.id);
}
onUserDelete(user: User): void {
if (confirm(`Delete ${user.name}?`)) {
this.storeFacade.deleteUser(user.id);
}
}
}Container responsibilities:
Fetch data via services/store
Handle user interactions (clicks, form submits)
Manage feature-level state
Coordinate multiple components
Expose observables to the template
One container per feature is usually enough. Sometimes you need two to three for complex features, but that’s rare.
Components (Dumb/Presentational)
Components render UI. No services, no state, just inputs and outputs.
// src/app/features/user-management/components/user-card/user-card.component.ts
import { Component, EventEmitter, Input, Output } from ‘@angular/core’;
import { User } from ‘../../models/user.interface’;
@Component({
selector: ‘app-user-card’,
templateUrl: ‘./user-card.component.html’,
styleUrls: [’./user-card.component.scss’]
})
export class UserCardComponent {
@Input() user: User;
@Input() showActions = true;
@Output() select = new EventEmitter<User>();
@Output() delete = new EventEmitter<User>();
onSelect(): void {
this.select.emit(this.user);
}
onDelete(): void {
this.delete.emit(this.user);
}
}<!-- user-card.component.html -->
<div class=”user-card” (click)=”onSelect()”>
<img [src]=”user.avatar” [alt]=”user.name”>
<div class=”user-info”>
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
<button
*ngIf=”showActions”
(click)=”onDelete(); $event.stopPropagation()”
class=”delete-btn”>
Delete
</button>
</div>Component responsibilities:
Render UI based on inputs
Emit events on user interactions
Apply styling
Stay dumb and reusable
The Golden Rule: If a component injects a service, it’s not dumb anymore. Make it a container or refactor.
The Template (Container)
<!-- user-management.html -->
<div class=”user-management”>
<div class=”loading” *ngIf=”isLoading$ | async”>
Loading users...
</div>
<div class=”user-list” *ngIf=”!(isLoading$ | async)”>
<app-user-card
*ngFor=”let user of users$ | async”
[user]=”user”
[showActions]=”true”
(select)=”onUserSelect($event)”
(delete)=”onUserDelete($event)”>
</app-user-card>
</div>
</div>Container template coordinates components. That’s it.
The Facade Pattern
This is crucial. Every feature needs two facades:
Internal Store Facade (Private)
Wraps your state management (NgRx, signals, services). Used by containers inside the feature.
// src/app/features/user-management/store/facade.ts
import { Injectable } from ‘@angular/core’;
import { Store } from ‘@ngrx/store’;
import { Observable } from ‘rxjs’;
import { User } from ‘../models/user.interface’;
import * as userActions from ‘./actions’;
import * as userSelectors from ‘./selectors’;
import { State } from ‘./reducers’;
@Injectable({ providedIn: ‘root’ })
export class UserStoreFacade {
readonly users$: Observable<User[]> = this.store.select(userSelectors.selectUsers);
readonly isLoading$: Observable<boolean> = this.store.select(userSelectors.selectIsLoading);
readonly selectedUser$: Observable<User | null> = this.store.select(userSelectors.selectSelectedUser);
constructor(private store: Store<State>) {}
loadUsers(): void {
this.store.dispatch(userActions.loadUsers());
}
selectUser(userId: string): void {
this.store.dispatch(userActions.selectUser({ userId }));
}
deleteUser(userId: string): void {
this.store.dispatch(userActions.deleteUser({ userId }));
}
}
Not exported. Containers use this directly.
External Feature Facade (Public)
Simple API for pages. Wraps the internal facade.
// src/app/features/user-management/user-management.facade.ts
import { Injectable } from ‘@angular/core’;
import { Observable } from ‘rxjs’;
import { UserStoreFacade } from ‘./store/facade’;
@Injectable()
export class UserManagementFacade {
readonly isLoading$: Observable<boolean> = this.storeFacade.isLoading$;
constructor(private storeFacade: UserStoreFacade) {}
// Pages can call these if needed
loadUsers(): void {
this.storeFacade.loadUsers();
}
refreshUsers(): void {
this.storeFacade.loadUsers();
}
}This gets exported. Pages can use it for programmatic actions.
Why Two Facades?
Separation of concerns:
Internal facade = feature implementation details
External facade = public API contract
Flexibility:
Change internal state management (NgRx → signals) without breaking pages
Internal facade can be complex
External facade stays simple
Encapsulation:
Pages only see what you want them to see
Internal complexity hidden
State Management
You have options. Pick what fits your needs.
Option 1: NgRx (Recommended for Complex Features)
Complete state management with actions, reducers, effects, selectors.
// store/actions.ts
export const loadUsers = createAction(’[User Management] Load Users’);
export const loadUsersSuccess = createAction(
‘[User Management] Load Users Success’,
props<{ users: User[] }>()
);
// store/reducers.ts
export const reducer = createReducer(
initialState,
on(loadUsers, state => ({ ...state, loading: true })),
on(loadUsersSuccess, (state, { users }) => ({ ...state, users, loading: false }))
);
// store/effects.ts
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(loadUsers),
switchMap(() =>
this.userApi.getUsers().pipe(
map(users => loadUsersSuccess({ users })),
catchError(error => of(loadUsersFailure({ error })))
)
)
)
);
Use when:
Complex state with multiple data sources
Need time-travel debugging
Large team wants predictable patterns
Option 2: Signals (Simpler Alternative)
Angular 16+ signals for reactive state.
// store/user.store.ts
@Injectable({ providedIn: ‘root’ })
export class UserStore {
private usersSignal = signal<User[]>([]);
private loadingSignal = signal(false);
readonly users = this.usersSignal.asReadonly();
readonly isLoading = this.loadingSignal.asReadonly();
constructor(private userApi: UserApiService) {}
loadUsers(): void {
this.loadingSignal.set(true);
this.userApi.getUsers().subscribe(users => {
this.usersSignal.set(users);
this.loadingSignal.set(false);
});
}
}Use when:
Simpler state needs
Want less boilerplate
Working with Angular 16+
Option 3: Service with BehaviorSubject
Classic reactive service pattern.
// services/user-state.service.ts
@Injectable({ providedIn: ‘root’ })
export class UserStateService {
private usersSubject = new BehaviorSubject<User[]>([]);
private loadingSubject = new BehaviorSubject(false);
readonly users$ = this.usersSubject.asObservable();
readonly isLoading$ = this.loadingSubject.asObservable();
constructor(private userApi: UserApiService) {}
loadUsers(): void {
this.loadingSubject.next(true);
this.userApi.getUsers().subscribe(users => {
this.usersSubject.next(users);
this.loadingSubject.next(false);
});
}
}
Use when:
Simple features
Don’t want NgRx overhead
Familiar RxJS patterns
Pick one per feature. Don’t mix approaches within a single feature.
Services
Feature-specific services stay private.
// services/user-api.service.ts
import { Injectable } from ‘@angular/core’;
import { HttpClient } from ‘@angular/common/http’;
import { Observable } from ‘rxjs’;
import { User } from ‘../models/user.interface’;
@Injectable({ providedIn: ‘root’ })
export class UserApiService {
private apiUrl = ‘/api/users’;
constructor(private http: HttpClient) {}
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
getUserById(id: string): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
deleteUser(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
Not exported. Only the store/state service uses this.
Public API (index.ts)
This is your feature’s contract with the outside world.
// src/app/features/user-management/index.ts
// Only export what pages need
export * from ‘./user-management.module’;
export * from ‘./user-management.facade’;
// Everything else stays private:
// ❌ UserStoreFacade
// ❌ UserApiService
// ❌ UserCardComponent
// ❌ Store internals (actions, reducers, effects)
// ❌ Models (unless other features need them)Golden rule: Export as little as possible. Pages should only see:
The module (to import)
The facade (for programmatic actions)
Maybe types if shared across features
That’s it.
The Module
// user-management.module.ts
import { NgModule } from ‘@angular/core’;
import { CommonModule } from ‘@angular/common’;
import { EffectsModule } from ‘@ngrx/effects’;
import { StoreModule } from ‘@ngrx/store’;
import { UserCardComponent } from ‘./components/user-card/user-card.component’;
import { UserManagementContainer } from ‘./containers/user-management-container/user-management.container’;
import { UserManagementFacade } from ‘./user-management.facade’;
import { UserEffects } from ‘./store/effects’;
import { featureKey, reducer } from ‘./store/reducers’;
@NgModule({
imports: [
CommonModule,
EffectsModule.forFeature([UserEffects]),
StoreModule.forFeature(featureKey, reducer)
],
declarations: [
UserCardComponent,
UserManagementContainer
],
exports: [
UserManagementContainer // Only export the container
],
providers: [
UserManagementFacade
]
})
export class UserManagementModule { }Only export the container. Internal components stay private.
Feature Modes
Features should adapt to different contexts via inputs, not configuration.
@Component({
selector: ‘app-user-management-container’,
template: `...`
})
export class UserManagementContainer {
@Input() mode: ‘full’ | ‘compact’ | ‘selection’ = ‘full’;
@Input() maxItems?: number;
@Input() showActions = true;
@Output() userSelected = new EventEmitter<User>();
}Usage on different pages:
<!-- Dashboard: compact view -->
<app-user-management-container
mode=”compact”
[maxItems]=”5”>
</app-user-management-container>
<!-- Admin: full view -->
<app-user-management-container
mode=”full”>
</app-user-management-container>
<!-- Selection dialog -->
<app-user-management-container
mode=”selection”
(userSelected)=”handleSelection($event)”>
</app-user-management-container>Same feature, different contexts. No code changes needed.
Common Mistakes
Mistake 1: Exporting Too Much
// ❌ DON’T
export * from ‘./components/user-card/user-card.component’;
export * from ‘./services/user-api.service’;
export * from ‘./store/actions’;
These couple of pages to your internals. Change implementation = break pages.
// ✅ DO
export * from ‘./user-management.module’;
export * from ‘./user-management.facade’;
Mistake 2: Smart Components
// ❌ DON’T: Component injecting services
export class UserCardComponent {
constructor(private userService: UserApiService) {}
}Components should be dumb. Move logic to the container.
// ✅ DO: Dumb component with inputs/outputs
export class UserCardComponent {
@Input() user: User;
@Output() delete = new EventEmitter<User>();
}Mistake 3: Feature-to-Feature Dependencies
// ❌ DON’T: Feature importing another feature
import { ProductCatalogFacade } from ‘../product-catalog’;Features should be independent. If they need to communicate, let the page coordinate.
Mistake 4: No Facade
// ❌ DON’T: Exporting store directly
export * from ‘./store/facade’;Always wrap internals in a feature facade. Gives you flexibility to change implementation.
Testing Strategy
Test Components in Isolation
describe(’UserCardComponent’, () => {
it(’should emit select event on click’, () => {
const component = new UserCardComponent();
const user = { id: ‘1’, name: ‘John’ };
component.user = user;
spyOn(component.select, ‘emit’);
component.onSelect();
expect(component.select.emit).toHaveBeenCalledWith(user);
});
});
Test Containers with Mocked Facade
describe(’UserManagementContainer’, () => {
let facade: jasmine.SpyObj<UserStoreFacade>;
beforeEach(() => {
facade = jasmine.createSpyObj(’UserStoreFacade’, [’loadUsers’]);
});
it(’should load users on init’, () => {
const container = new UserManagementContainer(facade);
container.ngOnInit();
expect(facade.loadUsers).toHaveBeenCalled();
});
});Test Feature Integration
describe(’UserManagementModule’, () => {
it(’should render container with users’, async () => {
const fixture = TestBed.createComponent(UserManagementContainer);
// Test full feature behavior
});
});Clean architecture = easy testing.
The Feature Checklist
Before marking a feature “done”:
Containers handle smart logic, components stay dumb
Internal store facade wraps state management
External feature facade provides a simple API
Only the module and facade are exported via index.ts
Services are private to the feature
No dependencies on other features
Container supports different modes via inputs
All components have unit tests
Feature works in isolation
Summary
Features are mini-applications. Get the internal structure right, and everything else becomes easy.
Key takeaways:
✅ Use container/component pattern (smart/dumb)
✅ Two facades: internal (store) + external (feature)
✅ Pick one state approach per feature (NgRx/signals/service)
✅ Keep services private
✅ Export minimal API via index.ts
✅ Support multiple modes via inputs
✅ Test components and containers separately
Build features this way, and they’ll work beautifully on any page you compose them into.
What’s Next
PFC #4 - From Monolith to Composition
Practical migration strategies. How to adopt PFC in existing projects without rewriting everything. Team workflows and common pitfalls.
PFC #5 - Real-World Examples
Complete feature and page implementations working together. Dashboard example, form flows, and handling complex scenarios.
Your Challenge
Pick one feature from your current codebase:
Can you identify what should be containers vs components?
What would you expose via the facade?
What services should stay private?
How many “modes” does it need to support?
Share your analysis in the comments. Next week, we tackle migration strategies.
Part 3 of the Page-Feature Composition series. Follow along at better-frontend.dev for practical Angular architecture.
Previous: PFC #2 - Pages as Orchestrators
Next: PFC #4 - Migration Strategies
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 ← You are here
- PFC #4 - Migration Strategies
- PFC #5 - Real-World Examples

