Onion Architecture #

There is a moment familiar to many engineering teams: a developer changes the database schema, and suddenly unit tests fail — not because business logic changed, but because domain entities directly use ORM structs whose columns shifted. Or when the HTTP framework is replaced, and it turns out controllers were holding business validation that cannot be separated. These problems share the same root: infrastructure and business are too close, too tightly bound. Onion Architecture, introduced by Jeffrey Palermo in 2008, is a direct response to this problem. With its onion-like layer visualization — domain at the center, infrastructure at the outermost skin — it enforces one principle that must never be violated: dependencies always flow inward, toward the domain, never the reverse. The database can be replaced. The framework can be swapped. The domain stays intact.

Ring Structure: The Visualization That Defines Everything #

Onion Architecture uses the onion metaphor — concentric layers wrapping a core. Each layer may only depend on layers deeper inside, never outward.

flowchart TD
    subgraph ONION["Onion Architecture — Dependencies always inward"]
        INFRA["Infrastructure\\nDB · HTTP · Framework · External API · ORM"]
        APP["Application Layer\\nUse Case · Application Service · DTO"]
        DOM_SVC["Domain Service\\nLogic involving many entities"]
        REPO_IF["Repository Interface\\nDefined in the 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

The fundamental rule is simple but non-negotiable:

LayerMay depend onMust not depend on
InfrastructureAll layers
ApplicationDomainInfrastructure
Domain ServiceEntity, Repository InterfaceApplication, Infrastructure
Repository InterfaceEntityEverything else
EntityNothingEverything

The domain is the innermost and most stable layer — it does not know what framework is used, what database is used, or how data is sent to clients.


Domain Layer: The Core That Depends on Nobody #

The domain layer contains three kinds of objects: Entities, Value Objects, Repository Interfaces, and Domain Services. There is not a single import from a database library, HTTP framework, or external tool here.

// domain/product.go — Entity
package domain

import (
	"errors"
	"time"
)

type ProductID string
type CategoryID string

// Product is an entity in the domain layer — no ORM tags, no JSON tags
type Product struct {
	id          ProductID
	name        string
	description string
	price       Money
	stock       int
	categoryID  CategoryID
	createdAt   time.Time
	updatedAt   time.Time
}

// NewProduct enforces business invariants at creation
func NewProduct(name, description string, price Money, categoryID CategoryID) (*Product, error) {
	if name == "" {
		return nil, errors.New("product name must not be empty")
	}
	if len(name) > 200 {
		return nil, errors.New("product name must not exceed 200 characters")
	}
	if price.IsZeroOrNegative() {
		return nil, errors.New("product price must be greater than zero")
	}
	return &Product{
		id:          ProductID(generateID()),
		name:        name,
		description: description,
		price:       price,
		stock:       0,
		categoryID:  categoryID,
		createdAt:   time.Now(),
		updatedAt:   time.Now(),
	}, nil
}

// AddStock adds stock — business logic lives inside the entity
func (p *Product) AddStock(qty int) error {
	if qty <= 0 {
		return errors.New("stock quantity to add must be greater than 0")
	}
	p.stock += qty
	p.updatedAt = time.Now()
	return nil
}

// Deduct reduces stock when a purchase occurs
func (p *Product) Deduct(qty int) error {
	if qty <= 0 {
		return errors.New("quantity to deduct must be greater than 0")
	}
	if p.stock < qty {
		return errors.New("insufficient stock")
	}
	p.stock -= qty
	p.updatedAt = time.Now()
	return nil
}

// UpdatePrice updates the price with business validation
func (p *Product) UpdatePrice(newPrice Money) error {
	if newPrice.IsZeroOrNegative() {
		return errors.New("new price must be greater than zero")
	}
	p.price = newPrice
	p.updatedAt = time.Now()
	return nil
}

// Getters — the entity controls access to its internal state
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 is a 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 must not be empty")
	}
	if cents < 0 {
		return Money{}, errors.New("money value must not be negative")
	}
	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("cannot add %s to %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 Interfaces defined in the domain
// ✓ The domain defines its needs — infrastructure fulfills them
package domain

import "context"

// ProductRepository is the interface defined by the domain
// Its implementation lives in the 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
// Logic involving many entities that does not fit in one entity
package domain

import (
	"context"
	"errors"
)

// InventoryService is a Domain Service for complex stock operations
type InventoryService struct {
	productRepo ProductRepository
}

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

// TransferStock moves stock from one product to another
// This logic involves two entities — it does not fit in either one
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
	}

	// Deduct from the source
	if err := from.Deduct(quantity); err != nil {
		return errors.New("source does not have enough stock: " + err.Error())
	}

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

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

Application Layer: Use Case Orchestration #

The application layer orchestrates business flows using entities and services from the domain layer. It contains no business logic — it only determines the sequence of steps that must happen.

// application/create_product.go
package application

import (
	"context"

	"myapp/domain"
)

