Angular #

Angular adalah framework frontend enterprise yang opinionated — punya opinion tentang module, dependency injection, dan reactive programming (RxJS). Sejak Angular 17, Angular pakai standalone components dan esbuild sebagai default build tool. Sejak Angular 18, zoneless mode sudah stabil untuk aplikasi yang lebih performant.

Untuk local development, Angular CLI + dev server memberikan live reload otomatis. Docker memastikan setiap developer punya Node version sama, Angular CLI version sama, dan dependency native terisolasi. Cocok untuk aplikasi skala besar dengan banyak developer.

Artikel ini membahas setup Angular dengan Docker Compose untuk local development, termasuk standalone components, services, RxJS, dan production image dengan nginx.

Prasyarat #

Pastikan sudah terinstall:

  • Docker dan Docker Compose versi terbaru
  • Node.js 20+ (opsional, untuk tooling)

Project Angular standar (bootstrap dengan ng new):

my-angular-app/
├── src/
│   ├── app/
│   │   ├── app.component.ts
│   │   ├── app.config.ts
│   │   ├── app.routes.ts
│   │   ├── core/
│   │   │   ├── services/
│   │   │   ├── guards/
│   │   │   └── interceptors/
│   │   ├── features/
│   │   │   ├── products/
│   │   │   └── auth/
│   │   └── shared/
│   │       ├── components/
│   │       └── pipes/
│   ├── main.ts
│   ├── index.html
│   └── styles.css
├── public/
├── angular.json
├── package.json
├── package-lock.json
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml

Arsitektur Angular #

Angular mengorganisir aplikasi dengan struktur yang konsisten:

flowchart TB
    Bootstrap[main.ts bootstrap]
    Config[app.config.ts providers]
    Routes[app.routes.ts]
    App[AppComponent]
    Feature1[Feature: Products]
    Feature2[Feature: Auth]
    Service[Service: ProductApi]
    Guard[AuthGuard]
    Interceptor[AuthInterceptor]
    
    Bootstrap --> Config
    Bootstrap --> App
    Config --> Routes
    App --> Feature1
    App --> Feature2
    Feature1 --> Service
    Feature2 --> Guard
    Config --> Interceptor
    Service -.HTTP.-> Interceptor
  • main.ts — entry point, bootstrap dengan config
  • app.config.ts — root providers (HTTP, routing, dll)
  • app.routes.ts — route definitions
  • standalone components — komponen tanpa module (default sejak Angular 17)
  • services — singleton dengan dependency injection
  • guards — proteksi route
  • interceptors — HTTP middleware

Dockerfile Multi-Stage #

Angular build menghasilkan static output. Production cukup nginx.

# syntax=docker/dockerfile:1.6

# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

# ---- Stage 2: Build Angular ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV=production
RUN npm run build -- --configuration production

# ---- Stage 3: Production dengan nginx ----
FROM nginx:1.27-alpine
COPY --from=builder /app/dist/my-angular-app/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Sejak Angular 17, output build ada di dist/[nama-app]/browser/ (bukan hanya dist/). Path ini berubah tergantung Angular version, cek angular.json architect.build.options.outputPath untuk path yang benar.

nginx.conf untuk Angular SPA:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Cache Angular hashed assets
    location ~* \.(js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA fallback
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy API
    location /api/ {
        proxy_pass http://api:3001/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

docker-compose.yml untuk Development #

# docker-compose.yml
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: my-angular-app:dev
    container_name: angular-dev
    command: npm run start -- --host 0.0.0.0 --port 4200
    ports:
      - "4200:4200"
    volumes:
      - ./:/app
      - /app/node_modules
      - /app/.angular
    environment:
      - NODE_ENV=development
    depends_on:
      - api

  api:
    build: ./api
    image: my-api:dev
    container_name: angular-api
    ports:
      - "3001:3001"
    environment:
      - DATABASE_URL=postgresql://app:dev@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db-data:

Dockerfile.dev:

FROM node:20-alpine
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm install

# Install Angular CLI globally di container
RUN npm install -g @angular/cli@18

COPY . .

EXPOSE 4200

CMD ["ng", "serve", "--host", "0.0.0.0", "--port", "4200"]

Penjelasan Service #

web — Angular dev server. Bind mount source code, anonymous volume untuk node_modules dan .angular (cache build). Port 4200 (default Angular).

api dan db — backend service.

Pasang Angular CLI di container, bukan di host. Ini memastikan semua developer pakai Angular CLI version yang sama. Kalau host pakai CLI 18 dan image pakai 17, bisa terjadi error subtle pada schematic atau builder.

Standalone Components #

Sejak Angular 17, standalone components adalah default. Tidak perlu NgModule.

// src/app/features/products/product-list/product-list.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProductService } from '../../../core/services/product.service';
import { ProductCardComponent } from '../product-card/product-card.component';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CommonModule, ProductCardComponent],
  template: `
    <h1>Produk</h1>
    <p>Filter: <input (input)="onFilterChange($event)" /></p>
    
    <div class="grid">
      @for (product of filteredProducts(); track product.id) {
        <app-product-card [product]="product" />
      } @empty {
        <p>Tidak ada produk.</p>
      }
    </div>
  `,
  styleUrl: './product-list.component.css',
})
export class ProductListComponent {
  private productService = inject(ProductService);
  
