Onion Architecture #

Ada momen yang akrab bagi banyak tim engineering: seorang developer mengubah schema database, dan tiba-tiba unit test gagal — bukan karena logic bisnis berubah, tapi karena entity domain langsung memakai struct ORM yang kolomnya ikut bergeser. Atau saat framework HTTP diganti, dan ternyata controller menyimpan validasi bisnis yang tidak bisa dipisahkan. Masalah ini punya akar yang sama: infrastruktur dan bisnis terlalu dekat, terlalu terikat. Onion Architecture, yang diperkenalkan oleh Jeffrey Palermo pada 2008, adalah respons langsung terhadap masalah ini. Dengan visualisasi lapisan seperti bawang — domain di pusat, infrastruktur di kulit terluar — ia menegaskan satu prinsip yang tidak boleh dilanggar: dependency selalu mengalir ke dalam, menuju domain, tidak pernah sebaliknya. Database bisa diganti. Framework bisa diswap. Domain tetap utuh.

Struktur Ring: Visualisasi yang Mendefinisikan Segalanya #

Onion Architecture menggunakan metafora bawang — lapisan konsentris yang membungkus inti. Setiap lapisan hanya boleh bergantung ke lapisan yang lebih dalam, tidak pernah ke luar.

flowchart TD
    subgraph ONION["Onion Architecture — Dependency selalu ke dalam"]
        INFRA["Infrastructure\nDB · HTTP · Framework · External API · ORM"]
        APP["Application Layer\nUse Case · Application Service · DTO"]
        DOM_SVC["Domain Service\nLogika yang melibatkan banyak entity"]
        REPO_IF["Repository Interface\nDidefinisikan di domain"]
        ENTITY["Entity & Value Object\nBusiness Rules"]
    end

    INFRA -->|"depends on"| APP
    APP -->|"depends on"| DOM_SVC
    DOM_SVC -->|"depends on"| REPO_IF
    REPO_IF -->|"depends on"| ENTITY

    style ENTITY fill:#2d6a4f,color:#fff
    style REPO_IF fill:#40916c,color:#fff
    style DOM_SVC fill:#52b788,color:#fff
    style APP fill:#74c69d,color:#1b4332
    style INFRA fill:#b7e4c7,color:#1b4332

Aturan fundamentalnya sederhana tapi tidak boleh dikompromikan:

Layer Boleh bergantung ke Tidak boleh bergantung ke
Infrastructure Semua layer
Application Domain Infrastructure
Domain Service Entity, Repository Interface Application, Infrastructure
Repository Interface Entity Semua yang lain
Entity Tidak ada Semua

Domain adalah lapisan paling dalam dan paling stabil — ia tidak tahu framework apa yang dipakai, database apa yang digunakan, atau bagaimana data dikirim ke client.


Domain Layer: Inti yang Tidak Bergantung pada Siapapun #

Domain layer berisi tiga jenis objek: Entity, Value Object, Repository Interface, dan Domain Service. Tidak ada satu pun import dari library database, HTTP framework, atau tool eksternal di sini.

// domain/product.go — Entity
package domain

import (
	"errors"
	"time"
)

type ProductID string
type CategoryID string

// Product adalah entity di domain layer — tidak ada ORM tag, tidak ada JSON tag
type Product struct {
	id          ProductID
	name        string
	description string
	price       Money
	stock       int
	categoryID  CategoryID
	createdAt   time.Time
	updatedAt   time.Time
}

// NewProduct menegakkan invariant bisnis saat pembuatan
func NewProduct(name, description string, price Money, categoryID CategoryID) (*Product, error) {
	if name == "" {
		return nil, errors.New("nama produk tidak boleh kosong")
	}
	if len(name) > 200 {
		return nil, errors.New("nama produk tidak boleh lebih dari 200 karakter")
	}
	if price.IsZeroOrNegative() {
		return nil, errors.New("harga produk harus lebih dari nol")
	}
	return &Product{
		id:          ProductID(generateID()),
		name:        name,
		description: description,
		price:       price,
		stock:       0,
		categoryID:  categoryID,
		createdAt:   time.Now(),
		updatedAt:   time.Now(),
	}, nil
}