// CreateProductInput is the input DTO from outside into the application layer
type CreateProductInput struct {
	Name        string
	Description string
	PriceCents  int64
	Currency    string
	CategoryID  string
}

// CreateProductOutput is the output DTO from the application layer to the outside
type CreateProductOutput struct {
	ProductID   string
	Name        string
	PriceCents  int64
	Stock       int
}

// CreateProductUseCase orchestrates the creation of a new product
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) {
	// Create the value object
	price, err := domain.NewMoney(input.PriceCents, input.Currency)
	if err != nil {
		return nil, err
	}

	// Create the domain entity — validation lives inside the entity
	product, err := domain.NewProduct(
		input.Name,
		input.Description,
		price,
		domain.CategoryID(input.CategoryID),
	)
	if err != nil {
		return nil, err
	}

	// Save through the 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 is the input for adding stock
type AddStockInput struct {
	ProductID string
	Quantity  int
}

// AddStockUseCase orchestrates adding product stock
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
	}

	// Delegate to the domain entity — the use case contains no business logic
	if err := product.AddStock(input.Quantity); err != nil {
		return err
	}

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

Separating the Domain Model from the Persistence Model #

One of the most important practices in Onion Architecture is separating domain entities from persistence models. Never mix ORM annotations with domain entities.

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

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

// ✓ CORRECT: pure domain entity — no tags at all
type Product struct {
	id    ProductID // private field, no tags
	name  string
	price Money
	stock int
}

// ✓ CORRECT: persistence model separated in the infrastructure layer
// infrastructure/postgres/model.go
package postgres

import "time"

// productRow is the database model — only in 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 converts the persistence model to a domain entity
func (r *productRow) toDomain() (*domain.Product, error) {
	price, err := domain.NewMoney(r.PriceCents, r.Currency)
	if err != nil {
		return nil, err
	}
	// Reconstruct the entity from persistence data
	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 implements domain.ProductRepository
type PostgresProductRepository struct {
	db *sql.DB
}

// Compile-time check — ensures the interface is satisfied
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("failed to save product: %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("product not found: %s", id)
	}
	if err != nil {
		return nil, fmt.Errorf("failed to read product: %w", err)
	}
	return row.toDomain()
}

Directory Structure #

myapp/
├── domain/                         ← The core — no external dependencies
│   ├── product.go                  ← Product entity
│   ├── category.go                 ← Category entity
│   ├── money.go                    ← Money Value Object
│   ├── repository.go               ← Repository interfaces
│   └── inventory_service.go        ← Domain Service
│
├── application/                    ← Use cases — only depend on the domain
│   ├── create_product.go
│   ├── add_stock.go
│   ├── get_product.go
│   └── deduct_stock.go
│
└── infrastructure/                 ← Technical details — depend on application and domain
    ├── postgres/
    │   ├── model.go                ← Persistence models (productRow, etc.)
    │   └── product_repository.go   ← domain.ProductRepository implementation
    ├── redis/
    │   └── product_cache.go
    ├── http/
    │   ├── product_handler.go      ← HTTP handlers (infrastructure concern)
    │   └── router.go
    └── cmd/
        └── main.go                 ← Dependency wiring

Testing: Domain and Application Without Infrastructure #

// domain/product_test.go — Testing the pure entity, no dependencies at all
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: valid stock addition
	if err := product.AddStock(10); err != nil {
		t.Errorf("expected no error: %v", err)
	}
	if product.Stock() != 10 {
		t.Errorf("stock should be 10, got %d", product.Stock())
	}

	// Test: invalid stock addition
	if err := product.AddStock(0); err == nil {
		t.Error("expected error for qty = 0")
	}
	if err := product.AddStock(-5); err == nil {
		t.Error("expected error for negative qty")
	}
}

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("expected error when stock is insufficient")
	}
	if product.Stock() != 5 {
		t.Errorf("stock must not change when deduct fails, got %d", product.Stock())
	}
}

// application/create_product_test.go — Testing the use case with a 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) // ✓ no real PostgreSQL needed

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

	if err != nil {
		t.Fatalf("expected no error: %v", err)
	}
	if output.ProductID == "" {
		t.Error("product ID must not be empty")
	}
	if output.PriceCents != 75000000 {
		t.Errorf("price should be 75000000, got %d", output.PriceCents)
	}
	if len(repo.products) != 1 {
		t.Errorf("expected 1 stored product, got %d", len(repo.products))
	}
}

Comparison: Onion, Clean, and 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
AspectOnionClean ArchitectureHexagonal
VisualizationConcentric rings4-layer circlesA hexagon with ports on its sides
TerminologyDomain, Application, InfrastructureEntities, Use Cases, Interface Adapters, FrameworksPort, Adapter, Application Core
Main emphasisDomain purity, dependencies inwardStrict dependency direction between layersPluggability — technology swappable via adapters
Inbound/Outbound portsNot formally distinguishedNot formally distinguishedExplicitly distinguished
RepositoryDefined in the domain layerDefined in the use case layerOutbound port in the application core
Best forDDD-heavy, complex domainsLarge systems with many layersSystems with many external integrations