  filterTerm = signal('');
  products = signal<Product[]>([]);
  
  filteredProducts = computed(() => {
    const term = this.filterTerm().toLowerCase();
    return this.products().filter(p => p.name.toLowerCase().includes(term));
  });
  
  constructor() {
    this.productService.getAll().subscribe(products => {
      this.products.set(products);
    });
  }
  
  onFilterChange(event: Event) {
    const input = event.target as HTMLInputElement;
    this.filterTerm.set(input.value);
  }
}
@for dan @if adalah control flow baru (sejak Angular 17) yang lebih performant dari *ngFor dan *ngIf. Gunakan untuk semua komponen baru.

Services dengan Dependency Injection #

Services adalah class dengan @Injectable() yang disuntikkan ke komponen. Angular punya hierarchical injector — service yang di-provide di root adalah singleton.

// src/app/core/services/product.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map, shareReplay } from 'rxjs';
import { Product } from '../../models/product.model';

@Injectable({
  providedIn: 'root', // Singleton di seluruh aplikasi
})
export class ProductService {
  private http = inject(HttpClient);
  private apiUrl = '/api/products';
  
  // Cache produk dengan shareReplay
  private products$ = this.http.get<Product[]>(this.apiUrl).pipe(
    shareReplay({ bufferSize: 1, refCount: true })
  );
  
  getAll(): Observable<Product[]> {
    return this.products$;
  }
  
  getById(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.apiUrl}/${id}`);
  }
  
  create(product: Omit<Product, 'id'>): Observable<Product> {
    return this.http.post<Product>(this.apiUrl, product);
  }
  
  update(id: number, product: Partial<Product>): Observable<Product> {
    return this.http.put<Product>(`${this.apiUrl}/${id}`, product);
  }
  
  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
}

Pakai di komponen:

import { Component, inject, OnInit } from '@angular/core';
import { ProductService } from '../core/services/product.service';
import { Product } from '../models/product.model';

@Component({
  selector: 'app-product-list',
  standalone: true,
  template: `
    @for (product of products; track product.id) {
      <p>{{ product.name }} — Rp {{ product.price.toLocaleString('id-ID') }}</p>
    }
  `,
})
export class ProductListComponent implements OnInit {
  private productService = inject(ProductService);
  products: Product[] = [];
  
  ngOnInit() {
    this.productService.getAll().subscribe(products => {
      this.products = products;
    });
  }
}
Pakai inject() function daripada constructor injection untuk kode yang lebih ringkas. inject() bisa dipanggil di property initializer, sedangkan constructor injection hanya di constructor.

RxJS untuk Reactive Programming #

RxJS adalah inti Angular. Semua HTTP client return Observable.

Stream transformation:

import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { Subject, takeUntil, debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ProductService } from './product.service';

@Component({
  selector: 'app-product-search',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="searchControl" placeholder="Cari produk..." />
    <ul>
      @for (product of results; track product.id) {
        <li>{{ product.name }}</li>
      }
    </ul>
  `,
})
export class ProductSearchComponent implements OnInit, OnDestroy {
  private productService = inject(ProductService);
  private destroy$ = new Subject<void>();
  
  searchControl = new FormControl('');
  results: any[] = [];
  