// AddStock menambah stok — logika bisnis ada di dalam entity
func (p *Product) AddStock(qty int) error {
	if qty <= 0 {
		return errors.New("jumlah stok yang ditambahkan harus lebih dari 0")
	}
	p.stock += qty
	p.updatedAt = time.Now()
	return nil
}

// Deduct mengurangi stok saat terjadi pembelian
func (p *Product) Deduct(qty int) error {
	if qty <= 0 {
		return errors.New("jumlah yang dikurangi harus lebih dari 0")
	}
	if p.stock < qty {
		return errors.New("stok tidak mencukupi")
	}
	p.stock -= qty
	p.updatedAt = time.Now()
	return nil
}

// UpdatePrice memperbarui harga dengan validasi bisnis
func (p *Product) UpdatePrice(newPrice Money) error {
	if newPrice.IsZeroOrNegative() {
		return errors.New("harga baru harus lebih dari nol")
	}
	p.price = newPrice
	p.updatedAt = time.Now()
	return nil
}

// Getter — entity mengontrol akses ke state internalnya
func (p *Product) ID() ProductID        { return p.id }
func (p *Product) Name() string         { return p.name }
func (p *Product) Description() string  { return p.description }
func (p *Product) Price() Money         { return p.price }
func (p *Product) Stock() int           { return p.stock }
func (p *Product) CategoryID() CategoryID { return p.categoryID }
func (p *Product) CreatedAt() time.Time { return p.createdAt }
func (p *Product) UpdatedAt() time.Time { return p.updatedAt }
func (p *Product) IsAvailable() bool    { return p.stock > 0 }
// domain/money.go — Value Object
package domain

import (
	"errors"
	"fmt"
)

// Money adalah Value Object — immutable, equality by value
type Money struct {
	cents    int64
	currency string
}

func NewMoney(cents int64, currency string) (Money, error) {
	if currency == "" {
		return Money{}, errors.New("currency tidak boleh kosong")
	}
	if cents < 0 {
		return Money{}, errors.New("nilai uang tidak boleh negatif")
	}
	return Money{cents: cents, currency: currency}, nil
}

func (m Money) Cents() int64    { return m.cents }
func (m Money) Currency() string { return m.currency }
func (m Money) IsZeroOrNegative() bool { return m.cents <= 0 }

func (m Money) Add(other Money) (Money, error) {
	if m.currency != other.currency {
		return Money{}, fmt.Errorf("tidak bisa menjumlahkan %s dengan %s", m.currency, other.currency)
	}
	return Money{cents: m.cents + other.cents, currency: m.currency}, nil
}

func (m Money) Multiply(factor int) Money {
	return Money{cents: m.cents * int64(factor), currency: m.currency}
}

func (m Money) Equals(other Money) bool {
	return m.cents == other.cents && m.currency == other.currency
}
// domain/repository.go — Repository Interface didefinisikan di domain
// ✓ Domain mendefinisikan kebutuhannya — infrastructure memenuhinya
package domain

import "context"

// ProductRepository adalah interface yang didefinisikan oleh domain
// Implementasinya ada di infrastructure layer
type ProductRepository interface {
	Save(ctx context.Context, product *Product) error
	FindByID(ctx context.Context, id ProductID) (*Product, error)
	FindByCategory(ctx context.Context, categoryID CategoryID) ([]*Product, error)
	FindAvailable(ctx context.Context) ([]*Product, error)
	Delete(ctx context.Context, id ProductID) error
}
// domain/inventory_service.go — Domain Service
// Logika yang melibatkan banyak entity dan tidak cocok di satu entity
package domain

import (
	"context"
	"errors"
)

// InventoryService adalah Domain Service untuk operasi stok yang kompleks
type InventoryService struct {
	productRepo ProductRepository
}

func NewInventoryService(productRepo ProductRepository) *InventoryService {
	return &InventoryService{productRepo: productRepo}
}

