Template Method Pattern #
Ada tiga jenis laporan keuangan yang harus dihasilkan sistem: laporan bulanan dalam format PDF, laporan kuartalan dalam format Excel, dan laporan tahunan dalam format HTML. Alur penyusunannya sama untuk ketiganya — ambil data dari database, validasi kelengkapan data, lakukan agregasi, format sesuai output, simpan ke storage, kirim notifikasi. Yang berbeda hanya bagaimana cara mengambil data (query berbeda per periode), bagaimana memformat output (PDF vs Excel vs HTML), dan ke mana menyimpannya. Tanpa Template Method, ada dua pilihan buruk: salin-tempel keseluruhan alur tiga kali dengan risiko inkonsistensi, atau gabungkan semuanya dalam satu fungsi raksasa penuh if-else. Template Method menawarkan pilihan ketiga yang lebih baik: definisikan alur di satu tempat, biarkan setiap implementasi mengisi langkah yang berbeda.
Apa itu Template Method Pattern? #
Template Method Pattern adalah behavioral design pattern yang mendefinisikan skeleton sebuah algoritma dalam satu method, sambil mendelegasikan beberapa langkah ke implementasi yang berbeda. Urutan dan struktur algoritma tetap sama dan tidak berubah; yang berbeda adalah isi dari langkah-langkah tertentu.
Dua jenis langkah dalam Template Method:
- Fixed steps (invariant) — langkah yang sama untuk semua implementasi; didefinisikan langsung di dalam template method; tidak bisa di-override
- Variable steps (variant) — langkah yang berbeda per implementasi; didefinisikan sebagai method terpisah yang harus atau boleh di-override
Ada juga jenis ketiga yang opsional:
- Hook methods — langkah opsional dengan implementasi default (biasanya kosong atau no-op); implementasi boleh meng-override jika perlu, boleh tidak
flowchart TD
subgraph "Template Method — Run()"
direction TB
S1["1. Validate() ← variable\nsetiap implementasi berbeda"]
S2["2. FetchData() ← variable\nsetiap implementasi berbeda"]
S3["3. Transform() ← fixed\nsama untuk semua"]
S4["4. BeforeFormat() ← hook\nopsional, default no-op"]
S5["5. Format() ← variable\nsetiap implementasi berbeda"]
S6["6. Save() ← variable\nsetiap implementasi berbeda"]
S7["7. AfterSave() ← hook\nopsional, default no-op"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
end
CSV[CSVProcessor] -->|implements| S1
CSV -->|implements| S2
CSV -->|implements| S5
CSV -->|implements| S6
JSON[JSONProcessor] -->|implements| S1
JSON -->|implements| S2
JSON -->|implements| S5
JSON -->|implements| S6
Golang Tidak Punya Abstract Class — Ini Solusinya #
Template Method Pattern secara klasik menggunakan abstract class. Golang tidak punya abstract class, tapi ada dua pendekatan idiomatik yang menghasilkan hasil serupa.
Pendekatan 1: Interface + Fungsi Template Terpisah #
// DataProcessor mendefinisikan langkah-langkah yang bisa divariasikan.
type DataProcessor interface {
Validate() error
FetchData() ([]byte, error)
Format(data []byte) ([]byte, error)
Save(formatted []byte) error
}
// Run adalah template method — mendefinisikan urutan algoritma.
// Fungsi ini adalah "abstract class" versi Golang.
func Run(processor DataProcessor) error {
// Langkah fixed: validasi terlebih dahulu
if err := processor.Validate(); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Langkah variable: ambil data
data, err := processor.FetchData()
if err != nil {
return fmt.Errorf("fetch failed: %w", err)
}
// Langkah fixed: log progress (sama untuk semua)
log.Printf("data fetched: %d bytes", len(data))
// Langkah variable: format
formatted, err := processor.Format(data)
if err != nil {
return fmt.Errorf("format failed: %w", err)
}
// Langkah variable: simpan
if err := processor.Save(formatted); err != nil {
return fmt.Errorf("save failed: %w", err)
}
return nil
}
// Penggunaan:
Run(&CSVProcessor{...})
Run(&JSONProcessor{...})
Pendekatan 2: Struct Embedding dengan Method Override Simulasi #
// BaseProcessor menyediakan implementasi default untuk hook methods
// dan menyimpan template method.
type BaseProcessor struct {
impl ProcessorImpl // interface ke implementasi konkret
}
type ProcessorImpl interface {
Validate() error
FetchData() ([]byte, error)
Format(data []byte) ([]byte, error)
Save(formatted []byte) error
// Hook methods — implementasi default tersedia
BeforeFormat(data []byte) []byte // default: return data tanpa modifikasi
AfterSave() error // default: no-op
}
// Run adalah template method yang tidak bisa di-override.
func (b *BaseProcessor) Run() error {
if err := b.impl.Validate(); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
data, err := b.impl.FetchData()
if err != nil {
return fmt.Errorf("fetch failed: %w", err)
}
data = b.impl.BeforeFormat(data) // hook — opsional
formatted, err := b.impl.Format(data)
if err != nil {
return fmt.Errorf("format failed: %w", err)
}
if err := b.impl.Save(formatted); err != nil {
return fmt.Errorf("save failed: %w", err)
}
return b.impl.AfterSave() // hook — opsional
}
Pendekatan mana yang lebih baik bergantung konteks. Pendekatan 1 (interface + fungsi) lebih idiomatic Golang karena explicit dan mudah di-test. Pendekatan 2 lebih dekat ke implementasi klasik OOP dengan hook methods.
Implementasi Lengkap: Data Pipeline #
Template Method dan Interface #
package pipeline
import (
"context"
"fmt"
"log/slog"
"time"
)
// PipelineResult menyimpan ringkasan hasil eksekusi pipeline.
type PipelineResult struct {
ProcessorName string
RecordsRead int
RecordsWritten int
Duration time.Duration
Error error
}
// DataPipeline mendefinisikan langkah-langkah yang harus diimplementasikan
// oleh setiap konkret pipeline.
type DataPipeline interface {
// Name mengembalikan nama pipeline untuk logging.
Name() string
// Validate memvalidasi konfigurasi dan ketersediaan resource.
Validate(ctx context.Context) error
// Extract mengambil data dari sumber.
Extract(ctx context.Context) ([]Record, error)
// Transform mengubah data (validasi, normalisasi, konversi).
// Default implementasi: return records tanpa modifikasi (hook).
Transform(ctx context.Context, records []Record) ([]Record, error)
// Load menyimpan data ke tujuan.
Load(ctx context.Context, records []Record) (int, error)
// OnSuccess dipanggil setelah pipeline berhasil (hook — opsional).
OnSuccess(ctx context.Context, result PipelineResult)
// OnError dipanggil jika pipeline gagal (hook — opsional).
OnError(ctx context.Context, err error)
}
// Record merepresentasikan satu baris data yang mengalir melalui pipeline.
type Record map[string]interface{}
// BasePipeline menyediakan implementasi default untuk hook methods.
// Semua concrete pipeline boleh embed struct ini untuk menghindari
// duplikasi implementasi hook yang tidak diperlukan.
type BasePipeline struct{}
// Transform default: kembalikan records tanpa modifikasi.
func (b *BasePipeline) Transform(ctx context.Context, records []Record) ([]Record, error) {
return records, nil
}
// OnSuccess default: no-op.
func (b *BasePipeline) OnSuccess(ctx context.Context, result PipelineResult) {}
// OnError default: no-op.
func (b *BasePipeline) OnError(ctx context.Context, err error) {}
// Execute adalah Template Method — mendefinisikan urutan langkah pipeline.
// Urutan ini TIDAK bisa diubah oleh implementasi konkret.
// Semua concrete pipeline mendapatkan urutan yang sama dan terjamin.
func Execute(ctx context.Context, pipeline DataPipeline, logger *slog.Logger) PipelineResult {
start := time.Now()
result := PipelineResult{ProcessorName: pipeline.Name()}
logger.InfoContext(ctx, "pipeline starting", "name", pipeline.Name())
// Langkah 1: Validate (fixed — selalu ada)
if err := pipeline.Validate(ctx); err != nil {
result.Error = fmt.Errorf("validation failed: %w", err)
pipeline.OnError(ctx, result.Error)
return result
}
// Langkah 2: Extract (fixed — selalu ada)
records, err := pipeline.Extract(ctx)
if err != nil {
result.Error = fmt.Errorf("extract failed: %w", err)
pipeline.OnError(ctx, result.Error)
return result
}
result.RecordsRead = len(records)
logger.InfoContext(ctx, "extract complete", "records", len(records))
// Langkah 3: Transform (variable — implementasi default = pass-through)
transformed, err := pipeline.Transform(ctx, records)
if err != nil {
result.Error = fmt.Errorf("transform failed: %w", err)
pipeline.OnError(ctx, result.Error)
return result
}
logger.InfoContext(ctx, "transform complete", "records", len(transformed))
// Langkah 4: Load (fixed — selalu ada)
written, err := pipeline.Load(ctx, transformed)
if err != nil {
result.Error = fmt.Errorf("load failed: %w", err)
pipeline.OnError(ctx, result.Error)
return result
}
result.RecordsWritten = written
result.Duration = time.Since(start)
logger.InfoContext(ctx, "pipeline complete",
"name", pipeline.Name(),
"read", result.RecordsRead,
"written", result.RecordsWritten,
"duration_ms", result.Duration.Milliseconds(),
)
pipeline.OnSuccess(ctx, result)
return result
}
Concrete Pipeline 1: CSV ke Database #
package pipeline
import (
"context"
"database/sql"
"encoding/csv"
"fmt"
"os"
"strings"
"time"
)
// CSVToDBPipeline membaca CSV dan menyimpannya ke database.
// Mengimplementasikan Transform untuk membersihkan data.
type CSVToDBPipeline struct {
BasePipeline // embed untuk mendapat default hooks
FilePath string
TableName string
DB *sql.DB
NotifySvc NotificationService // untuk hook OnSuccess
}
func (p *CSVToDBPipeline) Name() string { return "csv-to-db:" + p.FilePath }
func (p *CSVToDBPipeline) Validate(ctx context.Context) error {
if _, err := os.Stat(p.FilePath); os.IsNotExist(err) {
return fmt.Errorf("file not found: %s", p.FilePath)
}
if p.TableName == "" {
return fmt.Errorf("table name is required")
}
if err := p.DB.PingContext(ctx); err != nil {
return fmt.Errorf("database connection failed: %w", err)
}
return nil
}
func (p *CSVToDBPipeline) Extract(ctx context.Context) ([]Record, error) {
file, err := os.Open(p.FilePath)
if err != nil {
return nil, fmt.Errorf("cannot open file: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("cannot read CSV: %w", err)
}
if len(rows) < 2 {
return nil, fmt.Errorf("CSV must have header row and at least one data row")
}
headers := rows[0]
records := make([]Record, 0, len(rows)-1)
for _, row := range rows[1:] {
record := make(Record)
for i, value := range row {
if i < len(headers) {
record[headers[i]] = value
}
}
records = append(records, record)
}
return records, nil
}
// Transform membersihkan data: trim whitespace, normalisasi field kosong.
// Override hook method dari BasePipeline — ini yang berbeda dari pipeline lain.
func (p *CSVToDBPipeline) Transform(ctx context.Context, records []Record) ([]Record, error) {
cleaned := make([]Record, 0, len(records))
for _, record := range records {
clean := make(Record)
skip := false
for k, v := range record {
str, ok := v.(string)
if !ok {
clean[k] = v
continue
}
str = strings.TrimSpace(str)
// Skip record jika field kunci kosong
if k == "id" && str == "" {
skip = true
break
}
clean[k] = str
}
if !skip {
clean["imported_at"] = time.Now().Format(time.RFC3339)
cleaned = append(cleaned, clean)
}
}
return cleaned, nil
}
func (p *CSVToDBPipeline) Load(ctx context.Context, records []Record) (int, error) {
if len(records) == 0 {
return 0, nil
}
tx, err := p.DB.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("cannot begin transaction: %w", err)
}
defer tx.Rollback()
count := 0
for _, record := range records {
// Build INSERT query dari record keys
columns := make([]string, 0, len(record))
placeholders := make([]string, 0, len(record))
values := make([]interface{}, 0, len(record))
i := 1
for col, val := range record {
columns = append(columns, col)
placeholders = append(placeholders, fmt.Sprintf("$%d", i))
values = append(values, val)
i++
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON CONFLICT DO NOTHING",
p.TableName,
strings.Join(columns, ", "),
strings.Join(placeholders, ", "),
)
if _, err := tx.ExecContext(ctx, query, values...); err != nil {
return count, fmt.Errorf("insert failed: %w", err)
}
count++
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit failed: %w", err)
}
return count, nil
}
// OnSuccess override: kirim notifikasi setelah berhasil.
func (p *CSVToDBPipeline) OnSuccess(ctx context.Context, result PipelineResult) {
if p.NotifySvc == nil {
return
}
msg := fmt.Sprintf("Pipeline %s selesai: %d records diimport ke %s dalam %v",
result.ProcessorName, result.RecordsWritten, p.TableName, result.Duration)
_ = p.NotifySvc.Notify(ctx, "[email protected]", msg)
}
Concrete Pipeline 2: API ke Cloud Storage #
// APIToStoragePipeline mengambil data dari REST API dan menyimpannya ke cloud storage.
// Tidak mengimplementasikan Transform (menggunakan default pass-through dari BasePipeline).
type APIToStoragePipeline struct {
BasePipeline // tidak perlu Transform — data langsung disimpan
APIEndpoint string
APIKey string
BucketName string
OutputPath string
HTTPClient *http.Client
Storage CloudStorage
}
func (p *APIToStoragePipeline) Name() string {
return fmt.Sprintf("api-to-storage:%s", p.APIEndpoint)
}
func (p *APIToStoragePipeline) Validate(ctx context.Context) error {
if p.APIEndpoint == "" {
return fmt.Errorf("API endpoint is required")
}
if p.BucketName == "" {
return fmt.Errorf("bucket name is required")
}
// Test koneksi ke API
req, err := http.NewRequestWithContext(ctx, http.MethodHead, p.APIEndpoint, nil)
if err != nil {
return fmt.Errorf("invalid API endpoint: %w", err)
}
req.Header.Set("Authorization", "Bearer "+p.APIKey)
resp, err := p.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("API unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("invalid API key")
}
return nil
}
func (p *APIToStoragePipeline) Extract(ctx context.Context) ([]Record, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.APIEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+p.APIKey)
resp, err := p.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("API call failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var records []Record
if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
return nil, fmt.Errorf("cannot decode response: %w", err)
}
return records, nil
}
// Transform TIDAK diimplementasikan — menggunakan default dari BasePipeline (pass-through).
// Data dari API langsung disimpan tanpa transformasi.
func (p *APIToStoragePipeline) Load(ctx context.Context, records []Record) (int, error) {
data, err := json.Marshal(records)
if err != nil {
return 0, fmt.Errorf("cannot marshal records: %w", err)
}
key := fmt.Sprintf("%s/%s.json", p.OutputPath, time.Now().Format("2006-01-02T15-04-05"))
if err := p.Storage.Upload(ctx, p.BucketName, key, data, "application/json"); err != nil {
return 0, fmt.Errorf("upload failed: %w", err)
}
return len(records), nil
}
Penggunaan: Semua Pipeline Menggunakan Execute yang Sama #
func main() {
ctx := context.Background()
logger := slog.Default()
// Dua pipeline yang berbeda, tapi Execute dipanggil dengan cara yang sama
pipelines := []DataPipeline{
&CSVToDBPipeline{
FilePath: "data/users.csv",
TableName: "users",
DB: db,
NotifySvc: notifSvc,
},
&APIToStoragePipeline{
APIEndpoint: "https://api.external.com/v1/products",
APIKey: cfg.APIKey,
BucketName: "raw-data",
OutputPath: "products",
HTTPClient: &http.Client{Timeout: 30 * time.Second},
Storage: gcsStorage,
},
}
for _, p := range pipelines {
result := Execute(ctx, p, logger)
if result.Error != nil {
log.Printf("Pipeline %s failed: %v", result.ProcessorName, result.Error)
continue
}
log.Printf("Pipeline %s: read=%d written=%d duration=%v",
result.ProcessorName, result.RecordsRead, result.RecordsWritten, result.Duration)
}
}
Studi Kasus Kedua: Report Generator #
Report generator adalah contoh klasik Template Method — alur penyusunan selalu sama, yang berbeda adalah format output dan sumber data.
package report
import "context"
// ReportData menyimpan data yang akan diformat ke laporan.
type ReportData struct {
Title string
Period string
Rows []map[string]interface{}
Summary map[string]interface{}
}
// ReportGenerator mendefinisikan langkah-langkah yang bervariasi per format.
type ReportGenerator interface {
Name() string
FetchData(ctx context.Context, period string) (*ReportData, error)
Render(data *ReportData) ([]byte, error)
ContentType() string
FileExtension() string
// Hook: sebelum render (opsional — default no-op)
PreRender(data *ReportData) *ReportData
}
// BaseReportGenerator menyediakan default hook.
type BaseReportGenerator struct{}
func (b *BaseReportGenerator) PreRender(data *ReportData) *ReportData {
return data // default: tidak ada modifikasi
}
// GenerateReport adalah Template Method untuk report generation.
func GenerateReport(ctx context.Context, gen ReportGenerator, period, outputDir string) (string, error) {
// Langkah 1: Ambil data (variable)
data, err := gen.FetchData(ctx, period)
if err != nil {
return "", fmt.Errorf("fetch data failed: %w", err)
}
// Langkah 2: Hook pre-render (variable/optional)
data = gen.PreRender(data)
// Langkah 3: Validasi data (fixed — sama untuk semua)
if len(data.Rows) == 0 {
return "", fmt.Errorf("no data available for period: %s", period)
}
// Langkah 4: Render (variable — PDF, Excel, HTML berbeda)
content, err := gen.Render(data)
if err != nil {
return "", fmt.Errorf("render failed: %w", err)
}
// Langkah 5: Simpan ke file (fixed — sama untuk semua)
filename := fmt.Sprintf("%s/%s_%s.%s",
outputDir,
strings.ToLower(strings.ReplaceAll(data.Title, " ", "_")),
period,
gen.FileExtension(),
)
if err := os.WriteFile(filename, content, 0644); err != nil {
return "", fmt.Errorf("save failed: %w", err)
}
return filename, nil
}
// PDFReportGenerator mengimplementasikan render ke PDF.
type PDFReportGenerator struct {
BaseReportGenerator
db *sql.DB
pdfSvc PDFService
}
func (g *PDFReportGenerator) Name() string { return "PDF Report Generator" }
func (g *PDFReportGenerator) ContentType() string { return "application/pdf" }
func (g *PDFReportGenerator) FileExtension() string { return "pdf" }
func (g *PDFReportGenerator) FetchData(ctx context.Context, period string) (*ReportData, error) {
// Query spesifik untuk monthly PDF report
rows, err := g.db.QueryContext(ctx,
"SELECT date, revenue, expenses FROM financials WHERE period = $1 ORDER BY date", period)
if err != nil {
return nil, err
}
defer rows.Close()
// ... scan rows ...
return &ReportData{Title: "Financial Report", Period: period}, nil
}
func (g *PDFReportGenerator) Render(data *ReportData) ([]byte, error) {
return g.pdfSvc.GeneratePDF(data.Title, data.Rows, data.Summary)
}
// HTMLReportGenerator mengimplementasikan render ke HTML.
type HTMLReportGenerator struct {
BaseReportGenerator
db *sql.DB
tmplPath string
}
func (g *HTMLReportGenerator) Name() string { return "HTML Report Generator" }
func (g *HTMLReportGenerator) ContentType() string { return "text/html" }
func (g *HTMLReportGenerator) FileExtension() string { return "html" }
func (g *HTMLReportGenerator) FetchData(ctx context.Context, period string) (*ReportData, error) {
// Query yang berbeda untuk HTML report (mungkin lebih detail)
return &ReportData{Title: "Financial Report", Period: period}, nil
}
func (g *HTMLReportGenerator) Render(data *ReportData) ([]byte, error) {
tmpl, err := template.ParseFiles(g.tmplPath)
if err != nil {
return nil, fmt.Errorf("cannot parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("render failed: %w", err)
}
return buf.Bytes(), nil
}
// Override PreRender hook — tambahkan field computed sebelum render
func (g *HTMLReportGenerator) PreRender(data *ReportData) *ReportData {
// Hitung total untuk summary
var total float64
for _, row := range data.Rows {
if rev, ok := row["revenue"].(float64); ok {
total += rev
}
}
data.Summary["grand_total"] = total
return data
}
Template Method vs Strategy: Kapan Mana #
Ini adalah dua pattern yang sering dikacaukan karena keduanya memungkinkan variasi algoritma. Perbedaannya fundamental.
// Template Method: urutan langkah tetap, beberapa langkah berbeda
// Algoritmanya satu, variasinya di dalam
func Execute(p DataPipeline) error {
p.Validate() // selalu pertama
p.Extract() // selalu kedua
p.Transform() // selalu ketiga (boleh berbeda implementasi)
p.Load() // selalu keempat
// Urutan TIDAK bisa diubah oleh implementasi
}
// Strategy: seluruh algoritma bisa berbeda
// Algoritmanya banyak, client memilih yang mana
type ShippingStrategy interface {
Calculate(order Order) (int, error)
}
// JNE, J&T, GoSend — algoritma perhitungan yang sama sekali berbeda
// Client memilih strategi; tidak ada urutan langkah yang dipaksa
| Aspek | Template Method | Strategy |
|---|---|---|
| Kontrol urutan | Template method mengontrol urutan | Client memilih algoritma mana yang dijalankan |
| Granularitas variasi | Sebagian langkah bervariasi | Seluruh algoritma bervariasi |
| Relasi antar implementasi | Semua share urutan yang sama | Setiap implementasi independen |
| Keterbacaan | Alur jelas terlihat di template method | Alur tersembunyi di masing-masing implementasi |
| Cocok untuk | ETL pipeline, report generation, workflow | Sorting, pricing, routing algorithm |
Kombinasi Template Method dan Strategy #
Keduanya bisa dikombinasikan untuk kasus yang lebih kompleks — Template Method mendefinisikan urutan, Strategy mengisi langkah yang sangat bervariasi.
// DataPipeline dengan Transform yang menggunakan Strategy Pattern
type FlexiblePipeline struct {
BasePipeline
source DataSource // strategy untuk Extract
transformer DataTransformer // strategy untuk Transform
sink DataSink // strategy untuk Load
}
// Run adalah Template Method — urutan tetap
func (p *FlexiblePipeline) Run(ctx context.Context) error {
// Langkah fixed: log mulai
log.Printf("Pipeline starting")
// Langkah variable (via strategy): ambil data
data, err := p.source.Read(ctx)
if err != nil {
return err
}
// Langkah variable (via strategy): transform
transformed, err := p.transformer.Transform(ctx, data)
if err != nil {
return err
}
// Langkah variable (via strategy): simpan
if err := p.sink.Write(ctx, transformed); err != nil {
return err
}
// Langkah fixed: log selesai
log.Printf("Pipeline complete")
return nil
}
// Penggunaan: ganti strategy tanpa mengubah urutan
pipeline := &FlexiblePipeline{
source: &CSVSource{filePath: "data.csv"},
transformer: &CleaningTransformer{removeNulls: true},
sink: &PostgreSink{db: db, table: "users"},
}
pipeline.Run(ctx)
// Ganti sink ke cloud storage — urutan tetap sama
pipeline.sink = &S3Sink{bucket: "raw-data", key: "users.json"}
pipeline.Run(ctx)
Testing Template Method #
// MockPipeline untuk testing Execute template method
type MockPipeline struct {
BasePipeline
name string
validateErr error
extractData []Record
extractErr error
transformData []Record
transformErr error
loadCount int
loadErr error
successCalled bool
errorCalled bool
}
func (m *MockPipeline) Name() string { return m.name }
func (m *MockPipeline) Validate(ctx context.Context) error {
return m.validateErr
}
func (m *MockPipeline) Extract(ctx context.Context) ([]Record, error) {
return m.extractData, m.extractErr
}
func (m *MockPipeline) Transform(ctx context.Context, records []Record) ([]Record, error) {
if m.transformData != nil {
return m.transformData, m.transformErr
}
return records, m.transformErr
}
func (m *MockPipeline) Load(ctx context.Context, records []Record) (int, error) {
if m.loadErr != nil {
return 0, m.loadErr
}
m.loadCount = len(records)
return m.loadCount, nil
}
func (m *MockPipeline) OnSuccess(ctx context.Context, result PipelineResult) {
m.successCalled = true
}
func (m *MockPipeline) OnError(ctx context.Context, err error) {
m.errorCalled = true
}
func TestExecute_HappyPath(t *testing.T) {
mock := &MockPipeline{
name: "test-pipeline",
extractData: []Record{{"id": "1", "name": "Alice"}, {"id": "2", "name": "Bob"}},
}
result := Execute(context.Background(), mock, slog.Default())
if result.Error != nil {
t.Fatalf("expected no error, got: %v", result.Error)
}
if result.RecordsRead != 2 {
t.Errorf("expected 2 records read, got %d", result.RecordsRead)
}
if result.RecordsWritten != 2 {
t.Errorf("expected 2 records written, got %d", result.RecordsWritten)
}
if !mock.successCalled {
t.Error("OnSuccess should have been called")
}
if mock.errorCalled {
t.Error("OnError should NOT have been called")
}
}
func TestExecute_StopsAtValidationError(t *testing.T) {
mock := &MockPipeline{
name: "test-pipeline",
validateErr: fmt.Errorf("config missing"),
}
result := Execute(context.Background(), mock, slog.Default())
if result.Error == nil {
t.Error("expected error from validation failure")
}
if result.RecordsRead != 0 {
t.Error("no records should be read after validation failure")
}
if !mock.errorCalled {
t.Error("OnError should have been called on validation failure")
}
}
func TestExecute_StopsAtExtractError(t *testing.T) {
mock := &MockPipeline{
name: "test-pipeline",
extractErr: fmt.Errorf("source unavailable"),
}
result := Execute(context.Background(), mock, slog.Default())
if result.Error == nil {
t.Error("expected error from extract failure")
}
if mock.loadCount != 0 {
t.Error("Load should not be called after Extract failure")
}
}
func TestCSVToDBPipeline_Transform_SkipsEmptyID(t *testing.T) {
pipeline := &CSVToDBPipeline{}
records := []Record{
{"id": "1", "name": "Alice "},
{"id": "", "name": "Bob"}, // harus diskip karena ID kosong
{"id": "3", "name": " Charlie"},
}
result, err := pipeline.Transform(context.Background(), records)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Errorf("expected 2 records after skipping empty ID, got %d", len(result))
}
// Verifikasi trimming
if result[0]["name"] != "Alice" {
t.Errorf("expected trimmed name 'Alice', got %q", result[0]["name"])
}
}
Kapan Menggunakan dan Kapan Tidak #
GUNAKAN Template Method jika:
✓ Banyak proses memiliki alur yang sama tapi beberapa langkah berbeda
✓ Ingin memastikan semua implementasi mengikuti urutan yang sama
✓ Ada langkah yang identik di semua implementasi (tidak mau duplikasi)
✓ Membangun framework atau library yang perlu extensibility di langkah tertentu
✓ Ada hook points yang opsional untuk customisasi ringan
HINDARI Template Method jika:
✗ Urutan langkah bervariasi antar implementasi — gunakan Strategy
✗ Hanya satu atau dua langkah yang berbeda dan sangat sederhana — closure sudah cukup
✗ Implementasi perlu kontrol penuh atas algoritma — Template Method terlalu restrictive
✗ Tidak ada langkah yang di-share — tidak ada manfaat dari template
Checklist Review Template Method #
DESAIN:
□ Template method mendefinisikan urutan yang tidak bisa diubah oleh implementasi
□ Setiap method "variable" didefinisikan dalam interface dengan jelas
□ Hook methods memiliki implementasi default yang masuk akal (bukan panic)
□ Langkah yang sama di semua implementasi ada di template, bukan diulang
IMPLEMENTASI:
□ BasePipeline atau BaseProcessor menyediakan default untuk hook methods
□ Error dari setiap langkah ditangani di template method (tidak dibiarkan propagate sembarangan)
□ Template method mencatat progress (logging) di setiap langkah kunci
□ OnSuccess dan OnError dipanggil dengan konsisten
TESTING:
□ Template method ditest dengan MockPipeline — verifikasi urutan eksekusi
□ Test bahwa langkah selanjutnya tidak dipanggil jika langkah sebelumnya gagal
□ Setiap concrete implementation ditest untuk langkah yang mereka override
□ Hook methods ditest (OnSuccess dipanggil saat sukses, OnError saat gagal)
Ringkasan #
- Template Method mendefinisikan skeleton algoritma yang tidak bisa diubah — urutan langkah dikunci di template; implementasi hanya bisa mengisi langkah tertentu.
- Tiga jenis method: fixed (selalu sama, tidak bisa di-override), variable (harus diimplementasikan berbeda), dan hook (opsional dengan default sensible).
- Golang tanpa abstract class: gunakan interface untuk mendefinisikan variable steps, fungsi terpisah sebagai template method, dan struct embed untuk default hook implementations.
- BasePipeline/BaseProcessor menghindari duplikasi — default implementasi hook di-share; concrete pipeline hanya override yang memang perlu berbeda.
- Cocok untuk ETL, report generation, workflow — semua proses yang punya alur baku dengan variasi di beberapa titik.
- Kombinasi dengan Strategy: Template Method mengontrol urutan; Strategy mengisi langkah yang algoritmanya sangat bervariasi — keduanya bekerja sangat baik bersama.
- Bedakan dari Strategy: Template Method mengunci urutan, hanya beberapa langkah yang berbeda; Strategy memungkinkan seluruh algoritma berbeda tanpa urutan yang dipaksa.
- Testing yang bersih: MockPipeline memungkinkan test template method tanpa implementasi konkret; setiap concrete pipeline ditest secara terisolasi.