SoC — Separation of Concerns #

Ada satu fungsi yang hampir selalu muncul di codebase awal sebuah proyek: HTTP handler yang melakukan segalanya. Parsing request, validasi input, query database, business logic, format response, logging — semua ada dalam satu fungsi, ditulis berurutan dari atas ke bawah. Pada sprint pertama ini adalah hal yang wajar dan produktif. Tapi ketika fitur bertambah, ketika ada kebutuhan yang sama di endpoint lain, ketika ada unit test yang harus ditulis tapi tidak bisa jalan tanpa database — barulah terasa biayanya. Separation of Concerns (SoC) menyerang akar masalah ini: setiap bagian sistem harus fokus pada satu aspek, dan tidak mencampuri urusan aspek yang lain. Bukan sekadar “banyak folder” atau “lebih banyak file” — tapi pemisahan yang bermakna berdasarkan batas tanggung jawab yang jelas. Artikel ini membahas apa yang dimaksud concern, bagaimana membedakan SoC dari SRP, refactor bertahap dari handler monolitik ke arsitektur berlapis, SoC di frontend Flutter, SoC di level sistem, dan kapan pemisahan justru tidak proporsional.

Apa yang Dimaksud “Concern”? #

Concern adalah aspek atau domain permasalahan tertentu yang perlu ditangani oleh sistem. Kata kuncinya adalah “aspek” — bukan “fitur”, bukan “modul”, bukan “class”. Sebuah fitur bisa memiliki banyak concern yang saling terpisah.

Dalam aplikasi backend, concern yang umum ditemukan antara lain:

Concern                    Tanggung jawabnya
─────────────────────────  ──────────────────────────────────────────
HTTP transport             Parsing request, format response, HTTP status code
Validasi input             Format, kelengkapan, tipe data dari input luar
Business logic             Aturan domain: siapa boleh apa, bagaimana kalkulasinya
Data access                Query, insert, update ke database atau storage
Notifikasi                 Email, push notification, SMS
Autentikasi                Verifikasi identitas (siapa kamu?)
Otorisasi                  Verifikasi hak akses (apa yang boleh kamu lakukan?)
Logging & observability    Structured log, metric, trace
Konfigurasi                Membaca dan mendistribusikan env variable

Masalah muncul bukan ketika concern-concern ini ada — tapi ketika dua atau lebih concern yang berbeda dicampur dalam satu tempat. Ketika HTTP handler tahu cara query database, ia punya dua concern sekaligus. Ketika service tahu cara format JSON response, batas tanggung jawabnya sudah bocor.


SoC vs SRP — Perbedaan Level #

SoC dan SRP sering dianggap sama karena keduanya berbicara tentang pemisahan tanggung jawab. Tapi keduanya berbicara di level yang berbeda:

SRP (Single Responsibility Principle):
  Level  : modul, struct, class
  Pertanyaan: "Apakah struct/class ini punya lebih dari satu alasan untuk berubah?"
  Fokus  : granularitas implementasi

SoC (Separation of Concerns):
  Level  : sistem, arsitektur, layer
  Pertanyaan: "Apakah aspek-aspek yang berbeda dari sistem ini sudah terpisah?"
  Fokus  : struktur dan batas antar bagian sistem

Analogi:
  SRP = setiap pekerja punya satu peran (kasir hanya di kasir, koki hanya di dapur)
  SoC = setiap departemen punya domain sendiri (dapur, kasir, gudang terpisah)
  Keduanya diperlukan — SoC di level arsitektur, SRP di level implementasi

SoC adalah prinsip yang lebih luas. Layered Architecture, Clean Architecture, MVC, dan Microservices semuanya adalah implementasi SoC di level yang berbeda. SRP adalah cara menerapkan SoC di dalam setiap layer.