// TransferStock memindahkan stok dari satu produk ke produk lain
// Logika ini melibatkan dua entity — tidak cocok di salah satu entity
func (s *InventoryService) TransferStock(
	ctx context.Context,
	fromID, toID ProductID,
	quantity int,
) error {
	from, err := s.productRepo.FindByID(ctx, fromID)
	if err != nil {
		return err
	}
	to, err := s.productRepo.FindByID(ctx, toID)
	if err != nil {
		return err
	}

	// Kurangi dari source
	if err := from.Deduct(quantity); err != nil {
		return errors.New("sumber tidak memiliki stok cukup: " + err.Error())
	}

	// Tambahkan ke destination
	if err := to.AddStock(quantity); err != nil {
		return err
	}

	// Simpan keduanya
	if err := s.productRepo.Save(ctx, from); err != nil {
		return err
	}
	return s.productRepo.Save(ctx, to)
}

Application Layer: Orkestrasi Use Case #

Application layer mengorkestrasikan alur bisnis menggunakan entity dan service dari domain layer. Ia tidak mengandung business logic — ia hanya menentukan urutan langkah yang harus terjadi.

// application/create_product.go
package application

import (
	"context"

	"myapp/domain"
)

// CreateProductInput adalah input DTO dari luar ke application layer
type CreateProductInput struct {
	Name        string
	Description string
	PriceCents  int64
	Currency    string
	CategoryID  string
}

// CreateProductOutput adalah output DTO dari application layer ke luar
type CreateProductOutput struct {
	ProductID   string
	Name        string
	PriceCents  int64
	Stock       int
}

// CreateProductUseCase mengorkestrasikan pembuatan produk baru
type CreateProductUseCase struct {
	productRepo domain.ProductRepository
}

func NewCreateProductUseCase(productRepo domain.ProductRepository) *CreateProductUseCase {
	return &CreateProductUseCase{productRepo: productRepo}
}

func (uc *CreateProductUseCase) Execute(ctx context.Context, input CreateProductInput) (*CreateProductOutput, error) {
	// Buat value object
	price, err := domain.NewMoney(input.PriceCents, input.Currency)
	if err != nil {
		return nil, err
	}

	// Buat domain entity — validasi ada di dalam entity
	product, err := domain.NewProduct(
		input.Name,
		input.Description,
		price,
		domain.CategoryID(input.CategoryID),
	)
	if err != nil {
		return nil, err
	}

	// Simpan melalui repository interface
	if err := uc.productRepo.Save(ctx, product); err != nil {
		return nil, err
	}

	return &CreateProductOutput{
		ProductID:  string(product.ID()),
		Name:       product.Name(),
		PriceCents: product.Price().Cents(),
		Stock:      product.Stock(),
	}, nil
}

// AddStockInput adalah input untuk menambah stok
type AddStockInput struct {
	ProductID string
	Quantity  int
}

// AddStockUseCase mengorkestrasikan penambahan stok produk
type AddStockUseCase struct {
	productRepo domain.ProductRepository
}

func NewAddStockUseCase(productRepo domain.ProductRepository) *AddStockUseCase {
	return &AddStockUseCase{productRepo: productRepo}
}

func (uc *AddStockUseCase) Execute(ctx context.Context, input AddStockInput) error {
	product, err := uc.productRepo.FindByID(ctx, domain.ProductID(input.ProductID))
	if err != nil {
		return err
	}

	// Delegasikan ke domain entity — use case tidak mengandung logika bisnis
	if err := product.AddStock(input.Quantity); err != nil {
		return err
	}

	return uc.productRepo.Save(ctx, product)
}

Memisahkan Domain Model dari Persistence Model #

Salah satu praktik paling penting dalam Onion Architecture adalah memisahkan domain entity dari model persistence. Jangan pernah mencampur ORM annotation dengan domain entity.

// ✗ ANTI-PATTERN: domain entity dicemari ORM annotation
type Product struct {
	ID          int64  `gorm:"primaryKey;autoIncrement"` // ✗ detail ORM di domain
	Name        string `gorm:"column:product_name;not null"`
	PriceCents  int64  `gorm:"column:price_cents"`
	Stock       int    `gorm:"default:0"`
}

// ✗ ANTI-PATTERN: JSON tag di domain entity
type Product struct {
	ID    string `json:"id"` // ✗ transport concern di domain
	Name  string `json:"name"`
}

// ✓ BENAR: domain entity murni — tidak ada tag apapun
type Product struct {
	id    ProductID // field private, tidak ada tag
	name  string
	price Money
	stock int
}

// ✓ BENAR: persistence model terpisah di infrastructure layer
// infrastructure/postgres/model.go
package postgres

import "time"

// productRow adalah model database — hanya ada di infrastructure
type productRow struct {
	ID          string    `db:"id"`
	Name        string    `db:"name"`
	Description string    `db:"description"`
	PriceCents  int64     `db:"price_cents"`
	Currency    string    `db:"currency"`
	Stock       int       `db:"stock"`
	CategoryID  string    `db:"category_id"`
	CreatedAt   time.Time `db:"created_at"`
	UpdatedAt   time.Time `db:"updated_at"`
}

// toDomain mengonversi persistence model ke domain entity
func (r *productRow) toDomain() (*domain.Product, error) {
	price, err := domain.NewMoney(r.PriceCents, r.Currency)
	if err != nil {
		return nil, err
	}
	// Rekonstruksi entity dari data persistence
	return domain.ReconstructProduct(
		domain.ProductID(r.ID),
		r.Name,
		r.Description,
		price,
		r.Stock,
		domain.CategoryID(r.CategoryID),
		r.CreatedAt,
		r.UpdatedAt,
	), nil
}
// infrastructure/postgres/product_repository.go
package postgres

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

	"myapp/domain"
)

// PostgresProductRepository mengimplementasikan domain.ProductRepository
type PostgresProductRepository struct {
	db *sql.DB
}

// Compile-time check — memastikan interface terpenuhi
var _ domain.ProductRepository = (*PostgresProductRepository)(nil)

func NewPostgresProductRepository(db *sql.DB) *PostgresProductRepository {
	return &PostgresProductRepository{db: db}
}

