Component Architecture, focused on how to judge a component and say “this responsibility is wrong”.
Component Architecture (Deep, Practical)
1️⃣ Smart vs Dumb Components (Container vs Presentational)
πΉ Smart Components (Container)
Responsibilities
Fetch data (API, store, websocket)
Hold business logic
Decide what happens
Should NOT
Handle HTML-heavy UI logic
Format display strings
Know CSS/layout details
Example
@Component(...)
export class UserPageComponent {
users$ = this.userService.getUsers();
onUserSelected(userId: number) {
this.router.navigate(['/users', userId]);
}
}
πΉ Dumb Components (Presentational)
Responsibilities
Display data
Emit user actions
Should NOT
Call services
Know routes
Contain business rules
@Component(...)
export class UserListComponent {
@Input() users: User[];
@Output() select = new EventEmitter<number>();
}
✅ Rule:
If you can reuse it in another app → it should be dumb.
2️⃣ Single Responsibility per Component
A component should change for only ONE reason
❌ Bad Smells
Component handles:
Data fetching
Validation
Formatting
Navigation
State caching
// π« God Component
export class OrderComponent {
loadOrders() {}
validateOrder() {}
calculateTax() {}
formatDate() {}
navigateToPayment() {}
}
✅ Correct Split
OrderPageComponent→ orchestrationOrderListComponent→ UIOrderService→ business logicOrderMapper→ formatting / transformation
3️⃣ Inputs / Outputs Discipline (VERY IMPORTANT)
❌ Anti-pattern
constructor(private orderService: OrderService) {}
someMethod() {
this.orderService.updateOrder(this.order);
}Inside a child UI component ❌
❌ Problems:
-
The component now knows too much — it knows where data comes from and how to update it.
-
Harder to reuse — what if you want the same component in another page that uses a different service?
-
Harder to test — you now need to mock the service every time you test the component.
✅ Correct
@Input() orders: Order[]; // Input = data from parent
@Output() orderClicked = new EventEmitter<Order>(); // Output = events to parent<div *ngFor="let order of orders" (click)="orderClicked.emit(order)">
{{ order.title }}
</div>Mental Rule
Inputs = data
Outputs = events
No services inside dumb components
- Never injected in dumb UI components — business logic stays outside.
4️⃣ Avoiding God Components
A God Component:
500 lines
5+ services injected
10+ methods
Handles multiple screens or modes
π Quick Smell Test
Ask:
“Can I extract a section into its own component?”
“Is this method actually UI-related?”
“Does this logic belong to a service?”
If YES → component is already too big.
5️⃣ The “Responsibility Test” (Memorize This)
When you look at any component, ask:
| Question | If YES → Problem |
|---|---|
| Is it fetching data? | Should be a container |
| Is it formatting values? | Move to pipe/mapper |
| Is it validating rules? | Move to service |
| Is it navigating? | Only parent allowed |
| Is it handling multiple views? | Split component |
π If a component answers YES to more than 2 → it’s wrong
6️⃣ Practical Refactor Pattern (Real-World)
Before
engagement.component.ts
WebSocket handling
Form logic
Table rendering
Action buttons
State management
After
engagement-page.component.ts (smart)
engagement-form.component.ts (dumb)
engagement-table.component.ts (dumb)
engagement.actions.ts (logic)
engagement.mapper.ts (formatting)
This pattern fits Angular 14–19, works with RxJS, Material, and Signals.
7️⃣ Final Rule You Should Live By
If you delete the HTML, the TS file should almost feel useless
If TS still feels “powerful” → logic is in the wrong place.
8️⃣ “Where does this logic belong?” (Decision Table)
When you see logic, classify it instantly:
| Logic Type | Belongs In |
|---|---|
| API calls | Smart Component / Facade |
| WebSocket handling | Facade / Service |
| Business rules | Service |
| Value formatting | Pipe / Mapper |
| UI enable/disable | Dumb Component |
| State coordination | Container |
| Side effects | Effect / Service |
❌ Wrong
if (order.amount > 100000 && user.role === 'ADMIN') {
this.buttonDisabled = false;
}
✅ Correct
this.canApprove$ = this.orderRules.canApprove(order, user);
9️⃣ “No If-Else Hell in Components”
❌ Bad Smell
onAction(type: string, data: Order) {
if (type === 'CREATE') {
this.createOrder(data);
} else if (type === 'UPDATE') {
this.updateOrder(data);
} else if (type === 'DELETE') {
this.deleteOrder(data);
}
}
✅ Replace With Strategy
const handlers = { CREATE: (data: Order) => this.createOrder(data), UPDATE: (data: Order) => this.updateOrder(data), DELETE: (data: Order) => this.deleteOrder(data), };π Rule:
If you see 3+ condition branches → move logic out.
π Component Size Limits (Hard Rule)
| File | Max |
|---|---|
| Component TS | 150–200 lines |
| Template HTML | 150 lines |
| Methods | ≤ 5 public methods |
If exceeded → split immediately.
1️⃣1️⃣ ViewModel Pattern
Instead of passing raw API models:
❌ Bad
@Input() order: OrderApiResponse;
<h1>{{ order.customerName.toUpperCase() }}</h1>
<p>{{ (order.orderDate | date:'shortDate') }}</p>
<button *ngIf="order.permissions.canEdit">Edit</button>❌ Problems:
Your template now contains logic (formatting, transformations).
If the API changes (like orderDate format), your component breaks.
The component is not reusable — it depends on the API shape.
Raw API = raw vegetables straight from the farm. You have to cook them inside the component (template).
✅ Good
interface OrderViewModel {
title: string; // already formatted
canEdit: boolean; // extracted permission
displayDate: string; // already formatted for UI
}
<h1>{{ vm.title }}</h1>
<p>{{ vm.displayDate }}</p>
<button *ngIf="vm.canEdit">Edit</button>✅ Benefits:
-
Dumb components stay dumb — no logic in the template.
-
No formatting logic — all transformation happens outside.
-
Easier to test — the component only displays data.
-
Stable UI — if the API changes, only the ViewModel mapping needs updating.
ViewModel = pre-cut, pre-cooked vegetables ready to eat. Component just serves them.
1️⃣2️⃣ No Mutation Inside Components
❌ Wrong
this.orders.push(newOrder);
Here, this.orders is the same array object as before, but with one extra item. Angular’s OnPush change detection may not detect this change because the reference of this.orders hasn’t changed — only its content has.✅ Correct
this.orders = [...this.orders, newOrder];[...] is the spread operator, which creates a new array.Now this.orders is a new object reference, so Angular’s OnPush will detect it and update the UI properly.π Mutation hides bugs and breaks OnPush.
1️⃣3️⃣ Push Change Detection by Default
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
By default, Angular checks everything all the time.
Angular will re-check the component ONLY if:
An @Input reference changes
An event happens inside the component (click, input, etc.)
An observable emits (via async pipe)
You manually call markForCheck()
If component breaks with OnPush → it had wrong responsibility.
Why do components “break” with OnPush?
-
Data changes
-
UI does NOT update
Why?
-
Reference didn’t change
-
Angular thinks: “Nothing changed”
-
UI doesn’t refresh
❌ Component doing business logic
❌ Component pulling data itself
❌ Component transforming raw API data
Correct responsibilities (OnPush-friendly)
✅ Component should:
-
Receive immutable data via
@Input -
Render UI
-
Emit events
-
Subscribe via
asyncpipe
- No mutation. No logic. No magic.
1️⃣4️⃣ Services Are NOT Dumping Grounds
❌ Bad Service
export class AppService {
formatDate() {} // presentation logic
calculateTax() {} // business logic
showToast() {} // UI notification
saveData() {} // API call
}
Problems:
Everything is mixed together – date formatting, tax rules, UI, API → no cohesion.
Hard to maintain – one change might break unrelated functionality.
Hard to test – unit tests must mock/save unrelated methods.
Hard to reuse – you often only want calculateTax, but the service drags everything alon
✅ Good
OrderRulesService.ts calculateDiscount(), validateOrder(), calculateTax()
OrderApiService.ts getOrders(), saveOrder(), deleteOrder()
OrderMapper.ts mapOrderApiToVm(), mapOrderVmToApi()
ToastService.ts showSuccess(), showError()π Services should be cohesive, not generic.
1️⃣5️⃣ Avoid Passing Observables Everywhere
❌ Wrong
@Input() orders$: Observable<Order[]>;- You’re giving a stream (
Observable) to a UI component. - The component now has to subscribe (or use
asyncpipe) and manage async logic. - If the component is “dumb,” it should only display data, not care if it comes from an API, a BehaviorSubject, or something else.
- Makes testing harder, because now the component depends on async streams.
✅ Better
orders: @Input() orders: Order[];
<div *ngFor="let order of orders">
{{ order.title }}
</div>rule
- Containers = smart → handle observables, subscriptions, async logic
- UI components = dumb → render data, emit events
- Never force dumb components to deal with async
Exception: async pipe only in containers
You might still pass an observable if the container uses the
asyncpipe itself.But never pass
$streams directly to dumb components.
1️⃣6️⃣ Naming Reveals Responsibility
Bad names hide problems.
| Bad | Good |
|---|---|
DataComponent | OrderListComponent |
HandleLogic() | approveOrder() |
CommonService | OrderApprovalService |
π If the name is vague → responsibility is unclear.
1️⃣7️⃣ The “Delete Test” (Advanced)
Ask:
“If I delete this component, what breaks?”
| Breaks | Meaning |
|---|---|
| UI only | Good |
| Business rules | ❌ wrong |
| API calls | ❌ wrong |
| App navigation | ❌ wrong |
1️⃣8️⃣ Anti-Patterns You Must Recognize Instantly
❌ @Input() setter with logic
@Input() set orders(value: Order[]) {
this.orders = value.filter(o => o.active); // ❌ logic in setter
}
Problem:
-
The setter is doing business/UI logic.
-
Component should just receive data, not manipulate it.
-
Hard to track changes and test.
Better:
-
Transform the data before passing it to the component, maybe in a container or via a ViewMode
❌ Child component calling parent service
constructor(private parentService: ParentService) {}
Problem:
-
Violates dumb component rule.
-
Child now depends on specific parent implementation → not reusable.
-
Breaks testability.
- Use
@Inputfor data and@Outputfor events. - Let parent service handle the logic.
❌ subscribe() inside dumb component
ngOnInit() {
this.orders$.subscribe(data => this.orders = data);
}
Problem:
-
Component now manages async streams.
-
Risk of memory leaks (forgot
unsubscribe) -
Breaks OnPush detection if you mutate data.
Better:
-
Container subscribes → passes plain array to UI component
-
Or use
asyncpipe in the template of the container only.
❌ any everywhere
orders: any; // ❌
Problem:
-
No type safety → hides bugs
-
Makes code hard to read and maintain
Better:
-
Use interfaces / types (
Order[],OrderViewModel, etc.)
❌ setTimeout() for state sync
setTimeout(() => this.orders.push(newOrder), 0);
Problem:
-
Hides async bugs
-
Often used to “force” Angular to detect changes
-
Smells like OnPush workaround
Better:
-
Use immutable updates or
ChangeDetectorRef.markForCheck() -
Never rely on timing hacks
❌ Template doing logic {{ a > b ? ... }}
<p>{{ a > b ? 'Yes' : 'No' }}</p>
Problem:
-
Logic in template → hard to test, hard to maintain
-
Templates should just render data, not calculate it
Better:
-
Prepare values in the component or ViewModel:
1️⃣9️⃣ Real-World Refactor Example (Before → After)
❌ Before
control-data.component.ts
- subscribes to WebSocket
- updates table
- validates input
- formats values
✅ After
control-page.component.ts (container)
control-state.facade.ts (state)
control-table.component.ts (UI)
control-validator.service.ts (rules)
control.mapper.ts (format)
2️⃣0️⃣ Final Mental Model (Tattoo This)
Components describe UI.
They do NOT decide business truth.
If a component decides truth → responsibility is wrong.
✅ Recommended Folder Structure (Feature-First)
src/
└── app/
├── core/ # App-wide singletons
│ ├── services/
│ │ ├── api/
│ │ │ └── websocket.service.ts
│ │ ├── auth/
│ │ │ └── auth.service.ts
│ │ └── logger.service.ts
│ └── core.module.ts
│
├── shared/ # Reusable UI & utilities
│ ├── components/
│ │ └── table/
│ │ └── base-table.component.ts
│ ├── pipes/
│ │ └── date-format.pipe.ts
│ ├── utils/
│ │ └── string.utils.ts
│ └── shared.module.ts
│
├── features/
│ └── control/ # π₯ CONTROL FEATURE
│ ├── pages/
│ │ └── control-page/
│ │ ├── control-page.component.ts
│ │ ├── control-page.component.html
│ │ ├── control-page.component.scss
│ │ └── control-page.component.spec.ts
│ │
│ ├── components/
│ │ └── control-table/
│ │ ├── control-table.component.ts
│ │ ├── control-table.component.html
│ │ ├── control-table.component.scss
│ │
│ ├── state/
│ │ ├── control-state.facade.ts
│ │ ├── control-state.model.ts
│ │ └── control-state.initial.ts
│ │
│ ├── services/
│ │ └── control-validator.service.ts
│ │
│ ├── mappers/
│ │ └── control.mapper.ts
│ │
│ ├── models/
│ │ ├── control.dto.ts
│ │ └── control.viewmodel.ts
│ │
│ ├── control-routing.module.ts
│ └── control.module.ts
│
└── app.module.ts
src/
└── app/
├── core/ # App-wide singletons
│ ├── services/
│ │ ├── api/
│ │ │ └── websocket.service.ts
│ │ ├── auth/
│ │ │ └── auth.service.ts
│ │ └── logger.service.ts
│ └── core.module.ts
│
├── shared/ # Reusable UI & utilities
│ ├── components/
│ │ └── table/
│ │ └── base-table.component.ts
│ ├── pipes/
│ │ └── date-format.pipe.ts
│ ├── utils/
│ │ └── string.utils.ts
│ └── shared.module.ts
│
├── features/
│ └── control/ # π₯ CONTROL FEATURE
│ ├── pages/
│ │ └── control-page/
│ │ ├── control-page.component.ts
│ │ ├── control-page.component.html
│ │ ├── control-page.component.scss
│ │ └── control-page.component.spec.ts
│ │
│ ├── components/
│ │ └── control-table/
│ │ ├── control-table.component.ts
│ │ ├── control-table.component.html
│ │ ├── control-table.component.scss
│ │
│ ├── state/
│ │ ├── control-state.facade.ts
│ │ ├── control-state.model.ts
│ │ └── control-state.initial.ts
│ │
│ ├── services/
│ │ └── control-validator.service.ts
│ │
│ ├── mappers/
│ │ └── control.mapper.ts
│ │
│ ├── models/
│ │ ├── control.dto.ts
│ │ └── control.viewmodel.ts
│ │
│ ├── control-routing.module.ts
│ └── control.module.ts
│
└── app.module.ts
π Why This Structure Works
π§ pages/
Purpose
Smart / container components
Connected to routing
Coordinates state & services
π Example:
pages/control-page/
π¨ components/
Purpose
Dumb UI components
Reusable inside the feature
No services, no routing
π Example:
components/control-table/
π§© state/
Purpose
Facade pattern
State orchestration
RxJS / Signals
π Example:
state/control-state.facade.ts
✔ Keeps components thin
✔ Easy to migrate to NgRx later
π§ services/
Purpose
Business rules
Validation logic
No UI, no state
π Example:
services/control-validator.service.ts
π mappers/
Purpose
Transform API → ViewModel
Formatting logic
Prevent template logic
π Example:
mappers/control.mapper.ts
π¦ models/
Purpose
Clear separation of:
API DTOs
UI ViewModels
π Example:
models/control.dto.ts
models/control.viewmodel.ts
π§ͺ Bonus: Minimal Variant (Smaller Projects)
If project is small, you can flatten:
features/control/
├── control-page.component.ts
├── control-table.component.ts
├── control-state.facade.ts
├── control-validator.service.ts
├── control.mapper.ts
├── control.models.ts
└── control.module.ts
But do NOT do this in large codebases.
π₯ Mental Rule (Remember This)
Routing → pages
UI → components
Truth → services/state
Transformation → mappers
Routing → pages
UI → components
Truth → services/state
Transformation → mappers
If you follow this, your Angular code will:
Be testable
Be refactor-friendly
Scale without chaos
No comments:
Post a Comment