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.

← Sebelumnya: State   Berikutnya: Mediator →

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