Wednesday, December 31, 2025

Angular Component Architecture Best Practices

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 → orchestration

  • OrderListComponent → UI

  • OrderService → business logic

  • OrderMapper → 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:

  1. The component now knows too much — it knows where data comes from and how to update it.

  2. Harder to reuse — what if you want the same component in another page that uses a different service?

  3. 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:

QuestionIf 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 TypeBelongs In
API callsSmart Component / Facade
WebSocket handlingFacade / Service
Business rulesService
Value formattingPipe / Mapper
UI enable/disableDumb Component
State coordinationContainer
Side effectsEffect / 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), };

onAction(type: string, data: Order) { handlers[type]?.(data); }

πŸ“Œ Rule:

If you see 3+ condition branches → move logic out.


πŸ”Ÿ Component Size Limits (Hard Rule)

FileMax
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

interface OrderApiResponse {
  id: number;
  customerName: string;
  orderDate: string; // in ISO format
  status: string;
  permissions: { canEdit: boolean };
}

@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
}

@Input() vm: OrderViewModel;

<h1>{{ vm.title }}</h1>
<p>{{ vm.displayDate }}</p>
<button *ngIf="vm.canEdit">Edit</button>

✅ Benefits:

  1. Dumb components stay dumb — no logic in the template.

  2. No formatting logic — all transformation happens outside.

  3. Easier to test — the component only displays data.

  4. Stable UI — if the API changes, only the ViewModel mapping needs updating.

  5. 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:

  1. An @Input reference changes

  2. An event happens inside the component (click, input, etc.)

  3. An observable emits (via async pipe)

  4. You manually call markForCheck()

If component breaks with OnPush → it had wrong responsibility.

Why do components “break” with OnPush?

A component “breaks” when:
  • Data changes

  • UI does NOT update

Typical reason
this.orders.push(newOrder); // mutation

Why?

  • Reference didn’t change

  • Angular thinks: “Nothing changed”

  • UI doesn’t refresh

4️⃣ Wrong responsibilities (very common)

❌ Component doing business logic

this.orders.push(...)
this.orders.sort(...)
this.orders[0].status = 'DONE';

❌ Component pulling data itself

constructor(private orderService: OrderService) {}

❌ Component transforming raw API data

get displayDate() {
  return new Date(this.order.createdAt).toLocaleString();
}

 Correct responsibilities (OnPush-friendly)

✅ Component should:

  • Receive immutable data via @Input

  • Render UI

  • Emit events

  • Subscribe via async pipe

@Input() orders!: OrderViewModel[];
@Output() orderClicked = new EventEmitter<OrderViewModel>();

<div *ngFor="let order of orders" (click)="orderClicked.emit(order)">
  {{ order.title }}
</div>
  • 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:

  1. Everything is mixed together – date formatting, tax rules, UI, API → no cohesion.

  2. Hard to maintain – one change might break unrelated functionality.

  3. Hard to test – unit tests must mock/save unrelated methods.

  4. 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 async pipe) 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

The container component (or parent) subscribes to the observable:
    orders$: Observable<Order[]> = this.orderService.getOrders();

    orders: Order[] = [];

    ngOnInit() {
      this.orders$.subscribe(data => this.orders = data);
    }

Then the dumb child component simply displays 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 async pipe 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.

Better:
  • Use @Input for data and @Output for 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 async pipe 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

πŸ“Œ 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

If you follow this, your Angular code will:

  • Be testable

  • Be refactor-friendly

  • Scale without chaos


No comments:

Post a Comment

LeetCode C++ Cheat Sheet June

🎯 Core Patterns & Representative Questions 1. Arrays & Hashing Two Sum – hash map → O(n) Contains Duplicate , Product of A...