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 didist/[nama-app]/browser/(bukan hanyadist/). Path ini berubah tergantung Angular version, cekangular.jsonarchitect.build.options.outputPathuntuk 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);
}
}
@fordan@ifadalah control flow baru (sejak Angular 17) yang lebih performant dari*ngFordan*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;
});
}
}
Pakaiinject()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. PakaitakeUntil(destroy$)pattern atauasyncpipe 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:
signaluntuk state lokal (form input, toggle, counter)Observableuntuk HTTP, WebSocket, event bustoSignal()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
depsinstall,builderbuild denganng build,runnerserve dengan nginx.- Standalone components adalah default sejak Angular 17. Tidak perlu
NgModulelagi. Lebih simpel dan tree-shakeable.- Services dengan
providedIn: 'root'adalah singleton di seluruh aplikasi. Pakaiinject()function untuk DI yang ringkas.- RxJS untuk HTTP, event, dan async stream. Selalu unsubscribe dengan
takeUntil(destroy$)atauasyncpipe.- HTTP Interceptor untuk attach token, logging, dan error handling. Functional interceptor (Angular 17+) lebih ringan dari class-based.
- Functional guards untuk proteksi route.
CanActivateFnlebih 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
@formencegah DOM reuse yang salah. Selalu sertakan untuk list dinamis.- Angular CLI di container, bukan host.
npm install -g @angular/cli@18di Dockerfile.dev.- Anonymous volume untuk
node_modulesdan.angularmencegah 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 serveHMR. 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.