Separation of Concerns #
Ada sebuah pola masalah yang sangat konsisten di codebase yang sulit dirawat: satu fungsi HTTP handler yang sekaligus memparsing request, memvalidasi input, menjalankan business rule, mengeksekusi query SQL, mengirim email, dan memformat response. Tidak ada yang secara teknis salah — semua berjalan. Tapi ketika business rule berubah, kamu harus memahami request parsing sebelum bisa menemukan tempat yang tepat. Ketika ingin unit test validasinya, kamu butuh database dan HTTP server. Ketika ingin reuse logika yang sama dari CLI tool, tidak bisa tanpa copy-paste. Separation of Concerns (SoC) adalah prinsip yang lahir untuk menyelesaikan masalah ini: pisahkan sistem ke dalam bagian-bagian yang masing-masing menangani satu concern — satu aspek atau tanggung jawab — dan biarkan setiap bagian fokus pada urusannya sendiri tanpa mencampuri urusan yang lain. Panduan ini membahas SoC dari definisi concern yang konkret, contoh arsitektur layered di Go dan Dart yang lengkap, dampaknya pada testability, penerapannya di level arsitektur sistem, hingga batas yang perlu dijaga agar SoC tidak berubah menjadi over-engineering.
Apa yang Dimaksud “Concern”? #
Concern adalah aspek atau tanggung jawab tertentu dalam sebuah sistem. Dalam aplikasi backend yang khas, ada beberapa concern utama yang punya sifat berbeda dan alasan perubahan yang berbeda:
Concern Pertanyaan yang dijawab
──────────────────── ──────────────────────────────────────────
HTTP handling Bagaimana request diterima dan response dikirim?
Validasi input Apakah data dari user valid dan aman diproses?
Business logic Apa yang harus terjadi sesuai aturan bisnis?
Data access Bagaimana data disimpan dan diambil dari storage?
Authentication Siapa yang membuat request ini?
Logging Apa yang terjadi dan kapan?
Configuration Bagaimana sistem dikonfigurasi di berbagai environment?
Masalah muncul ketika concern-concern ini dicampur dalam satu unit kode. Mixing concern berarti:
- Mengubah format response HTTP memaksa kamu memahami business rule sebelum bisa berbuat apapun
- Unit test business logic membutuhkan database nyata dan HTTP server
- Reuse logika bisnis dari konteks lain (CLI tool, background worker) tidak bisa tanpa membawa semua infrastruktur HTTP bersamanya
- Multiple engineer tidak bisa mengerjakan concern berbeda secara paralel tanpa saling konflik
Contoh Tanpa SoC — Monolithic Handler #
Berikut handler yang sering ditemukan di codebase yang tidak menerapkan SoC — semua concern dicampur dalam satu fungsi:
// ANTI-PATTERN: semua concern dalam satu handler
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
// Concern 1: HTTP parsing
var req struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// Concern 2: Validasi input
if req.Email == "" {
http.Error(w, "email required", http.StatusBadRequest)
return
}
if !strings.Contains(req.Email, "@") {
http.Error(w, "invalid email", http.StatusBadRequest)
return
}
if len(req.Password) < 8 {
http.Error(w, "password too short", http.StatusBadRequest)
return
}
// Concern 3: Business logic (hashing, ID generation)
hashedPwd, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
userID := uuid.New().String()
// Concern 4: Database access
_, err := db.Exec(
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
userID, req.Email, req.Name, string(hashedPwd),
)
if err != nil {
http.Error(w, "failed to save user", http.StatusInternalServerError)
return
}
// Concern 5: Side effects (email notification)
smtpClient.Send(req.Email, "Welcome!", "Thanks for joining, "+req.Name)
// Concern 6: Response formatting
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"id": userID,
"status": "created",
})
}
Untuk unit test business logic “validasi password minimal 8 karakter” dari handler ini, kamu butuh: database connection, SMTP client, HTTP request/response writer. Untuk reuse logika user creation dari background worker, kamu harus copy-paste semua kode.
Penerapan SoC dengan Layered Architecture #
Pisahkan concern ke dalam layer yang terpisah dan terkoordinasi.
Struktur folder yang mencerminkan SoC:
/domain → entities dan domain rules (tidak bergantung pada apapun)
/repository → database access (bergantung pada domain)
/service → business logic (bergantung pada domain dan repository)
/handler → HTTP request/response (bergantung pada service)
/middleware → cross-cutting concerns (auth, logging, tracing)
Layer 1 — Domain (Entity dan Business Rules) #
Domain layer adalah inti — tidak bergantung pada HTTP, database, atau library eksternal apapun. Ia mendefinisikan apa artinya sebuah data dalam konteks bisnis.
// domain/user.go
package domain
import "errors"
type User struct {
ID string
Email string
Name string
PasswordHash string
}
// Business rule ada di domain, bukan di handler atau validator terpisah
func NewUser(id, email, name, passwordHash string) (*User, error) {
if email == "" {
return nil, errors.New("email is required")
}
if !isValidEmail(email) {
return nil, errors.New("invalid email format")
}
if name == "" {
return nil, errors.New("name is required")
}
return &User{
ID: id,
Email: email,
Name: name,
PasswordHash: passwordHash,
}, nil
}
func isValidEmail(email string) bool {
return strings.Contains(email, "@") && strings.Contains(email, ".")
}
Layer 2 — Repository (Data Access Concern) #
Repository layer tahu cara menyimpan dan mengambil data. Ia tidak tahu business rule, tidak tahu HTTP, dan tidak tahu cara membuat entity — ia hanya tahu cara bicara dengan storage.
// repository/user_repository.go
package repository
import (
"context"
"database/sql"
"domain"
)
type UserRepository interface {
Save(ctx context.Context, user *domain.User) error
FindByEmail(ctx context.Context, email string) (*domain.User, error)
ExistsByEmail(ctx context.Context, email string) (bool, error)
}
type postgresUserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) UserRepository {
return &postgresUserRepository{db: db}
}
func (r *postgresUserRepository) Save(ctx context.Context, user *domain.User) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO users (id, email, name, password_hash) VALUES ($1, $2, $3, $4)",
user.ID, user.Email, user.Name, user.PasswordHash,
)
return err
}
func (r *postgresUserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM users WHERE email = $1", email,
).Scan(&count)
return count > 0, err
}
Layer 3 — Service (Business Logic Concern) #
Service layer mengorkestrasi aturan bisnis. Ia tahu tentang domain dan menggunakan repository, tapi tidak tahu apapun tentang HTTP, JSON, atau format response.
// service/user_service.go
package service
import (
"context"
"domain"
"repository"
"errors"
)
type UserService interface {
Register(ctx context.Context, req RegisterRequest) (*domain.User, error)
}
type RegisterRequest struct {
Email string
Name string
Password string
}
type userService struct {
repo repository.UserRepository
hasher PasswordHasher
idGen IDGenerator
}
func NewUserService(
repo repository.UserRepository,
hasher PasswordHasher,
idGen IDGenerator,
) UserService {
return &userService{repo: repo, hasher: hasher, idGen: idGen}
}
func (s *userService) Register(ctx context.Context, req RegisterRequest) (*domain.User, error) {
// Business rule: email harus unik
exists, err := s.repo.ExistsByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("check email: %w", err)
}
if exists {
return nil, errors.New("email already registered")
}
// Business logic: hash password sebelum simpan
hashedPwd, err := s.hasher.Hash(req.Password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
// Domain validation: pakai constructor yang enforce aturan bisnis
user, err := domain.NewUser(s.idGen.New(), req.Email, req.Name, hashedPwd)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
if err := s.repo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("save user: %w", err)
}
return user, nil
}
Layer 4 — Handler (HTTP Concern) #
Handler layer hanya tahu tentang HTTP: parse request, panggil service, format response. Tidak ada business rule di sini.
// handler/user_handler.go
package handler
import (
"encoding/json"
"net/http"
"service"
)
type UserHandler struct {
svc service.UserService
}
func NewUserHandler(svc service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
// Concern handler: parse HTTP request
var body struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
// Delegasi ke service — handler tidak tahu business rule apapun
user, err := h.svc.Register(r.Context(), service.RegisterRequest{
Email: body.Email,
Name: body.Name,
Password: body.Password,
})
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
// Concern handler: format HTTP response
respondJSON(w, http.StatusCreated, map[string]string{
"id": user.ID,
"email": user.Email,
})
}
Dampak SoC pada Testability #
Pemisahan concern yang jelas mengubah unit test menjadi mudah dan cepat.
// Test service layer — tidak butuh database atau HTTP server
func TestUserService_Register_EmailAlreadyExists(t *testing.T) {
fakeRepo := &FakeUserRepository{
existingEmails: map[string]bool{
"[email protected]": true,
},
}
svc := NewUserService(fakeRepo, &RealPasswordHasher{}, &UUIDGenerator{})
_, err := svc.Register(context.Background(), RegisterRequest{
Email: "[email protected]",
Name: "Test User",
Password: "password123",
})
// Test fokus: business rule "email harus unik"
assert.ErrorContains(t, err, "email already registered")
}
// Test domain layer — hanya pure Go, tidak ada dependency
func TestNewUser_InvalidEmail(t *testing.T) {
_, err := domain.NewUser("id-1", "notanemail", "Budi", "hashedpwd")
assert.ErrorContains(t, err, "invalid email format")
}
// Test handler layer — hanya test HTTP parsing dan response format
func TestUserHandler_Register_MissingBody(t *testing.T) {
fakeSvc := &FakeUserService{}
handler := NewUserHandler(fakeSvc)
req := httptest.NewRequest("POST", "/users", strings.NewReader("invalid json"))
w := httptest.NewRecorder()
handler.Register(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
Setiap layer ditest secara independen, fokus pada concern-nya sendiri, tanpa memerlukan infrastruktur yang tidak berkaitan.
SoC dalam Dart/Flutter #
Di Flutter, SoC sering dilanggar dengan mencampur UI, state, dan business logic dalam satu Widget. Pola yang benar memisahkan concern ke lapisan yang jelas.
// ANTI-PATTERN: semua concern dalam satu StatefulWidget
class OrderScreen extends StatefulWidget {
@override
_OrderScreenState createState() => _OrderScreenState();
}
class _OrderScreenState extends State<OrderScreen> {
List<Order> orders = [];
bool isLoading = false;
@override
void initState() {
super.initState();
// HTTP call langsung dari Widget
http.get(Uri.parse('https://api.example.com/orders')).then((resp) {
// JSON parsing di Widget
final data = json.decode(resp.body) as List;
setState(() {
// Business rule filtering di Widget
orders = data
.map((o) => Order.fromJson(o))
.where((o) => o.status != 'cancelled') // business rule di UI!
.toList();
isLoading = false;
});
});
}
// ...
}
// BENAR: concern dipisah ke layer masing-masing
// Data access concern
class OrderRepository {
final http.Client _client;
OrderRepository(this._client);
Future<List<Order>> findAll() async {
final response = await _client.get(Uri.parse('https://api.example.com/orders'));
final data = json.decode(response.body) as List;
return data.map((o) => Order.fromJson(o)).toList();
}
}
// Business logic concern
class OrderService {
final OrderRepository _repository;
OrderService(this._repository);
Future<List<Order>> getActiveOrders() async {
final orders = await _repository.findAll();
return orders.where((o) => o.status != 'cancelled').toList();
}
}
// State management concern (koordinasi antara service dan UI)
class OrderController extends ChangeNotifier {
final OrderService _service;
OrderController(this._service);
List<Order> orders = [];
bool isLoading = false;
String? error;
Future<void> loadOrders() async {
isLoading = true;
notifyListeners();
try {
orders = await _service.getActiveOrders();
error = null;
} catch (e) {
error = e.toString();
} finally {
isLoading = false;
notifyListeners();
}
}
}
// UI concern — hanya tampilkan state, tidak ada business logic
class OrderScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ctrl = context.watch<OrderController>();
if (ctrl.isLoading) return const CircularProgressIndicator();
if (ctrl.error != null) return Text(ctrl.error!);
return ListView(
children: ctrl.orders.map((o) => OrderTile(order: o)).toList(),
);
}
}
SoC di Level Arsitektur #
SoC tidak hanya berlaku pada code level — ia juga berlaku pada level arsitektur sistem.
Layered Architecture (SoC dalam satu service):
Presentation Layer (HTTP/gRPC handler)
↓
Application Layer (use cases, orchestration)
↓
Domain Layer (business rules, entities)
↓
Infrastructure Layer (database, cache, external APIs)
Setiap layer hanya boleh bergantung ke layer di bawahnya.
Domain layer tidak boleh tahu tentang database atau HTTP.
Microservices (SoC di level service):
User Service → concern: identitas dan autentikasi
Order Service → concern: pemesanan dan inventory
Payment Service → concern: transaksi finansial
Notification Service → concern: pengiriman notifikasi
Setiap service hanya handle concern domain-nya.
Komunikasi via API atau event, bukan shared database.
SoC vs SRP — Perbedaan Level #
Kedua prinsip ini sering dikacaukan karena tujuannya mirip.
SRP (Single Responsibility Principle):
Level: class/module
Pertanyaan: "Apakah class ini punya lebih dari satu reason to change?"
Contoh: pisahkan UserValidator dari UserRepository
SoC (Separation of Concerns):
Level: sistem dan arsitektur
Pertanyaan: "Apakah concern-concern berbeda sudah terpisah secara struktural?"
Contoh: pisahkan HTTP layer dari business logic layer dari data access layer
SoC adalah "big picture" — ia mendefinisikan bagaimana sistem secara keseluruhan diorganisir.
SRP adalah "detail" — ia mendefinisikan bagaimana setiap komponen dalam sistem diorganisir.
Keduanya saling mendukung: SoC mendefinisikan layer, SRP memastikan setiap class dalam layer
punya satu tanggung jawab.
Anti-Pattern yang Harus Dihindari #
// ✗ Business logic bocor ke handler
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
// ... parsing ...
if order.Total > 10_000_000 { // business rule ada di handler!
applyVIPDiscount(&order)
}
db.Save(order)
}
// ✓ Business rule di service layer
// ✗ Database query di service yang harusnya di repository
func (s *UserService) CreateUser(email string) error {
s.db.Exec("INSERT INTO users...") // database access langsung di service
}
// ✓ Delegate ke repository
// ✗ HTTP-specific code masuk ke service
func (s *UserService) GetUserForAPI(r *http.Request) (*User, error) {
userID := r.Header.Get("X-User-ID") // service tahu tentang HTTP header!
return s.repo.FindByID(userID)
}
// ✓ Handler extract userID dari request, lalu pass ke service
// ✗ Response formatting di service
func (s *UserService) CreateUser(email string) map[string]interface{} {
// ...
return map[string]interface{}{"data": user, "status": "ok"} // JSON structure di service!
}
// ✓ Service return domain object, handler format response
Ringkasan #
- SoC berarti memisahkan sistem ke dalam bagian yang masing-masing menangani satu concern — HTTP handling, business logic, data access, dan domain rules harus terpisah.
- Empat layer utama: Domain (entities + rules), Repository (data access), Service (business logic + orchestration), Handler (HTTP request/response).
- Domain layer adalah inti — tidak boleh bergantung pada database, HTTP, atau library eksternal; business rules ada di sini.
- Handler tidak boleh berisi business logic — hanya parse request, panggil service, format response.
- Service tidak boleh tahu tentang HTTP — tidak ada
http.Requestatauhttp.ResponseWriterdi service layer.- Repository tidak boleh berisi business rules — hanya tahu cara menyimpan dan mengambil data.
- Dampak pada testability: setiap layer bisa ditest independen — service ditest dengan fake repository tanpa database, domain ditest tanpa dependency apapun.
- SoC di Flutter: Repository (data), Service (business logic), Controller (state management), Widget (UI) — masing-masing layer fokus pada satu concern.
- SoC lebih luas dari SRP: SRP tentang class-level, SoC tentang arsitektur sistem — keduanya saling melengkapi.