In principle, the three are almost identical. Choose Onion if the focus is domain purity and you work with DDD; choose Hexagonal if the focus is pluggability and technology swapping; choose Clean if the focus is explicit, formal dependency rules.


Anti-Patterns to Avoid #

// ✗ Importing infrastructure in the domain — the most basic violation
package domain

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

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

// ✓ Pure domain — no imports from external libraries
package domain

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

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

	// ✗ business validation in the handler
	if req.Stock < 0 {
		c.JSON(400, gin.H{"error": "stock must not be negative"})
		return
	}
	// ✗ price calculation in the handler
	finalPrice := req.Price * 1.1 // add 10% VAT
	// ...
}

// ✓ Thin handlers — delegate to use cases
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 cases importing infrastructure directly
package application

import "myapp/infrastructure/postgres" // ✗ dependency on concrete infrastructure

type CreateProductUseCase struct {
	repo *postgres.PostgresProductRepository // ✗ concrete struct, not an interface
}

// ✓ Application depends on domain interfaces, not concrete implementations
package application

import "myapp/domain"

type CreateProductUseCase struct {
	repo domain.ProductRepository // ✓ interface — infrastructure can be swapped
}

// ✗ One layer containing every concern — a God Layer
package application

type ProductService struct{} // ✗ contains HTTP parsing, SQL queries, and business logic at once

func (s *ProductService) CreateProduct(r *http.Request) ([]byte, error) {
	// HTTP request parsing (infrastructure concern)
	// business validation (domain concern)
	// direct SQL queries (infrastructure concern)
	// JSON response formatting (infrastructure concern)
}

// ✓ Each layer has a clear responsibility
// Infrastructure: parse HTTP, format responses
// Application: orchestrate use cases
// Domain: business rules and invariants

Onion Architecture Review Checklist #

DOMAIN LAYER:
  □ No imports from external libraries (ORM, HTTP, framework)
  □ Entities have private fields and public getters controlling access
  □ Business validation lives inside entities, not in services or handlers
  □ Repository interfaces are defined in the domain, not in infrastructure
  □ Value Objects are immutable — no setters, operations produce new objects

APPLICATION LAYER:
  □ Depends on the domain through interfaces, not concrete implementations
  □ No complex business logic — only orchestration
  □ Input and output use DTOs, not domain entities or HTTP structs
  □ No imports from infrastructure packages

INFRASTRUCTURE LAYER:
  □ Persistence models (ORM structs) are separated from domain entities
  □ Mapper functions exist: toDomain() and fromDomain() in repository implementations
  □ HTTP handlers are thin — no business logic
  □ Compile-time interface check: var _ domain.Repo = (*ImplRepo)(nil)

MODEL SEPARATION:
  □ Domain entities have no ORM tags (gorm, db, bson)
  □ Domain entities have no JSON tags (json:"...")
  □ Persistence models only exist in infrastructure/postgres or similar
  □ HTTP request/response structs only exist in infrastructure/http

TESTING:
  □ Domain entities are tested without any dependencies
  □ Use cases are tested with mock repositories, no real database
  □ Repositories are tested with integration tests against a real database
  □ go test -race is run to detect race conditions

Summary #

  • Dependencies always flow inward, toward the domain — this is the one rule that must never be violated; infrastructure depends on application, application depends on domain, domain depends on nobody.
  • The domain layer is the most stable and most important layer — entities, value objects, repository interfaces, and domain services live here; no ORM tags, no JSON tags, no external library imports.
  • Repository interfaces are defined in the domain, implementations in infrastructure — this ensures the domain determines the needs, not the database dictating the entity’s shape.
  • Persistence models must be separated from domain entities — structs with ORM annotations only exist in infrastructure; a toDomain() mapper function converts between the two.
  • The application layer only orchestrates — use cases determine the sequence of steps, not the business logic; validation and invariants live in the domain.
  • The infrastructure layer changes most often — HTTP frameworks, databases, caches, external APIs — all live here and can be swapped without touching the domain or application.
  • Testing without infrastructure is a success indicator — if the domain and application layers can be tested with mocks without a real database or HTTP server, Onion Architecture is applied correctly.
  • Onion, Clean, and Hexagonal are the same family — choose Onion for domain purity (DDD-heavy), Hexagonal for pluggability (many external integrations), Clean for formal dependency rules.
  • Not suitable for every situation — simple CRUD, fast MVPs, or teams unfamiliar with dependency inversion will be more efficient with a simpler approach.
  • Start from the domain, not the database — the most important principle in Onion Architecture is designing entities based on business rules, not on the database tables that will store them.

← Previous: Hexagonal   Next: Layered →

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