  ngOnInit() {
    this.searchControl.valueChanges.pipe(
      debounceTime(300),          // Tunggu 300ms setelah user berhenti mengetik
      distinctUntilChanged(),     // Abaikan value yang sama
      switchMap(term => this.productService.search(term ?? '')),
      takeUntil(this.destroy$),
    ).subscribe(results => {
      this.results = results;
    });
  }
  
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Subject sebagai Event Bus:

import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';

interface Notification {
  type: 'success' | 'error' | 'info';
  message: string;
}

@Injectable({ providedIn: 'root' })
export class NotificationService {
  private notifications$ = new Subject<Notification>();
  
  get notifications(): Observable<Notification> {
    return this.notifications$.asObservable();
  }
  
  success(message: string) {
    this.notifications$.next({ type: 'success', message });
  }
  
  error(message: string) {
    this.notifications$.next({ type: 'error', message });
  }
}
Selalu unsubscribe dari Observable untuk mencegah memory leak. Pakai takeUntil(destroy$) pattern atau async pipe di template (auto-unsubscribe).

HTTP Interceptor #

Interceptor adalah middleware HTTP — cocok untuk attach token, logging, atau error handling.

// src/app/core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const router = inject(Router);
  const token = authService.getToken();
  
  // Attach token jika ada dan request ke API sendiri
  if (token && req.url.startsWith('/api')) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` },
    });
  }
  
  return next(req).pipe(
    catchError((error) => {
      if (error.status === 401) {
        authService.logout();
        router.navigate(['/login']);
      }
      return throwError(() => error);
    })
  );
};

Daftarkan di app.config.ts:

// src/app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Routing dengan Guards #

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent),
  },
  {
    path: 'products',
    loadComponent: () => import('./features/products/product-list.component').then(m => m.ProductListComponent),
  },
  {
    path: 'products/:id',
    loadComponent: () => import('./features/products/product-detail.component').then(m => m.ProductDetailComponent),
  },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
  },
  {
    path: '**',
    loadComponent: () => import('./features/not-found/not-found.component').then(m => m.NotFoundComponent),
  },
];

Functional guard (Angular 17+):

// src/app/core/guards/auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);
  
  if (authService.isAuthenticated()) {
    return true;
  }
  
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

Signals untuk Fine-Grained Reactivity #

Sejak Angular 16, signals adalah cara modern mengelola state lokal. Lebih simpel dari RxJS untuk UI state.

import { Component, signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Count: {{ count() }} (double: {{ double() }})</p>
    <button (click)="increment()">Tambah</button>
    <button (click)="reset()">Reset</button>
  `,
})
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);
  
  constructor() {
    // Side effect otomatis saat count berubah
    effect(() => {
      console.log('Count changed:', this.count());
    });
  }
  
  increment() {
    this.count.update(v => v + 1);
  }
  
  reset() {
    this.count.set(0);
  }
}
State Pakai
UI state (count, toggle, form input) signal()
Computed value computed()
Side effect effect()
Async data (HTTP, event) Observable + RxJS
Global state lintas komponen signal() di service

Build dan Run #

# Development
docker compose up --build
# Akses: http://localhost:4200

# Production build
docker build -t my-angular-app:prod -f Dockerfile .
docker run -p 8080:80 my-angular-app:prod

# Lihat log
docker compose logs -f web

# Stop
docker compose down

Best Practice #

1. Standalone Components untuk Semua #

Sejak Angular 17, standalone adalah default. Hindari NgModule kecuali maintain project lama.

2. Functional Guards dan Interceptors #

CanActivateFn dan HttpInterceptorFn lebih ringan dari class-based. Pakai untuk semua guard dan interceptor baru.

3. inject() Function daripada Constructor Injection #

// ✓ Lebih ringkas
private http = inject(HttpClient);

// ✗ Verbose
constructor(private http: HttpClient) {}

4. Lazy Load Routes #

loadComponent: () => import('./feature.component').then(m => m.FeatureComponent)

Angular CLI otomatis code-split per lazy route.

5. OnPush Change Detection #

Untuk komponen yang jarang berubah, pakai OnPush untuk skip change detection:

import { ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-product',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class ProductComponent {}

6. Track Function di @for #

@for (product of products; track product.id) {
  ...
}

track mencegah DOM reuse yang salah saat list berubah.

7. Unsubscribe dari Observable #

Pakai async pipe di template (auto-unsubscribe) atau takeUntil(destroy$) di class.

@Component({
  template: `
    <ul>
      @for (item of items$ | async; track item.id) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `,
})
export class MyComponent {
  items$ = this.service.getItems();
}

8. Pakai Signals untuk UI State, RxJS untuk Async Stream #

Kombinasi yang ideal:

  • signal untuk state lokal (form input, toggle, counter)
  • Observable untuk HTTP, WebSocket, event bus
  • toSignal() untuk convert Observable ke signal saat masuk template

9. Vite host: '0.0.0.0' #

Wajib di ng serve atau angular.json serve.options.host. Tanpa ini, dev server tidak bisa diakses dari host.

10. Angular CLI di Container, Bukan Host #

Pastikan npm install -g @angular/cli di Dockerfile.dev. Semua developer pakai version yang sama.


Troubleshooting #

HMR Tidak Update #

Pastikan ng serve --host 0.0.0.0 (atau konfigurasi di angular.json). Cek docker compose logs -f web untuk error.

“Cannot find module” di AOT Build #

Biasanya karena circular dependency atau module resolution issue. Cek tsconfig.json paths dan restart container.

“NullInjectorError: No provider for X” #

Service tidak di-provide. Untuk root singleton, tambahkan providedIn: 'root'. Untuk scoped, tambahkan di providers array komponen.

Build Gagal dengan Budget Exceeded #

Angular punya budget default untuk bundle size. Naikkan di angular.json:

{
  "budgets": [
    {
      "type": "initial",
      "maximumWarning": "500kb",
      "maximumError": "1mb"
    }
  ]
}

Port 4200 Konflik #

lsof -i :4200

Ubah port mapping Compose atau stop proses.

“NG0” Error (Missing Provider) #

Service atau directive lupa di-import. Untuk standalone component, tambahkan di imports array.


Ringkasan #

  • Angular ideal untuk SPA enterprise dengan TypeScript, RxJS, dan dependency injection.
  • Dockerfile multi-stage dengan nginx static serving. Tahap deps install, builder build dengan ng build, runner serve dengan nginx.
  • Standalone components adalah default sejak Angular 17. Tidak perlu NgModule lagi. Lebih simpel dan tree-shakeable.
  • Services dengan providedIn: 'root' adalah singleton di seluruh aplikasi. Pakai inject() function untuk DI yang ringkas.
  • RxJS untuk HTTP, event, dan async stream. Selalu unsubscribe dengan takeUntil(destroy$) atau async pipe.
  • HTTP Interceptor untuk attach token, logging, dan error handling. Functional interceptor (Angular 17+) lebih ringan dari class-based.
  • Functional guards untuk proteksi route. CanActivateFn lebih simpel dari class-based guard.
  • Signals untuk UI state lokal (counter, toggle, form). RxJS untuk async stream. Kombinasi keduanya adalah pola modern.
  • Lazy load routes dengan loadComponent: () => import(...) untuk code splitting otomatis.
  • OnPush change detection untuk komponen yang jarang berubah. Skip check yang tidak perlu, performa lebih baik.
  • Track function di @for mencegah DOM reuse yang salah. Selalu sertakan untuk list dinamis.
  • Angular CLI di container, bukan host. npm install -g @angular/cli@18 di Dockerfile.dev.
  • Anonymous volume untuk node_modules dan .angular mencegah konflik dengan host.
  • Troubleshooting: HMR mati (cek host), NullInjector (cek provider), budget exceeded (naikkan limit), NG0 error (cek imports).
  • Build output ada di dist/[nama-app]/browser/ sejak Angular 17. Path ini penting untuk Dockerfile.
  • Vite masih dalam experimental untuk Angular. Default masih pakai webpack/esbuild.
  • TypeScript adalah wajib di Angular. Type safety di service, component, dan template.
  • Hot reload via ng serve HMR. State dipertahankan untuk komponen OnPush dengan signal.
  • Production image static dengan nginx. Bundle kecil setelah tree-shake, lazy load, dan minification.
  • Best practice: standalone, functional guards/interceptors, inject(), lazy load, OnPush, signals + RxJS hybrid, unsubscribe.

← Sebelumnya: Svelte   Berikutnya: Best Practice →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact