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 →