flowchart TD
    SOC["Separation of Concerns\n(level arsitektur & sistem)"]
    LA["Layered Architecture\n(HTTP → Service → Repository)"]
    MICRO["Microservices\n(Auth Service, Order Service, ...)"]
    MVC["MVC / MVP / MVVM\n(Model, View, Controller)"]
    SRP2["SRP di setiap layer\n(granularitas implementasi)"]

    SOC -->|"implementasi di level arsitektur"| LA
    SOC -->|"implementasi di level sistem"| MICRO
    SOC -->|"implementasi di frontend"| MVC
    LA & MICRO & MVC -->|"di dalam setiap komponen"| SRP2

    style SOC fill:#4C9BE8,color:#fff
    style SRP2 fill:#5CB85C,color:#fff

Masalah Nyata Tanpa SoC #

Sebelum melihat solusinya, penting untuk memahami biaya konkret yang muncul ketika concern dicampur — bukan sekadar “kode jadi jelek”:

1. Tidak bisa unit test business logic. Jika business logic ada di dalam HTTP handler yang langsung query database, satu-satunya cara untuk mengujinya adalah dengan menjalankan HTTP server dan database sekaligus. Test menjadi integration test secara tidak sengaja — lambat, fragile, dan sulit di-debug.

2. Logika yang sama tidak bisa digunakan ulang. Ketika ada kebutuhan yang sama dari konteks berbeda — misalnya logika yang sama perlu dipanggil dari HTTP handler dan dari background worker — kode harus diduplikasi karena ia sudah terikat dengan HTTP concern. Ini adalah DRY violation yang dipicu oleh SoC violation.

3. Perubahan di satu aspek merusak aspek lain. Mengganti database engine dari MySQL ke PostgreSQL seharusnya hanya berdampak pada layer data access. Jika SQL query tersebar di handler dan service, perubahan itu harus dilakukan di banyak tempat — dan setiap tempat adalah risiko.

4. Deployment dan scaling yang tidak fleksibel. Ketika business logic, HTTP handling, dan database access semua dalam satu binary yang tidak terpisahkan, tidak ada pilihan untuk scale-out hanya bagian yang menjadi bottleneck.


Dari Handler Monolitik ke Layered Architecture #

Refactor bertahap dari handler yang mencampur semua concern ke arsitektur berlapis adalah demonstrasi SoC yang paling langsung.

Titik awal: handler yang melakukan segalanya

