Skip to content

Page-Feature Composition · an open architecture

Features own capabilities. Pages compose them.

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-Composition
A dashboard page composing three features. The user list emits a selection, the page holds it, and the page passes it into analytics as an input. The features never touch each other.
folders, one home for every file
4
import rules that hold it together
3
lines of business logic in a page
0
component framework
any
01

A component that knows where it lives can't live anywhere else.

Six 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.

user-card.component.ts
@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]);
  }
}
The same card, one new context at a time

<app-user-card

  1. [user]="user"admin panel
  2. [editRoute]="'/customers/edit'"customer dashboard
  3. [showActions]="false"
  4. variant="compact"
  5. [selectable]="true"selection dialog
  6. (editOverride)="onEdit($event)"
  7. [hideEmail]="true"profile sidebar
  8. [isCustomerContext]="true"
  9. [actionsSlot]="customActions"

></app-user-card>

props
9
contexts
4
card nobody dares to change
1

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.

02

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 componentsPFC features
GoalReusable everywhereComposable on pages
A new contextFifteen optional props, still wrongWorks unchanged, in another mode
Business logicLeaks into pages and cards alikeOwned entirely by one feature
ReuseCopy-paste a fourth UserCardCompose the same feature again
Change impactUnknown until something breaksBounded by the feature's public API
03

Four folders, and every file has exactly one home.

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.

    • user-management/
    • product-catalog/
    • order-processing/
  • pages/

    Orchestrators

    Conductors. They arrange features on screen, handle navigation and hold zero business logic.

    • dashboard/
    • admin/
    • customer-portal/
  • shared/

    Truly generic

    Buttons, pipes and utilities that work anywhere and belong to no business domain.

    • components/
    • utils/
  • core/

    App foundation

    Authentication, the layout shell and configuration. Things genuinely global to the app.

    • auth/
    • layout/
    • config/

Dependencies point one way

  1. pages
  2. features
  3. shared · core

Features own business capabilities. Pages compose features. That is it.

04

A page is a conductor. It doesn't play the violin.

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.

admin-dashboard.page.ts
export class AdminDashboardPage {
  constructor(private router: Router) {}

  onUserSelected(userId: string): void {
    this.router.navigate(['/users', userId]);
  }
}
admin-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>

  <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

  • Compose feature containers on screen
  • Handle navigation and route changes
  • Carry values from one feature to another
  • Apply layout
  • Hold route-specific state: active tab, current step, selection

A page never

  • Holds business logic
  • Calls an API
  • Manages filters, sort options or feature state
  • Builds UI inline that a feature should own

Same features, four ways to compose them.

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.

05

A feature is a mini-application with one public door.

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.

Pages never reach past the door

Why two facades?

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.

features/user-management/index.ts
export * from './user-management.module';
export * from './user-management.facade';

// Everything else stays private:
// UserStoreFacade, UserApiService, UserCardComponent,
// actions, reducers, effects, models.

Modes, not forks.

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

How big is a feature?

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

  1. Can I build it without other features existing?
  2. Can I test it in complete isolation?
  3. Can I use it on several different pages?
  4. Does it handle one clear business domain?

Three to seven components, one or two containers. When unsure, go smaller: small features merge easily later, large ones never split.

06

Three rules hold it together. Everything else is detail.

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

  1. 01

    Pages touch a feature's public API. Nothing else.

    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.

  2. 02

    Features never import features.

    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.

  3. 03

    A component that injects a service is not a component.

    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/.

How two features talk without knowing each other

  1. Feature B emits
  2. The page holds the value
  3. The page passes it into feature A

Simple and explicit. Neither feature knows the other exists, so either can be replaced, tested or moved without the other noticing.

sales-dashboard.html
<app-date-picker-container
  (dateRangeChanged)="range = $event">
</app-date-picker-container>

<app-sales-chart-container
  [dateRange]="range">
</app-sales-chart-container>
07

An architecture an agent can't misread.

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

  • app/
  • pages/dashboard.page.ts
  • pages/admin.page.ts
  • features/order-processing/
  • containers/order-list.container.ts
  • components/order-row.component.ts
  • store/facade.ts
  • order-processing.facade.ts
  • index.ts
  • features/product-catalog/
  • features/user-management/
  • shared/
  • core/
Context the agent must loadthe whole tree
To change one card safely, the agent has to read everything that might depend on it. In a coupled codebase, that is everything.

