01
Context that fits the window
A feature folder is the whole capability: its components, state, services and public API. An agent working on orders loads features/order-processing/ and the page that composes it, not the app.
Page-Feature Composition · an open architecture
PFC is a frontend architecture that stops an app turning into spaghetti as it grows. It is a rule about which code may know about which, not about syntax, so it holds in Angular, React, Vue or Svelte. And because its rules are simple enough to check by machine, it is an architecture coding agents get right.
Teach it to your agent
npx skills add irekBudzowski/PFC_Page-Feature-CompositionSix months ago the project was a thing of beauty. Today you are afraid to touch anything, because changing one component might break three others you didn't know existed. The cause is almost always the same: components welded to the view they were first written for.
Here is the card everyone has written. It renders a user. It also injects the router and the user service, and it knows the admin edit route. Innocent enough: on the admin page it was built for, it works.
Then the customer dashboard needs the same card, and it navigates to the admin route. So a prop controls the route. Then one hides the actions, one switches to a compact variant, one overrides the click. Every new context is paid for in props, and every call site has to configure all of them correctly.
That is prop hell, and its population is your entire codebase. The usual escape, copying the card into a fourth UserCard, is worse: four copies that drift apart.
@Component({
selector: 'app-user-card',
templateUrl: './user-card.component.html'
})
export class UserCardComponent {
@Input() user: User;
constructor(
private userService: UserService,
private router: Router
) {}
editUser() {
this.router.navigate(['/admin/users/edit', this.user.id]);
}
}<app-user-card
></app-user-card>
The rigidity isn't in the code you can see. It is in what the component assumes about where it is rendered: which route, which page, which data. The view is the most volatile thing in a frontend. Couple a component to it and the component inherits that volatility, and so does every page that uses it.
Stop asking
How do I make this component reusable everywhere?Start asking
How do I make features composable on pages?
Components that try to be generic fail, because genericness is bought with props. Features go the other way: specific to one business capability, and composable anywhere. The page decides where a feature appears and what happens around it. The feature decides everything about how it works.
| Generic components | PFC features | |
|---|---|---|
| Goal | Reusable everywhere | Composable on pages |
| A new context | Fifteen optional props, still wrong | Works unchanged, in another mode |
| Business logic | Leaks into pages and cards alike | Owned entirely by one feature |
| Reuse | Copy-paste a fourth UserCard | Compose the same feature again |
| Change impact | Unknown until something breaks | Bounded by the feature's public API |
No more debates about where a file goes. If it is a business capability, it is a feature. If it arranges capabilities, it is a page. If it is genuinely generic, it is shared. If it is app-wide plumbing, it is core.
features/
Business capabilities
Self-contained mini-applications. Everything for user management, components, state, services, lives in one folder.
pages/
Orchestrators
Conductors. They arrange features on screen, handle navigation and hold zero business logic.
shared/
Truly generic
Buttons, pipes and utilities that work anywhere and belong to no business domain.
core/
App foundation
Authentication, the layout shell and configuration. Things genuinely global to the app.
Dependencies point one way
Features own business capabilities. Pages compose features. That is it.
A page composes features, wires their outputs to navigation and carries values between them. That is the whole job. If a page grows past about fifty lines of code, something in it belongs in a feature.
export class AdminDashboardPage {
constructor(private router: Router) {}
onUserSelected(userId: string): void {
this.router.navigate(['/users', userId]);
}
}<div class="dashboard-grid">
<app-analytics-container mode="summary"></app-analytics-container>
<app-user-management-container
mode="compact"
[maxItems]="10"
(userSelected)="onUserSelected($event)">
</app-user-management-container>
<app-notifications-container [maxItems]="5"></app-notifications-container>
</div>A real admin dashboard, in full. Eight lines of TypeScript. The template composes three features; the features do the work.
A page does
A page never
Switch the pattern. The page changes; the features don't. The switcher itself is page state, and an active tab is exactly the kind of thing a page is allowed to hold.
Grid
Features arranged in a CSS grid. The default, and the most common.
page state
// none. The layout is CSS.
A feature owns one complete business capability. Not “render a card”, not “call an API”: user management, end to end. Inside, it splits into smart and dumb. Outside, it exposes as little as possible. Think of it as an npm package you could publish.
pages/admin
imports only through the door
features/user-management/
Public
index.ts
The only door. Exports the module or container and the public facade. Nothing else.
user-management.facade.ts
The small contract a page may hold: refresh, filter by a route parameter, read a loading flag.
Private to the feature
containers/smart
Smart. Read state through the store facade, handle interactions, coordinate components. One or two per feature.
components/dumb
Dumb. Inputs in, events out. The moment one injects a service, it is a container wearing the wrong label.
store/ + internal facadeprivate
Wraps NgRx, signals, Pinia or Zustand. One state approach per feature, never two mixed.
services/private
API calls nobody outside the feature ever sees.
The internal facade is where your state library lives. The external facade is the contract pages hold. With both, you can swap NgRx for signals without touching a single page. With only one, every page is coupled to your state management choice.
export * from './user-management.module';
export * from './user-management.facade';
// Everything else stays private:
// UserStoreFacade, UserApiService, UserCardComponent,
// actions, reducers, effects, models.A feature adapts to its context through an input, never through a copy. The same user-management feature runs the full admin page, a compact dashboard tile and a selection dialog. Same code, zero modifications.
<app-user-management-container mode="compact" [maxItems]="3">
on the dashboard
The elevator pitch test: one sentence, no “and”.
“User management handles user CRUD.”
“E-commerce handles products, cart, checkout, orders and payments.”
The can-it-live-alone test
Three to seven components, one or two containers. When unsure, go smaller: small features merge easily later, large ones never split.
The folders are the visible part. The import graph is the part that matters, and the part that gets broken first, usually under deadline.
allowedforbidden
01
Never a container class, component, service, store, action or selector. A page that reaches past the barrel breaks the day the implementation changes, which is precisely the coupling the pattern exists to prevent. If a page needs more, widen the facade on purpose.
02
The violation that quietly undoes everything: once two features are coupled, neither can be tested, moved or reused alone. When they need to talk, the page carries the value. If that feels absurd because they are inseparable, they are one feature.
03
It is a container wearing the wrong label. Move the logic up into the container and pass the result down as an input, or admit what the file is and move it to containers/.
Simple and explicit. Neither feature knows the other exists, so either can be replaced, tested or moved without the other noticing.
<app-date-picker-container
(dateRangeChanged)="range = $event">
</app-date-picker-container>
<app-sales-chart-container
[dateRange]="range">
</app-sales-chart-container>PFC was written for teams. It turns out to be exactly what coding agents need, for the same reason: it replaces judgement calls with rules that have one answer, and those rules can be checked by a machine. The framework doesn't matter. The agent reasons about the direction of imports, not about syntax.
Components welded to views
Page-Feature Composition
01
A feature folder is the whole capability: its components, state, services and public API. An agent working on orders loads features/order-processing/ and the page that composes it, not the app.
02
Capability or layout? Smart or dumb? The decision order is a short algorithm, not a matter of taste, so ten runs of an agent put the file in the same place a senior engineer would.
03
Every boundary is an import direction. A short check fails the build when a feature imports a feature, and an agent fixes a red build far more reliably than it follows a paragraph of guidance.
04
Pages see a feature only through its barrel. Whatever an agent rewrites behind index.ts cannot break a page that never imported it.
05
One developer or one agent per feature, the page composed last. The property that gives human teams few merge conflicts lets several agents work at once.
06
A feature change touches one folder. A page change is composition you can read in seconds. Whether a diff respects the boundaries is visible at a glance, to a human reviewer or an agent one.
$ npm run build
> node scripts/verify-boundaries.mjs
PFC boundary violations:
✗ features/order-processing/containers/OrderList.tsx: feature "order-processing" imports feature "product-catalog"
1 violation(s). See docs/architecture/pfc.md.
# the agent moves the value up to the page and runs it again
$ npm run build
PFC boundaries: clean
✓ Compiled successfully
This is not a thought experiment. The guide you are reading is one feature, features/pfc-guide, composed by a page that holds no logic, and this site's build fails if any feature imports another.
The rules are about the direction of dependencies, so they survive a change of syntax. The same dashboard page, four spellings.
@Component({ templateUrl: './dashboard.html' })
export class DashboardPage {
constructor(private router: Router) {}
onUserSelected(id: string): void {
this.router.navigate(['/users', id]);
}
}
<!-- dashboard.html -->
<div class="dashboard-grid">
<app-analytics-container mode="summary"></app-analytics-container>
<app-user-management-container
mode="compact"
[maxItems]="10"
(userSelected)="onUserSelected($event)">
</app-user-management-container>
</div>// pages/DashboardPage.tsx
import { AnalyticsContainer } from '@/features/analytics';
import { UserManagementContainer } from '@/features/user-management';
export function DashboardPage() {
const navigate = useNavigate();
return (
<div className="dashboard-grid">
<AnalyticsContainer mode="summary" />
<UserManagementContainer
mode="compact"
maxItems={10}
onUserSelected={(id) => navigate(`/users/${id}`)}
/>
</div>
);
}<!-- pages/DashboardPage.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { AnalyticsContainer } from '@/features/analytics';
import { UserManagementContainer } from '@/features/user-management';
const router = useRouter();
</script>
<template>
<div class="dashboard-grid">
<AnalyticsContainer mode="summary" />
<UserManagementContainer
mode="compact"
:max-items="10"
@user-selected="id => router.push(`/users/${id}`)"
/>
</div>
</template><!-- routes/dashboard/+page.svelte -->
<script lang="ts">
import { goto } from '$app/navigation';
import { AnalyticsContainer } from '$lib/features/analytics';
import { UserManagementContainer } from '$lib/features/user-management';
</script>
<div class="dashboard-grid">
<AnalyticsContainer mode="summary" />
<UserManagementContainer
mode="compact"
maxItems={10}
on:userSelected={(e) => goto(`/users/${e.detail}`)}
/>
</div>| PFC concept | Angular | React | Vue 3 | Svelte |
|---|---|---|---|---|
| Feature | folder + NgModule | folder + barrel | folder + barrel | folder + barrel |
| Container | component with injected facade | component using the feature's hook | component using a composable | component using the feature's store |
| Component | @Input() / @Output() | props + callback props | defineProps / defineEmits | props + events |
| Internal facade | @Injectable over NgRx or signals | private Zustand or Redux hook | private Pinia store | private writable store |
| Public facade | exported @Injectable | exported hook | exported composable | exported store + actions |
| Modes | @Input() mode | mode prop | mode prop | mode prop |
| Page | routed component | route component | route component | +page.svelte |
The series writes its examples in Angular. The React, Vue and Svelte mappings are an adaptation of the same rules, kept in the repository as framework-translation.md.
The repository ships as agent skills. One command, and any agent that reads SKILL.md files knows PFC without being told again: Claude Code, Codex, Cursor, Amp and others. For agents that read the AGENTS.md convention instead, there is one at the repository root.
In any project
npx skills add irekBudzowski/PFC_Page-Feature-CompositionBuild
Builds features and pages the PFC way: capability or layout, smart or dumb, what may import what. And it refuses to apply the pattern where it doesn't belong.
you askAdd order history to the customer portal.
Audit
Audits code against seven checks in severity order and names the specific fix for each. When nothing is broken it says so in one line, instead of inventing findings.
you askDoes this PR follow PFC?
Extract
Extracts one feature out of a monolith with the strangler pattern. Old and new coexist, and every pass leaves the app shippable.
you askPull users out of the admin page.
Enforce
Sets up a check that fails the build on a boundary violation: ESLint boundaries, Nx module boundaries, or a zero-dependency script. Then proves it by breaking a rule on purpose.
you askMake the build fail when boundaries break.
Agents that don't load skills read AGENTS.md at the repository root: the whole pattern in four lines, the rules that must not break, and where every new file goes.
Nobody sets aside three months to “do PFC”. You take one concern out of one page, ship it, and repeat. It is the strangler pattern, named after the fig that slowly replaces its host tree: new work goes in the new shape, old code is extracted when you touch it, and both live side by side until the new one is the majority.
Teams actively building
New work goes into features/ and pages/. Old code stays where it is.
Immediate
Teams with one bad page
Split the worst page into small features, one concern per pass.
1–2 sprints
Stable codebases
Boy Scout rule: extract only the part you are already changing.
No dedicated time
The first feature is the hardest. It gets easier.
Every team that meets PFC raises the same objections. These are the short answers; the longer ones are in the series.
For a toy project, yes. For a production app that will grow, no: the structure is what lets it grow without the complexity compounding. If your app is four routes and will stay four routes, skip it.
The page coordinates. One feature emits, the page holds the value, the page passes it into the other as an input. If that feels absurd because the two are genuinely inseparable, they are one feature.
No. Features are independent. A component two features need belongs in shared/. If two features overlap that much, they are probably one feature.
core/auth. It is an app-wide concern, not a business capability. Features may inject auth services when they need them.
It is one file per feature, and it is what makes the feature swappable. The internal facade wraps your state library; the external one is the contract pages hold. Skip the split and you have exported your store, so every page depends on its shape.
NgRx for complex state with several data sources or a large team that wants predictable patterns. Signals for simpler state with less boilerplate. A service with a BehaviorSubject where NgRx would be overhead. One approach per feature, never two mixed.
Components in isolation, through their inputs and outputs. Containers against a mocked store facade. Pages as composition exercises. Clear boundaries mean no massive mock setups.
Start small. Describe it in one sentence without “and”, then check it can be built, tested and used on several pages alone. Too small beats too big: small features merge easily, large ones never split.
It doesn't matter. You are not rewriting the app. Build new work the new way, extract one concern when you touch it, and let old and new coexist. The series puts the first visible value at two to three sprints, without stopping delivery.
About ten minutes, because the rule is one sentence: features contain business logic, pages compose features. Ownership follows from the folders.
Yes. The rules are about the direction of dependencies, not syntax. The examples are Angular; React, Vue and Svelte map onto the same concepts, as the frameworks chapter shows.
Because it removes the judgement calls agents get wrong. A task fits in one feature folder, there is one answer to where a file goes, and the boundaries are import rules a build can check, so a violation comes back as a red build the agent fixes rather than a review comment it might ignore.
Applying PFC where it doesn't belong is a worse failure than not knowing it. The skills are written to say so out loud and build the simple thing instead.
Use it for
Skip it for