func (r *PostgresProductRepository) Save(ctx context.Context, p *domain.Product) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO products (id, name, description, price_cents, currency, stock, category_id, created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
		 ON CONFLICT (id) DO UPDATE
		 SET name = $2, description = $3, price_cents = $4,
		     stock = $6, updated_at = $9`,
		string(p.ID()), p.Name(), p.Description(),
		p.Price().Cents(), p.Price().Currency(),
		p.Stock(), string(p.CategoryID()),
		p.CreatedAt(), p.UpdatedAt(),
	)
	if err != nil {
		return fmt.Errorf("gagal menyimpan produk: %w", err)
	}
	return nil
}

func (r *PostgresProductRepository) FindByID(ctx context.Context, id domain.ProductID) (*domain.Product, error) {
	row := &productRow{}
	err := r.db.QueryRowContext(ctx,
		`SELECT id, name, description, price_cents, currency, stock, category_id, created_at, updated_at
		 FROM products WHERE id = $1`,
		string(id),
	).Scan(
		&row.ID, &row.Name, &row.Description, &row.PriceCents, &row.Currency,
		&row.Stock, &row.CategoryID, &row.CreatedAt, &row.UpdatedAt,
	)
	if err == sql.ErrNoRows {
		return nil, fmt.Errorf("produk tidak ditemukan: %s", id)
	}
	if err != nil {
		return nil, fmt.Errorf("gagal membaca produk: %w", err)
	}
	return row.toDomain()
}

Struktur Direktori #

myapp/
├── domain/                         ← Inti — tidak ada dependency ke luar
│   ├── product.go                  ← Entity Product
│   ├── category.go                 ← Entity Category
│   ├── money.go                    ← Value Object Money
│   ├── repository.go               ← Repository interfaces
│   └── inventory_service.go        ← Domain Service
│
├── application/                    ← Use cases — hanya bergantung ke domain
│   ├── create_product.go
│   ├── add_stock.go
│   ├── get_product.go
│   └── deduct_stock.go
│
└── infrastructure/                 ← Detail teknis — bergantung ke application dan domain
    ├── postgres/
    │   ├── model.go                ← Persistence model (productRow, dll)
    │   └── product_repository.go   ← Implementasi domain.ProductRepository
    ├── redis/
    │   └── product_cache.go
    ├── http/
    │   ├── product_handler.go      ← HTTP handler (infrastructure concern)
    │   └── router.go
    └── cmd/
        └── main.go                 ← Wiring dependency

Pengujian: Domain dan Application Tanpa Infrastruktur #

// domain/product_test.go — Menguji entity murni, tidak ada dependency apapun
package domain_test

import (
	"testing"
)

func TestProduct_AddStock(t *testing.T) {
	price, _ := NewMoney(100000, "IDR")
	product, err := NewProduct("Laptop", "Gaming laptop", price, "cat-electronics")
	if err != nil {
		t.Fatal(err)
	}

	// Test: tambah stok valid
	if err := product.AddStock(10); err != nil {
		t.Errorf("tidak mengharapkan error: %v", err)
	}
	if product.Stock() != 10 {
		t.Errorf("stok seharusnya 10, dapat %d", product.Stock())
	}

	// Test: tambah stok invalid
	if err := product.AddStock(0); err == nil {
		t.Error("seharusnya error untuk qty = 0")
	}
	if err := product.AddStock(-5); err == nil {
		t.Error("seharusnya error untuk qty negatif")
	}
}

func TestProduct_Deduct_InsufficientStock(t *testing.T) {
	price, _ := NewMoney(100000, "IDR")
	product, _ := NewProduct("Laptop", "desc", price, "cat-1")
	product.AddStock(5)

	if err := product.Deduct(10); err == nil {
		t.Error("seharusnya error saat stok tidak cukup")
	}
	if product.Stock() != 5 {
		t.Errorf("stok tidak boleh berubah saat deduct gagal, dapat %d", product.Stock())
	}
}

// application/create_product_test.go — Menguji use case dengan mock repository
package application_test

import (
	"context"
	"testing"

	"myapp/application"
	"myapp/domain"
)

type mockProductRepo struct {
	products map[string]*domain.Product
}

func newMockProductRepo() *mockProductRepo {
	return &mockProductRepo{products: make(map[string]*domain.Product)}
}

func (m *mockProductRepo) Save(ctx context.Context, p *domain.Product) error {
	m.products[string(p.ID())] = p
	return nil
}

func (m *mockProductRepo) FindByID(ctx context.Context, id domain.ProductID) (*domain.Product, error) {
	if p, ok := m.products[string(id)]; ok {
		return p, nil
	}
	return nil, errors.New("not found")
}

func (m *mockProductRepo) FindByCategory(ctx context.Context, id domain.CategoryID) ([]*domain.Product, error) {
	return nil, nil
}

func (m *mockProductRepo) FindAvailable(ctx context.Context) ([]*domain.Product, error) {
	return nil, nil
}

func (m *mockProductRepo) Delete(ctx context.Context, id domain.ProductID) error {
	delete(m.products, string(id))
	return nil
}

func TestCreateProductUseCase_Success(t *testing.T) {
	repo := newMockProductRepo()
	uc := application.NewCreateProductUseCase(repo) // ✓ tidak butuh PostgreSQL nyata

	output, err := uc.Execute(context.Background(), application.CreateProductInput{
		Name:        "Mechanical Keyboard",
		Description: "TKL layout, red switch",
		PriceCents:  75000000, // 750.000 IDR dalam sen
		Currency:    "IDR",
		CategoryID:  "cat-peripherals",
	})

	if err != nil {
		t.Fatalf("tidak mengharapkan error: %v", err)
	}
	if output.ProductID == "" {
		t.Error("product ID tidak boleh kosong")
	}
	if output.PriceCents != 75000000 {
		t.Errorf("harga seharusnya 75000000, dapat %d", output.PriceCents)
	}
	if len(repo.products) != 1 {
		t.Errorf("seharusnya 1 produk tersimpan, dapat %d", len(repo.products))
	}
}

Perbandingan: Onion, Clean, dan Hexagonal #

flowchart LR
    subgraph OA["Onion Architecture"]
        OA1["Domain\n(Entity, VO, Repo Interface)"]
        OA2["Application\n(Use Case)"]
        OA3["Infrastructure\n(DB, HTTP, Framework)"]
        OA3 --> OA2 --> OA1
    end

    subgraph CA["Clean Architecture"]
        CA1["Entities"]
        CA2["Use Cases"]
        CA3["Interface Adapters"]
        CA4["Frameworks & Drivers"]
        CA4 --> CA3 --> CA2 --> CA1
    end

    subgraph HA["Hexagonal"]
        HA1["Application Core"]
        HA2["Inbound Port"]
        HA3["Outbound Port"]
        HA4["Inbound Adapter"]
        HA5["Outbound Adapter"]
        HA4 --> HA2 --> HA1
        HA1 --> HA3 --> HA5
    end
Aspek Onion Clean Architecture Hexagonal
Visualisasi Ring konsentris Lingkaran 4 lapisan Segi enam dengan port di sisi
Terminologi Domain, Application, Infrastructure Entities, Use Cases, Interface Adapters, Frameworks Port, Adapter, Application Core
Penekanan utama Kemurnian domain, dependency ke dalam Arah dependency yang ketat antar layer Pluggability — teknologi bisa diganti via adapter
Inbound/Outbound port Tidak dibedakan secara formal Tidak dibedakan secara formal Dibedakan secara eksplisit
Repository Didefinisikan di domain layer Didefinisikan di use case layer Outbound port di application core
Cocok untuk DDD-heavy, domain kompleks Sistem besar dengan banyak layer Sistem dengan banyak integrasi eksternal

Secara prinsip, ketiganya hampir identik. Pilih Onion jika fokusnya pada kemurnian domain dan kamu bekerja dengan DDD; pilih Hexagonal jika fokusnya pada pluggability dan kemampuan mengganti teknologi; pilih Clean jika fokusnya pada aturan dependency yang eksplisit dan formal.


Anti-Pattern yang Harus Dihindari #

// ✗ Import infrastructure di domain — pelanggaran paling dasar
package domain

import (
	"gorm.io/gorm"       // ✗ ORM di domain
	"github.com/gin-gonic/gin" // ✗ framework HTTP di domain
)

type Product struct {
	gorm.Model // ✗ embedding model ORM di domain entity
	Name string
}

// ✓ Domain murni — tidak ada import dari library eksternal
package domain

type Product struct {
	id    ProductID
	name  string
	price Money
	stock int
}

// ✗ Business logic di infrastructure (HTTP handler)
func (h *ProductHandler) CreateProduct(c *gin.Context) {
	var req CreateProductReq
	c.ShouldBindJSON(&req)

	// ✗ validasi bisnis di handler
	if req.Stock < 0 {
		c.JSON(400, gin.H{"error": "stok tidak boleh negatif"})
		return
	}
	// ✗ kalkulasi harga di handler
	finalPrice := req.Price * 1.1 // tambah PPN 10%
	// ...
}

// ✓ Handler tipis — delegate ke use case
func (h *ProductHandler) CreateProduct(c *gin.Context) {
	var req CreateProductReq
	c.ShouldBindJSON(&req)
	output, err := h.createProductUC.Execute(c.Request.Context(), mapToInput(req))
	if err != nil {
		c.JSON(422, gin.H{"error": err.Error()})
		return
	}
	c.JSON(201, mapToResponse(output))
}

// ✗ Application use case mengimport infrastructure langsung
package application

import "myapp/infrastructure/postgres" // ✗ dependency ke infrastructure konkret

type CreateProductUseCase struct {
	repo *postgres.PostgresProductRepository // ✗ struct konkret, bukan interface
}

// ✓ Application bergantung pada domain interface, bukan implementation konkret
package application

import "myapp/domain"

type CreateProductUseCase struct {
	repo domain.ProductRepository // ✓ interface — infrastructure bisa diganti
}

// ✗ Satu layer mengandung semua concern — God Layer
package application

type ProductService struct{} // ✗ berisi HTTP parsing, SQL query, dan business logic sekaligus

func (s *ProductService) CreateProduct(r *http.Request) ([]byte, error) {
	// parsing HTTP request (infrastructure concern)
	// validasi bisnis (domain concern)
	// query SQL langsung (infrastructure concern)
	// format JSON response (infrastructure concern)
}

// ✓ Setiap layer punya tanggung jawab yang jelas
// Infrastructure: parse HTTP, format response
// Application: orkestrasi use case
// Domain: business rules dan invariant

Checklist Review Onion Architecture #

DOMAIN LAYER:
  □ Tidak ada import dari library eksternal (ORM, HTTP, framework)
  □ Entity memiliki private field dan public getter yang mengontrol akses
  □ Validasi bisnis ada di dalam entity, bukan di service atau handler
  □ Repository interface didefinisikan di domain, bukan di infrastructure
  □ Value Object immutable — tidak ada setter, operasi hasilkan objek baru

APPLICATION LAYER:
  □ Bergantung ke domain melalui interface, bukan concrete implementation
  □ Tidak ada business logic kompleks — hanya orkestrasi
  □ Input dan output menggunakan DTO, bukan domain entity atau HTTP struct
  □ Tidak ada import dari infrastructure package

INFRASTRUCTURE LAYER:
  □ Persistence model (ORM struct) dipisah dari domain entity
  □ Ada fungsi mapper: toDomain() dan fromDomain() di repository implementation
  □ HTTP handler tipis — tidak mengandung business logic
  □ Compile-time interface check: var _ domain.Repo = (*ImplRepo)(nil)

SEPARATION MODEL:
  □ Domain entity tidak punya ORM tag (gorm, db, bson)
  □ Domain entity tidak punya JSON tag (json:"...")
  □ Persistence model hanya ada di infrastructure/postgres atau sejenisnya
  □ HTTP request/response struct hanya ada di infrastructure/http

PENGUJIAN:
  □ Domain entity diuji tanpa dependency apapun
  □ Use case diuji dengan mock repository, tanpa database nyata
  □ Repository diuji dengan integration test terhadap database nyata
  □ go test -race dijalankan untuk mendeteksi race condition

Ringkasan #

  • Dependency selalu ke dalam, menuju domain — ini adalah satu aturan yang tidak boleh dilanggar; infrastructure bergantung ke application, application bergantung ke domain, domain tidak bergantung ke siapapun.
  • Domain layer adalah lapisan paling stabil dan paling penting — entity, value object, repository interface, dan domain service ada di sini; tidak ada ORM tag, tidak ada JSON tag, tidak ada import library eksternal.
  • Repository interface didefinisikan di domain, implementasinya di infrastructure — ini memastikan domain yang menentukan kebutuhan, bukan database yang mendikte bentuk entity.
  • Model persistence harus dipisah dari domain entity — struct dengan ORM annotation hanya ada di infrastructure; ada fungsi mapper toDomain() yang mengonversi antara keduanya.
  • Application layer hanya mengorkestrasikan — use case menentukan urutan langkah, bukan mengandung business logic; validasi dan invariant ada di domain.
  • Infrastructure layer adalah lapisan yang paling sering berubah — framework HTTP, database, cache, external API — semuanya ada di sini dan bisa diganti tanpa menyentuh domain atau application.
  • Pengujian tanpa infrastruktur adalah indikator keberhasilan — jika domain dan application layer bisa diuji dengan mock tanpa database atau HTTP server nyata, Onion Architecture diterapkan dengan benar.
  • Onion, Clean, dan Hexagonal adalah keluarga yang sama — pilih Onion jika fokus pada kemurnian domain (DDD-heavy), Hexagonal jika fokus pada pluggability (banyak integrasi eksternal), Clean jika fokus pada aturan dependency yang formal.
  • Tidak cocok untuk semua situasi — CRUD sederhana, MVP cepat, atau tim yang belum familiar dengan dependency inversion akan lebih efisien dengan pendekatan yang lebih sederhana.
  • Mulai dari domain, bukan database — prinsip paling penting dalam Onion Architecture adalah mendesain entity berdasarkan aturan bisnis, bukan berdasarkan tabel database yang akan menyimpannya.

← Sebelumnya: Hexagonal Architecture   Berikutnya: Layered Architecture →

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