Page-Feature Composition

  • app/
  • pages/dashboard.page.ts
  • pages/admin.page.ts
  • features/order-processing/
  • containers/order-list.container.ts
  • components/order-row.component.ts
  • store/facade.ts
  • order-processing.facade.ts
  • index.ts
  • features/product-catalog/
  • features/user-management/
  • shared/
  • core/
Context the agent must loadone feature
The task fits in one folder. Pages see the feature only through index.ts, so nothing outside it can depend on what the agent changes.

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.

02

One answer to “where does this go?”

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

Rules a script can check

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

A bounded blast radius

Pages see a feature only through its barrel. Whatever an agent rewrites behind index.ts cannot break a page that never imported it.

05

Parallel work, few collisions

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

Diffs a reviewer can judge

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.

What the agent sees

$ 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

An illustrative run of the boundary check this site's build runs first.

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.

08

One pattern. Any component framework.

The rules are about the direction of dependencies, so they survive a change of syntax. The same dashboard page, four spellings.

Angular
@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>
React
// 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>
  );
}
Vue
<!-- 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>
Svelte
<!-- 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>

The concept map

PFC conceptAngularReactVue 3Svelte
Featurefolder + NgModulefolder + barrelfolder + barrelfolder + barrel
Containercomponent with injected facadecomponent using the feature's hookcomponent using a composablecomponent using the feature's store
Component@Input() / @Output()props + callback propsdefineProps / defineEmitsprops + events
Internal facade@Injectable over NgRx or signalsprivate Zustand or Redux hookprivate Pinia storeprivate writable store
Public facadeexported @Injectableexported hookexported composableexported store + actions
Modes@Input() modemode propmode propmode prop
Pagerouted componentroute componentroute 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.

09

Install the pattern into your agent.

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-Composition

pfc

triggers on its own

Build

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.

  • features/order-history/ with one container, three components
  • store facade private, public facade exported
  • index.ts exports the container and the facade only
  • the page composes it in one line

pfc-review

triggers on its own

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?

  • CRITICAL features/cart imports features/catalog
  • HIGH UserCard injects UserApiService
  • MEDIUM dashboard.page.ts has absorbed filters

pfc-migrate

runs when asked

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.

  • user-management extracted into a feature
  • admin.page.ts: 500 → 350 lines
  • legacy UserService bridged, marked TODO
  • next pass: product-catalog

pfc-enforce

runs when asked

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.

  • ✗ feature imports feature → build fails, fix named
  • ✗ page imports a container directly → build fails
  • ✓ page carries the value → clean

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.

10

Adopt it without stopping delivery.

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.

A realistic arc for a 500-line admin page
  1. Today500 lines
  2. Sprint 1 · users out350 lines
  3. Sprint 2 · products out200 lines
  4. Sprint 3 · orders out50 lines
  5. Sprint 4 · a clean page30 lines

Three ways in

  • Teams actively building

    New features only

    New work goes into features/ and pages/. Old code stays where it is.

    Immediate

  • Teams with one bad page

    Extract one messy page

    Split the worst page into small features, one concern per pass.

    1–2 sprints

  • Stable codebases

    Refactor when you touch it

    Boy Scout rule: extract only the part you are already changing.

    No dedicated time

The first feature is the hardest. It gets easier.

11

The questions that come up every time.

Every team that meets PFC raises the same objections. These are the short answers; the longer ones are in the series.

Isn't this over-engineered?

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.

What if two features need the same data?

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.

Can one feature use another feature's component?

No. Features are independent. A component two features need belongs in shared/. If two features overlap that much, they are probably one feature.

Where does authentication go?

core/auth. It is an app-wide concern, not a business capability. Features may inject auth services when they need them.

Do I really need two facades? Isn't that indirection?

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.

Which state management should a feature use?

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.

How do you test it?

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.

How do we decide where one feature ends?

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.

Our codebase is too big to migrate.

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.

How long does it take a new developer to learn?

About ten minutes, because the rule is one sentence: features contain business logic, pages compose features. Ownership follows from the folders.

Does it work outside Angular?

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.

Why does it suit AI coding agents in particular?

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.

12

Knowing when not to use it is part of the pattern.

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

  • Medium to large applications, ten routes and up
  • Long-lived projects that will keep evolving
  • Teams of three or more developers
  • Apps with recurring UI patterns

Skip it for

  • Landing pages and marketing sites
  • Prototypes and short-term experiments
  • Solo projects under ten routes
  • Apps that are mostly one-off interfaces