// ANTI-PATTERN: semua concern dalam satu fungsi
// HTTP, validasi, business logic, database, response — semuanya di sini
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    // === Concern 1: HTTP parsing ===
    var req struct {
        UserID string      `json:"user_id"`
        Items  []struct {
            ProductID string `json:"product_id"`
            Quantity  int    `json:"quantity"`
        } `json:"items"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    // === Concern 2: Validasi ===
    if req.UserID == "" {
        http.Error(w, "user_id is required", http.StatusBadRequest)
        return
    }
    if len(req.Items) == 0 {
        http.Error(w, "at least one item required", http.StatusBadRequest)
        return
    }

    // === Concern 3: Cek user (business rule) — langsung query ===
    var userActive bool
    db.QueryRow("SELECT is_active FROM users WHERE id = ?", req.UserID).Scan(&userActive)
    if !userActive {
        http.Error(w, "user account is not active", http.StatusForbidden)
        return
    }

    // === Concern 4: Hitung total (business logic) ===
    total := 0.0
    for _, item := range req.Items {
        var price float64
        db.QueryRow("SELECT price FROM products WHERE id = ?", item.ProductID).Scan(&price)
        total += price * float64(item.Quantity)
    }

    // === Concern 5: Simpan order (data access) ===
    result, err := db.Exec(
        "INSERT INTO orders (user_id, total, status) VALUES (?, ?, 'pending')",
        req.UserID, total,
    )
    if err != nil {
        http.Error(w, "failed to create order", http.StatusInternalServerError)
        return
    }
    orderID, _ := result.LastInsertId()

    // === Concern 6: Format response ===
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "order_id": orderID,
        "total":    total,
        "status":   "pending",
    })
}
// Test untuk fungsi ini membutuhkan: HTTP server + database yang terisi data
// Business logic "hitung total" tidak bisa ditest secara independen
// Logika yang sama tidak bisa dipakai dari background worker

Langkah 1: Layer Domain — entitas dan aturan bisnis inti

// internal/order/domain.go
package order

import (
    "errors"
    "time"
)

// Order adalah domain entity — tidak tahu HTTP, tidak tahu SQL
type Order struct {
    ID        string
    UserID    string
    Items     []OrderItem
    Status    Status
    CreatedAt time.Time
}

type OrderItem struct {
    ProductID string
    Quantity  int
    UnitPrice float64
}

type Status string

const (
    StatusPending   Status = "pending"
    StatusConfirmed Status = "confirmed"
    StatusCancelled Status = "cancelled"
)

// Business rule melekat pada domain, bukan di handler atau service
func (o Order) Total() float64 {
    total := 0.0
    for _, item := range o.Items {
        total += item.UnitPrice * float64(item.Quantity)
    }
    return total
}

func (o Order) IsEmpty() bool {
    return len(o.Items) == 0
}

func (o Order) Validate() error {
    if o.UserID == "" {
        return errors.New("order must belong to a user")
    }
    if o.IsEmpty() {
        return errors.New("order must have at least one item")
    }
    return nil
}

// Concern: representasi data dan aturan inti domain
// Tidak tahu: cara data disimpan, cara request datang, cara response diformat

Langkah 2: Layer Repository — data access concern

// internal/order/repository.go
package order

import (
    "context"
    "database/sql"
    "fmt"
)

// OrderRepository adalah interface — bergantung pada abstraksi (DIP)
type OrderRepository interface {
    Save(ctx context.Context, order Order) (string, error)
    FindByID(ctx context.Context, id string) (*Order, error)
    FindByUserID(ctx context.Context, userID string) ([]Order, error)
}

type postgresOrderRepository struct {
    db *sql.DB
}

func NewPostgresOrderRepository(db *sql.DB) OrderRepository {
    return &postgresOrderRepository{db: db}
}

func (r *postgresOrderRepository) Save(ctx context.Context, order Order) (string, error) {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return "", fmt.Errorf("save order: begin tx: %w", err)
    }
    defer tx.Rollback()

    var orderID string
    err = tx.QueryRowContext(ctx,
        `INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING id`,
        order.UserID, order.Total(), string(order.Status),
    ).Scan(&orderID)
    if err != nil {
        return "", fmt.Errorf("save order: insert: %w", err)
    }

    for _, item := range order.Items {
        _, err = tx.ExecContext(ctx,
            `INSERT INTO order_items (order_id, product_id, quantity, unit_price)
             VALUES ($1, $2, $3, $4)`,
            orderID, item.ProductID, item.Quantity, item.UnitPrice,
        )
        if err != nil {
            return "", fmt.Errorf("save order: insert item: %w", err)
        }
    }

    if err := tx.Commit(); err != nil {
        return "", fmt.Errorf("save order: commit: %w", err)
    }
    return orderID, nil
}

// Concern: bagaimana data disimpan dan diambil dari database
// Tidak tahu: HTTP, business rules, cara validasi input

Langkah 3: Layer Service — business logic concern

// internal/order/service.go
package order

import (
    "context"
    "fmt"
)

type UserChecker interface {
    IsActive(ctx context.Context, userID string) (bool, error)
}

type ProductPricer interface {
    GetPrice(ctx context.Context, productID string) (float64, error)
}

type OrderService struct {
    repo    OrderRepository
    users   UserChecker
    pricing ProductPricer
}

func NewOrderService(
    repo OrderRepository,
    users UserChecker,
    pricing ProductPricer,
) *OrderService {
    return &OrderService{repo: repo, users: users, pricing: pricing}
}

type CreateOrderRequest struct {
    UserID string
    Items  []CreateOrderItem
}

type CreateOrderItem struct {
    ProductID string
    Quantity  int
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (string, error) {
    // Business rule: cek user aktif
    active, err := s.users.IsActive(ctx, req.UserID)
    if err != nil {
        return "", fmt.Errorf("createOrder: check user: %w", err)
    }
    if !active {
        return "", ErrUserNotActive
    }

    // Bangun domain entity dengan harga dari product service
    items := make([]OrderItem, 0, len(req.Items))
    for _, reqItem := range req.Items {
        price, err := s.pricing.GetPrice(ctx, reqItem.ProductID)
        if err != nil {
            return "", fmt.Errorf("createOrder: get price %s: %w", reqItem.ProductID, err)
        }
        items = append(items, OrderItem{
            ProductID: reqItem.ProductID,
            Quantity:  reqItem.Quantity,
            UnitPrice: price,
        })
    }

    order := Order{
        UserID: req.UserID,
        Items:  items,
        Status: StatusPending,
    }

    // Validasi domain entity
    if err := order.Validate(); err != nil {
        return "", fmt.Errorf("createOrder: validate: %w", err)
    }

    // Simpan melalui repository
    orderID, err := s.repo.Save(ctx, order)
    if err != nil {
        return "", fmt.Errorf("createOrder: save: %w", err)
    }

    return orderID, nil
}

// Concern: orchestrasi business logic
// Tidak tahu: HTTP, SQL query, format JSON

Langkah 4: Layer Handler — HTTP transport concern

// internal/api/order_handler.go
package api

import (
    "encoding/json"
    "errors"
    "net/http"

    "github.com/example/app/internal/order"
)

type OrderHandler struct {
    service *order.OrderService
}

func NewOrderHandler(service *order.OrderService) *OrderHandler {
    return &OrderHandler{service: service}
}

type createOrderRequest struct {
    UserID string `json:"user_id"`
    Items  []struct {
        ProductID string `json:"product_id"`
        Quantity  int    `json:"quantity"`
    } `json:"items"`
}

type createOrderResponse struct {
    OrderID string `json:"order_id"`
    Message string `json:"message"`
}

func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
    // HTTP concern: parse request
    var req createOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid request body")
        return
    }

    // Terjemahkan HTTP request ke service request
    items := make([]order.CreateOrderItem, len(req.Items))
    for i, item := range req.Items {
        items[i] = order.CreateOrderItem{
            ProductID: item.ProductID,
            Quantity:  item.Quantity,
        }
    }

    orderID, err := h.service.CreateOrder(r.Context(), order.CreateOrderRequest{
        UserID: req.UserID,
        Items:  items,
    })
    if err != nil {
        // Terjemahkan domain error ke HTTP status
        switch {
        case errors.Is(err, order.ErrUserNotActive):
            writeError(w, http.StatusForbidden, err.Error())
        case errors.Is(err, order.ErrValidation):
            writeError(w, http.StatusUnprocessableEntity, err.Error())
        default:
            writeError(w, http.StatusInternalServerError, "internal server error")
        }
        return
    }

    // HTTP concern: format response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(createOrderResponse{
        OrderID: orderID,
        Message: "order created successfully",
    })
}

// Concern: HTTP transport — parse request, terjemahkan error, format response
// Tidak tahu: SQL, business logic kalkulasi harga, aturan domain
flowchart TD
    HTTP["HTTP Request"]
    H["Handler\n(HTTP concern:\nparse, validate format,\nformat response)"]
    SVC["Service\n(Business logic concern:\norkestrasi, aturan domain,\nvalidasi bisnis)"]
    DOM["Domain\n(Entity concern:\nstruktur data, aturan inti,\nbusiness invariant)"]
    REPO["Repository\n(Data access concern:\nSQL, transaction,\nmapping ke domain)"]
    DB[("Database")]

    HTTP --> H
    H -->|"CreateOrderRequest"| SVC
    SVC -->|"domain entity"| DOM
    SVC -->|"Save(ctx, order)"| REPO
    REPO --> DB
    DB --> REPO
    REPO -->|"orderID"| SVC
    SVC -->|"orderID, error"| H
    H -->|"HTTP Response"| HTTP

    style H fill:#F0AD4E,color:#fff
    style SVC fill:#4C9BE8,color:#fff
    style DOM fill:#9B59B6,color:#fff
    style REPO fill:#5CB85C,color:#fff

Hasilnya: business logic di OrderService bisa ditest tanpa HTTP server dan tanpa database — inject fake UserChecker, ProductPricer, dan OrderRepository. Handler bisa ditest dengan mock service. Repository bisa ditest secara independen dengan integration test yang hanya menyentuh database.


SoC di Level Fungsi — Middleware #

SoC tidak hanya tentang layer — ia juga berlaku di dalam layer itu sendiri, misalnya pada HTTP middleware:

// ANTI-PATTERN: autentikasi, logging, dan rate limit semua dalam handler
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    // === Concern: autentikasi ===
    token := r.Header.Get("Authorization")
    userID, err := validateJWT(token)
    if err != nil {
        http.Error(w, "unauthorized", 401)
        return
    }

    // === Concern: logging ===
    start := time.Now()
    defer func() {
        log.Printf("POST /orders user=%s duration=%v", userID, time.Since(start))
    }()

    // === Concern: rate limiting ===
    if !rateLimiter.Allow(userID) {
        http.Error(w, "too many requests", 429)
        return
    }

    // === Concern: business logic ===
    // ... baru mulai di sini
}

// BENAR: setiap concern ada di middleware-nya sendiri
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        userID, err := validateJWT(token)
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), contextKeyUserID, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: 200}
        next.ServeHTTP(rec, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rec.status,
            "duration", time.Since(start),
        )
    })
}

func RateLimitMiddleware(limiter *rate.Limiter) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                http.Error(w, "too many requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// Handler sekarang hanya punya satu concern: business logic untuk endpoint ini
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
    userID := r.Context().Value(contextKeyUserID).(string)
    // ... langsung ke business logic
}

// Middleware stack didefinisikan di satu tempat
router.Use(LoggingMiddleware)
router.Use(AuthMiddleware)
router.Use(RateLimitMiddleware(limiter))
router.POST("/orders", handler.Create)

SoC di Frontend — Dart/Flutter #

Di Flutter, SoC paling sering dilanggar dengan menempatkan logika bisnis di dalam widget. Widget yang punya HTTP call atau business calculation langsung di dalamnya adalah tanda concern yang bercampur.

// ANTI-PATTERN: semua concern dalam satu widget
class OrderListScreen extends StatefulWidget {
    @override
    State<OrderListScreen> createState() => _OrderListScreenState();
}

class _OrderListScreenState extends State<OrderListScreen> {
    List<Order> _orders = [];
    bool _loading = false;

    @override
    void initState() {
        super.initState();
        _loadOrders();
    }

    Future<void> _loadOrders() async {
        setState(() => _loading = true);
        try {
            // === Data access concern di dalam widget ===
            final response = await http.get(
                Uri.parse('https://api.example.com/orders'),
                headers: {'Authorization': 'Bearer ${authToken}'},
            );
            final data = jsonDecode(response.body) as List;

            // === Business logic di dalam widget ===
            final orders = data
                .map((e) => Order.fromJson(e))
                .where((o) => o.total > 0) // business rule di widget
                .toList();

            setState(() {
                _orders = orders;
                _loading = false;
            });
        } catch (e) {
            // === Error handling di widget ===
            setState(() => _loading = false);
        }
    }

    @override
    Widget build(BuildContext context) {
        // Tidak bisa unit test logika filtering tanpa render widget
        if (_loading) return const CircularProgressIndicator();
        return ListView.builder(
            itemCount: _orders.length,
            itemBuilder: (ctx, i) => OrderTile(order: _orders[i]),
        );
    }
}

// BENAR: setiap concern dipisahkan ke tempatnya

// Data access concern
abstract class OrderRepository {
    Future<List<Order>> getOrders();
}

class HttpOrderRepository implements OrderRepository {
    final http.Client _client;
    final String _baseUrl;
    final String _token;

    HttpOrderRepository({
        required http.Client client,
        required String baseUrl,
        required String token,
    }) : _client = client, _baseUrl = baseUrl, _token = token;

    @override
    Future<List<Order>> getOrders() async {
        final response = await _client.get(
            Uri.parse('$_baseUrl/orders'),
            headers: {'Authorization': 'Bearer $_token'},
        );
        if (response.statusCode != 200) {
            throw ApiException(response.statusCode);
        }
        final data = jsonDecode(response.body) as List;
        return data.map((e) => Order.fromJson(e)).toList();
    }
}

// Business logic concern
class OrderViewModel extends ChangeNotifier {
    final OrderRepository _repository;

    List<Order> orders = [];
    bool isLoading = false;
    String? error;

    OrderViewModel(this._repository);

    Future<void> loadOrders() async {
        isLoading = true;
        error = null;
        notifyListeners();

        try {
            final all = await _repository.getOrders();
            // Business rule ada di ViewModel, bisa ditest tanpa widget
            orders = all.where((o) => o.total > 0).toList();
        } catch (e) {
            error = 'Failed to load orders';
        } finally {
            isLoading = false;
            notifyListeners();
        }
    }
}

// UI concern
class OrderListScreen extends StatelessWidget {
    const OrderListScreen({super.key});

    @override
    Widget build(BuildContext context) {
        return ChangeNotifierProvider(
            create: (_) => OrderViewModel(context.read<OrderRepository>())..loadOrders(),
            child: Consumer<OrderViewModel>(
                builder: (context, vm, _) {
                    if (vm.isLoading) return const Center(child: CircularProgressIndicator());
                    if (vm.error != null) return Center(child: Text(vm.error!));
                    return ListView.builder(
                        itemCount: vm.orders.length,
                        itemBuilder: (ctx, i) => OrderTile(order: vm.orders[i]),
                    );
                },
            ),
        );
    }
}
// Business rule (filter total > 0) sekarang ada di ViewModel
// Bisa ditest dengan FakeOrderRepository tanpa render widget

SoC di Level Arsitektur Sistem #

Di level yang lebih besar, SoC diterapkan sebagai pemisahan antar service atau antar subsystem. Setiap service bertanggung jawab atas satu bounded context — dan tidak mencampuri urusan domain lain.

flowchart TD
    GW["API Gateway\n(routing, rate limit, auth token validation)"]
    AUTH["Auth Service\n(identity, token issuance, session)"]
    USER["User Service\n(profil, preferensi, status)"]
    ORDER["Order Service\n(pembuatan order, status order)"]
    INVENTORY["Inventory Service\n(stok, reservasi)"]
    NOTIF["Notification Service\n(email, push, SMS)"]
    PAYMENT["Payment Service\n(charge, refund, webhook)"]

    GW --> AUTH
    GW --> USER
    GW --> ORDER
    ORDER -->|"HTTP: reserve stock"| INVENTORY
    ORDER -->|"HTTP: initiate payment"| PAYMENT
    ORDER -->|"event: order_created"| NOTIF
    USER -->|"event: user_registered"| NOTIF

    style GW fill:#F0AD4E,color:#fff
    style AUTH fill:#4C9BE8,color:#fff
    style NOTIF fill:#9B59B6,color:#fff

Setiap service hanya tahu tentang concern-nya sendiri. Notification Service tidak tahu cara membuat order — ia hanya tahu cara mengirim notifikasi berdasarkan event yang diterimanya. Order Service tidak tahu cara mengirim email — ia hanya publish event order_created dan membiarkan subscriber yang tepat menanganinya.


Kesalahan Umum Saat Menerapkan SoC #

1. MENGANGGAP SOC = BANYAK FOLDER
   Membuat folder handler/, service/, repository/ tidak otomatis berarti SoC
   terpenuhi jika business logic masih bocor ke handler atau SQL query
   tersebar di service.
   → SoC adalah tentang batas tanggung jawab, bukan struktur direktori

2. BUSINESS LOGIC BOCOR KE HANDLER
   Handler yang melakukan lebih dari parsing request dan formatting response
   adalah tanda business concern bocor ke HTTP concern.
   → Pindahkan semua "if user is gold, apply 15% discount" ke service

3. REPOSITORY BERISI BUSINESS LOGIC
   Repository yang melakukan kalkulasi harga atau membuat keputusan bisnis
   adalah tanda data access concern tercampur dengan business concern.
   → Repository hanya tahu cara menyimpan dan mengambil data

4. OVER-LAYERING TANPA NILAI TAMBAH
   UseCase yang hanya meneruskan panggilan ke Repository tanpa logic apapun
   adalah layer yang tidak menambah SoC — hanya menambah indirection.
   → Tambahkan layer hanya ketika ada concern yang benar-benar berbeda

5. DOMAIN ENTITY YANG TAHU CARA DISIMPAN
   Domain entity yang punya method Save() atau yang mengimport database driver
   adalah tanda domain concern dan data access concern bercampur.
   → Domain entity tidak boleh tahu infrastruktur apapun
SoC proporsional dengan kompleksitas. Untuk script kecil atau prototype, layered architecture yang lengkap adalah over-engineering. Untuk aplikasi yang akan hidup lama dan dikerjakan banyak orang, SoC adalah investasi yang terbayar setiap kali ada perubahan. Mulai dari yang sederhana, tambahkan layer ketika kompleksitas bisnis memang membutuhkannya.

Kapan SoC Sangat Penting #

SOC MEMBERIKAN NILAI TERBESAR KETIKA:
  ✓ Tim besar — engineer bisa bekerja paralel di concern yang berbeda
    tanpa saling menghalangi (frontend vs backend, service A vs service B)
  ✓ Perubahan bisnis cepat — business logic yang terpisah bisa diubah
    tanpa khawatir merusak HTTP handling atau database access
  ✓ Testing adalah prioritas — unit test business logic tanpa infrastruktur
    hanya mungkin ketika concern sudah terpisah dengan benar
  ✓ Multiple delivery channel — logika yang sama perlu bisa dipanggil
    dari HTTP handler, gRPC server, dan background worker
  ✓ Sistem jangka panjang — biaya pemisahan concern dibayar berulang kali
    setiap kali ada perubahan di masa depan

PERTIMBANGKAN ULANG KETIKA:
  ✗ Script sekali pakai atau tool internal kecil
  ✗ Prototype yang tidak akan jadi production code
  ✗ Tim sangat kecil (1–2 orang) dan scope yang terbatas
  → Mulai sederhana, refactor ke SoC ketika tim dan kompleksitas bertambah

Anti-Pattern dalam Satu Pandangan #

// ✗ Handler yang tahu SQL
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    // ... parse request ...
    db.Exec("INSERT INTO orders ...") // HTTP concern tahu SQL
}

// ✗ Service yang tahu JSON
func (s *OrderService) CreateOrder(req *http.Request) {
    var body struct { ... }
    json.NewDecoder(req.Body).Decode(&body) // business concern tahu HTTP
}

// ✗ Repository yang punya business logic
func (r *OrderRepository) SaveWithDiscount(order Order) error {
    if order.UserTier == "gold" {
        order.Total *= 0.85 // data access concern tahu business rule
    }
    r.db.Exec("INSERT INTO orders ...", order.Total)
    return nil
}

// ✗ Domain entity yang tahu cara disimpan
type Order struct { ... }
func (o *Order) Save(db *sql.DB) error { // domain entity tahu SQL
    db.Exec("INSERT INTO orders ...")
    return nil
}

// ✗ Widget Flutter yang langsung HTTP call
class OrderWidget extends StatefulWidget {
    void _load() async {
        final resp = await http.get(Uri.parse("https://api.example.com/orders"))
        // UI concern tahu network detail
    }
}

Checklist Review SoC #

LAYER HANDLER (HTTP):
  □ Handler tidak punya SQL query atau langsung akses database
  □ Handler tidak melakukan kalkulasi bisnis (harga, diskon, pajak)
  □ Handler hanya parse request, panggil service, format response
  □ Autentikasi dan logging ada di middleware, bukan di handler

LAYER SERVICE (BUSINESS LOGIC):
  □ Service tidak tahu cara parse HTTP request atau format JSON response
  □ Service tidak punya SQL query langsung
  □ Business rule (diskon, validasi bisnis, status transition) ada di service atau domain
  □ Service berbicara dalam bahasa domain, bukan bahasa HTTP atau SQL

LAYER REPOSITORY (DATA ACCESS):
  □ Repository tidak melakukan kalkulasi bisnis
  □ Repository tidak membuat keputusan tentang flow bisnis
  □ Repository hanya tahu: bagaimana menyimpan, mengambil, dan memperbarui data

DOMAIN ENTITY:
  □ Domain entity tidak import database driver atau HTTP package
  □ Business invariant (Validate(), Total(), IsEligible()) ada sebagai method
  □ Domain entity bisa diinstansiasi dan ditest tanpa infrastruktur apapun

FRONTEND (FLUTTER):
  □ Widget tidak punya HTTP call langsung
  □ Business logic ada di ViewModel/Controller, bisa ditest tanpa render widget
  □ Repository layer bertanggung jawab atas network dan parsing

Ringkasan #

  • SoC bukan tentang jumlah folder — tapi tentang batas tanggung jawab yang jelas. Struktur direktori yang rapi tidak menjamin SoC terpenuhi jika business logic masih ada di handler atau SQL query tersebar di service.
  • SoC vs SRP: SoC di level arsitektur (layer, service, subsystem); SRP di level implementasi (struct, class, function). Keduanya saling melengkapi — SoC mendefinisikan boundary, SRP menjaga kohesi di dalam boundary tersebut.
  • Tujuh concern utama yang perlu dipisahkan: HTTP transport, validasi input, business logic, data access, notifikasi, autentikasi/otorisasi, dan observability. Setiap concern punya tempat yang tepat — dan tidak boleh bocor ke tempat yang lain.
  • Refactor bertahap: Domain entity (aturan inti) → Repository (SQL) → Service (orchestrasi bisnis) → Handler (HTTP). Setiap layer hanya tahu tentang layer di bawahnya melalui interface — tidak pernah skip layer.
  • Middleware adalah SoC di level fungsi: autentikasi, logging, rate limiting masing-masing jadi middleware independen — handler hanya punya satu concern: business logic untuk endpoint itu.
  • Di Flutter: widget hanya urusan UI, ViewModel/Controller urusan business logic, Repository urusan data access. Business rule di ViewModel bisa ditest tanpa render widget sama sekali.
  • Di level sistem: setiap service adalah SSOT untuk domain-nya dan tidak mencampuri domain lain. Komunikasi antar concern melalui API atau event, bukan langsung akses database service lain.
  • Kesalahan umum: business logic bocor ke handler, SQL di service, kalkulasi bisnis di repository, domain entity yang tahu infrastruktur, dan widget Flutter yang punya HTTP call.
  • SoC proporsional dengan kompleksitas: untuk prototype dan script kecil, mulai sederhana. Tambahkan layer ketika tim dan kompleksitas bisnis memang membutuhkannya — bukan sebelumnya.

← Sebelumnya: SSOT   Berikutnya: SPOF →

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