Layered Architecture #

Among all architectural patterns in existence, Layered Architecture is the most widely implemented, the most often taught, and the easiest for new developers to understand. If you have ever seen a codebase with handler, service, repository, and model folders — that is Layered Architecture in one of its variations. Its simplicity is both its strength and its weakness: easy to start, but also easy to get wrong in ways that are not immediately visible. Business logic slowly creeping into handlers, entities tightly coupled to database schemas, a service layer becoming the dumping ground for everything — these are common traps that often only start hurting years later. This article discusses Layered Architecture not just as a “way to organize folders”, but as a set of rules that, when enforced with discipline, produces a codebase that is easy to understand, test, and grow over the long term.

The Four-Layer Structure #

Layered Architecture separates the system into horizontal layers with a clear dependency rule: an upper layer depends on the layer below it, never the reverse.

flowchart TD
    P["Presentation Layer\\nHTTP Handler · gRPC Server · CLI"]
    A["Application Layer\\nService · Use Case · Orchestration"]
    D["Domain Layer\\nEntity · Business Rules · Repository Interface"]
    I["Infrastructure Layer\\nDatabase · Cache · Message Broker · External API"]

    P -->|"depends on"| A
    A -->|"depends on"| D
    D -.->|"interface implemented by"| I

    style P fill:#74c69d,color:#1b4332
    style A fill:#52b788,color:#fff
    style D fill:#2d6a4f,color:#fff
    style I fill:#b7e4c7,color:#1b4332

Each layer has one clear, non-overlapping responsibility:

LayerResponsibilityMust Not
PresentationParse requests, validate formats, format responsesBusiness logic, direct database access
ApplicationUse case orchestration, transactions, inter-service coordinationHTTP details, JSON/XML formatting
DomainBusiness rules, invariants, entity state machinesDatabase imports, HTTP frameworks
InfrastructureRepository implementations, DB connections, external integrationsBusiness logic, routing

Strict vs Relaxed Layering #

There are two variants of Layered Architecture to understand:

Strict Layering: Each layer may only call the layer exactly one level below it. Presentation only calls Application, Application only calls Domain.

Relaxed Layering: Layers may skip the layers in between when needed — for example, Presentation may call Domain directly for simple queries.

flowchart LR
    subgraph STRICT["Strict Layering"]
        SP["Presentation"] --> SA["Application"] --> SD["Domain"] --> SI["Infrastructure"]
    end

    subgraph RELAXED["Relaxed Layering"]
        RP["Presentation"] --> RA["Application"]
        RP -->|"may call directly"| RD["Domain"]
        RA --> RD --> RI["Infrastructure"]
    end

For most systems, strict layering is safer because it prevents unexpected coupling. Relaxed layering can be useful for simple read-only queries, but it must be applied consistently and documented clearly.


Go Implementation: Employee Management System #

The following example implements an employee management system using the four-layer Layered Architecture:

Domain Layer #

// domain/employee.go
package domain

import (
	"errors"
	"regexp"
	"time"
)

type EmployeeID string
type DepartmentID string

// Employee is an entity with business rules
// ✓ No imports from databases, HTTP, or any framework
type Employee struct {
	id           EmployeeID
	fullName     string
	email        string
	departmentID DepartmentID
	salary       int64 // in the smallest unit (cents/rupiah)
	joinDate     time.Time
	isActive     bool
}

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

// NewEmployee enforces business invariants at creation
func NewEmployee(fullName, email string, departmentID DepartmentID, salary int64) (*Employee, error) {
	if fullName == "" {
		return nil, errors.New("employee name must not be empty")
	}
	if !emailRegex.MatchString(email) {
		return nil, errors.New("invalid email format")
	}
	if salary < 0 {
		return nil, errors.New("salary must not be negative")
	}

	return &Employee{
		id:           EmployeeID(generateID()),
		fullName:     fullName,
		email:        email,
		departmentID: departmentID,
		salary:       salary,
		joinDate:     time.Now(),
		isActive:     true,
	}, nil
}

// Promote raises the salary with business validation
func (e *Employee) Promote(newSalary int64) error {
	if newSalary <= e.salary {
		return errors.New("new salary must be greater than the current salary")
	}
	e.salary = newSalary
	return nil
}

// Resign deactivates the employee
func (e *Employee) Resign() error {
	if !e.isActive {
		return errors.New("employee is already inactive")
	}
	e.isActive = false
	return nil
}

// Transfer moves the employee to another department
func (e *Employee) Transfer(newDeptID DepartmentID) error {
	if e.departmentID == newDeptID {
		return errors.New("employee is already in that department")
	}
	e.departmentID = newDeptID
	return nil
}

// Getters
func (e *Employee) ID() EmployeeID           { return e.id }
func (e *Employee) FullName() string          { return e.fullName }
func (e *Employee) Email() string             { return e.email }
func (e *Employee) DepartmentID() DepartmentID { return e.departmentID }
func (e *Employee) Salary() int64             { return e.salary }
func (e *Employee) JoinDate() time.Time       { return e.joinDate }
func (e *Employee) IsActive() bool            { return e.isActive }

// EmployeeRepository is the interface defined in the domain
// ✓ The domain defines the needs — infrastructure implements them
type EmployeeRepository interface {
	Save(employee *Employee) error
	FindByID(id EmployeeID) (*Employee, error)
	FindByDepartment(deptID DepartmentID) ([]*Employee, error)
	FindAllActive() ([]*Employee, error)
}

Application Layer #

// application/employee_service.go
package application

import (
	"errors"
	"fmt"

	"myapp/domain"
)

// EmployeeService orchestrates employee use cases
// ✓ No HTTP or database details here — only business flows
type EmployeeService struct {
	employeeRepo domain.EmployeeRepository
}

func NewEmployeeService(employeeRepo domain.EmployeeRepository) *EmployeeService {
	return &EmployeeService{employeeRepo: employeeRepo}
}

// CreateEmployeeInput is the input DTO for creating an employee
type CreateEmployeeInput struct {
	FullName     string
	Email        string
	DepartmentID string
	Salary       int64
}

// EmployeeOutput is the output DTO used for all responses
type EmployeeOutput struct {
	ID           string
	FullName     string
	Email        string
	DepartmentID string
	Salary       int64
	JoinDate     string
	IsActive     bool
}

func toOutput(e *domain.Employee) *EmployeeOutput {
	return &EmployeeOutput{
		ID:           string(e.ID()),
		FullName:     e.FullName(),
		Email:        e.Email(),
		DepartmentID: string(e.DepartmentID()),
		Salary:       e.Salary(),
		JoinDate:     e.JoinDate().Format("2006-01-02"),
		IsActive:     e.IsActive(),
	}
}

// CreateEmployee creates a new employee
func (s *EmployeeService) CreateEmployee(input CreateEmployeeInput) (*EmployeeOutput, error) {
	employee, err := domain.NewEmployee(
		input.FullName,
		input.Email,
		domain.DepartmentID(input.DepartmentID),
		input.Salary,
	)
	if err != nil {
		return nil, err
	}

	if err := s.employeeRepo.Save(employee); err != nil {
		return nil, fmt.Errorf("failed to save employee: %w", err)
	}

	return toOutput(employee), nil
}

// PromoteEmployee raises an employee's salary
func (s *EmployeeService) PromoteEmployee(employeeID string, newSalary int64) (*EmployeeOutput, error) {
	employee, err := s.employeeRepo.FindByID(domain.EmployeeID(employeeID))
	if err != nil {
		return nil, err
	}

	// Delegate to the domain entity — the service contains no business logic
	if err := employee.Promote(newSalary); err != nil {
		return nil, err
	}

	if err := s.employeeRepo.Save(employee); err != nil {
		return nil, fmt.Errorf("failed to save changes: %w", err)
	}

	return toOutput(employee), nil
}

// ResignEmployee processes an employee's resignation
func (s *EmployeeService) ResignEmployee(employeeID string) error {
	employee, err := s.employeeRepo.FindByID(domain.EmployeeID(employeeID))
	if err != nil {
		return err
	}

	if err := employee.Resign(); err != nil {
		return err
	}

	return s.employeeRepo.Save(employee)
}

// TransferEmployee moves an employee to another department
func (s *EmployeeService) TransferEmployee(employeeID, newDeptID string) (*EmployeeOutput, error) {
	employee, err := s.employeeRepo.FindByID(domain.EmployeeID(employeeID))
	if err != nil {
		return nil, err
	}

	if err := employee.Transfer(domain.DepartmentID(newDeptID)); err != nil {
		return nil, err
	}

	if err := s.employeeRepo.Save(employee); err != nil {
		return nil, err
	}

	return toOutput(employee), nil
}

// GetDepartmentEmployees fetches all employees in one department
func (s *EmployeeService) GetDepartmentEmployees(deptID string) ([]*EmployeeOutput, error) {
	employees, err := s.employeeRepo.FindByDepartment(domain.DepartmentID(deptID))
	if err != nil {
		return nil, err
	}

	outputs := make([]*EmployeeOutput, len(employees))
	for i, emp := range employees {
		outputs[i] = toOutput(emp)
	}
	return outputs, nil
}

Presentation Layer #

// presentation/employee_handler.go
package presentation

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

	"myapp/application"
)

// EmployeeHandler is an HTTP handler — the presentation layer
// ✓ Only handles request parsing, delegation to services, and response formatting
type EmployeeHandler struct {
	employeeService *application.EmployeeService
}

func NewEmployeeHandler(svc *application.EmployeeService) *EmployeeHandler {
	return &EmployeeHandler{employeeService: svc}
}

type createEmployeeRequest struct {
	FullName     string `json:"full_name"`
	Email        string `json:"email"`
	DepartmentID string `json:"department_id"`
	Salary       int64  `json:"salary"`
}

func (h *EmployeeHandler) CreateEmployee(w http.ResponseWriter, r *http.Request) {
	var req createEmployeeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request")
		return
	}

	// Format validation belongs here — not business validation
	if req.FullName == "" || req.Email == "" || req.DepartmentID == "" {
		writeError(w, http.StatusBadRequest, "all fields are required")
		return
	}

	output, err := h.employeeService.CreateEmployee(application.CreateEmployeeInput{
		FullName:     req.FullName,
		Email:        req.Email,
		DepartmentID: req.DepartmentID,
		Salary:       req.Salary,
	})
	if err != nil {
		writeError(w, http.StatusUnprocessableEntity, err.Error())
		return
	}

	writeJSON(w, http.StatusCreated, output)
}

type promoteRequest struct {
	NewSalary int64 `json:"new_salary"`
}

func (h *EmployeeHandler) PromoteEmployee(w http.ResponseWriter, r *http.Request) {
	employeeID := r.PathValue("id")

	var req promoteRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request")
		return
	}

	output, err := h.employeeService.PromoteEmployee(employeeID, req.NewSalary)
	if err != nil {
		writeError(w, http.StatusUnprocessableEntity, err.Error())
		return
	}

	writeJSON(w, http.StatusOK, output)
}

func (h *EmployeeHandler) ResignEmployee(w http.ResponseWriter, r *http.Request) {
	employeeID := r.PathValue("id")

	if err := h.employeeService.ResignEmployee(employeeID); err != nil {
		writeError(w, http.StatusUnprocessableEntity, err.Error())
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

func (h *EmployeeHandler) GetDepartmentEmployees(w http.ResponseWriter, r *http.Request) {
	deptID := r.PathValue("dept_id")

	outputs, err := h.employeeService.GetDepartmentEmployees(deptID)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "failed to fetch employee data")
		return
	}

	writeJSON(w, http.StatusOK, outputs)
}

func writeJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, map[string]string{"error": message})
}

Infrastructure Layer #

// infrastructure/postgres_employee_repository.go
package infrastructure

import (
	"database/sql"
	"fmt"
	"time"

	"myapp/domain"
)

// PostgresEmployeeRepository implements domain.EmployeeRepository
type PostgresEmployeeRepository struct {
	db *sql.DB
}

var _ domain.EmployeeRepository = (*PostgresEmployeeRepository)(nil)

func NewPostgresEmployeeRepository(db *sql.DB) *PostgresEmployeeRepository {
	return &PostgresEmployeeRepository{db: db}
}

// employeeRow is the database model — separated from the domain entity
type employeeRow struct {
	ID           string
	FullName     string
	Email        string
	DepartmentID string
	Salary       int64
	JoinDate     time.Time
	IsActive     bool
}

func (r *employeeRow) toDomain() *domain.Employee {
	return domain.ReconstructEmployee(
		domain.EmployeeID(r.ID),
		r.FullName,
		r.Email,
		domain.DepartmentID(r.DepartmentID),
		r.Salary,
		r.JoinDate,
		r.IsActive,
	)
}

func (r *PostgresEmployeeRepository) Save(emp *domain.Employee) error {
	_, err := r.db.Exec(
		`INSERT INTO employees (id, full_name, email, department_id, salary, join_date, is_active)
		 VALUES ($1, $2, $3, $4, $5, $6, $7)
		 ON CONFLICT (id) DO UPDATE
		 SET full_name = $2, email = $3, department_id = $4,
		     salary = $5, is_active = $7`,
		string(emp.ID()), emp.FullName(), emp.Email(),
		string(emp.DepartmentID()), emp.Salary(),
		emp.JoinDate(), emp.IsActive(),
	)
	if err != nil {
		return fmt.Errorf("failed to save employee: %w", err)
	}
	return nil
}

func (r *PostgresEmployeeRepository) FindByID(id domain.EmployeeID) (*domain.Employee, error) {
	row := &employeeRow{}
	err := r.db.QueryRow(
		`SELECT id, full_name, email, department_id, salary, join_date, is_active
		 FROM employees WHERE id = $1`,
		string(id),
	).Scan(
		&row.ID, &row.FullName, &row.Email,
		&row.DepartmentID, &row.Salary,
		&row.JoinDate, &row.IsActive,
	)
	if err == sql.ErrNoRows {
		return nil, fmt.Errorf("employee not found: %s", id)
	}
	if err != nil {
		return nil, fmt.Errorf("failed to read employee: %w", err)
	}
	return row.toDomain(), nil
}

func (r *PostgresEmployeeRepository) FindByDepartment(deptID domain.DepartmentID) ([]*domain.Employee, error) {
	rows, err := r.db.Query(
		`SELECT id, full_name, email, department_id, salary, join_date, is_active
		 FROM employees WHERE department_id = $1 AND is_active = TRUE`,
		string(deptID),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to query employees: %w", err)
	}
	defer rows.Close()

	var employees []*domain.Employee
	for rows.Next() {
		row := &employeeRow{}
		if err := rows.Scan(
			&row.ID, &row.FullName, &row.Email,
			&row.DepartmentID, &row.Salary,
			&row.JoinDate, &row.IsActive,
		); err != nil {
			return nil, err
		}
		employees = append(employees, row.toDomain())
	}
	return employees, nil
}

func (r *PostgresEmployeeRepository) FindAllActive() ([]*domain.Employee, error) {
	// implementation similar to FindByDepartment...
	return nil, nil
}

The Hidden Problem: Anemic Domain Model #

Layered Architecture has a characteristic trap that slowly damages maintainability: when business logic that should live in the domain starts “creeping” into the service layer, entities become anemic — mere data containers without behavior.

// ✗ Anemic Domain Model — entity is only data, all logic in services
type Employee struct { // only getters/setters
    ID       string
    FullName string
    Salary   int64
    IsActive bool
}

type EmployeeService struct {
    repo EmployeeRepository
}

func (s *EmployeeService) Promote(id string, newSalary int64) error {
    emp, _ := s.repo.FindByID(id)
    // ✗ business validation leaking into the service layer
    if newSalary <= emp.Salary {
        return errors.New("new salary must be greater")
    }
    emp.Salary = newSalary // ✗ direct mutation from outside the entity
    return s.repo.Save(emp)
}

// ✓ Rich Domain Model — business rules live inside the entity
type Employee struct {
    id     EmployeeID
    salary int64
    // ... other fields
}

func (e *Employee) Promote(newSalary int64) error {
    // ✓ business validation inside the entity
    if newSalary <= e.salary {
        return errors.New("new salary must be greater than the current salary")
    }
    e.salary = newSalary // ✓ the entity controls its own state
    return nil
}

// The service only orchestrates
func (s *EmployeeService) PromoteEmployee(id string, newSalary int64) error {
    emp, _ := s.repo.FindByID(EmployeeID(id))
    if err := emp.Promote(newSalary); err != nil { // ✓ delegate to the entity
        return err
    }
    return s.repo.Save(emp)
}

The Database as Center of Gravity #

Another hidden problem of undisciplined Layered Architecture is when the database schema becomes the “center of gravity” dictating the shape of the entire codebase:

flowchart TD
    subgraph WRONG["❌ Database-Driven — the schema dictates everything"]
        DB1[(DB Schema)] -->|"directly becomes entity"| E1["User struct\\n(matching DB columns)"]
        E1 -->|"returned directly"| S1["UserService"]
        S1 -->|"serialized directly"| H1["HTTP Response\\n(= DB columns)"]
    end

    subgraph RIGHT["✓ Domain-Driven within Layered — the domain defines everything"]
        DOM["User entity\\n(based on business rules)"]
        DB2[(DB Schema)] -->|"mapper"| DOM
        DOM -->|"processed"| SVC["UserService"]
        SVC -->|"DTO mapper"| RESP["HTTP Response\\n(according to client needs)"]
    end

When the database schema directly becomes the domain entity, every column change in the database requires changes across the entire stack. With a mapper separating the persistence model from the domain entity, database changes are localized to the infrastructure layer.


Directory Structure #

myapp/
├── presentation/              ← Presentation layer
│   ├── employee_handler.go    ← HTTP handlers
│   ├── middleware.go          ← Auth, logging, cors
│   └── router.go              ← Route registration
│
├── application/               ← Application layer
│   ├── employee_service.go    ← Use case orchestration
│   └── dto.go                 ← Input/output DTOs
│
├── domain/                    ← Domain layer
│   ├── employee.go            ← Entity + business rules
│   ├── department.go
│   └── repository.go          ← Repository interfaces
│
├── infrastructure/            ← Infrastructure layer
│   ├── postgres_employee_repository.go
│   ├── redis_cache.go
│   └── smtp_mailer.go
│
└── cmd/
    └── main.go                ← Wiring all dependencies

Layered vs Clean Architecture #

This is the most frequently asked question when teams consider Layered Architecture:

AspectLayered ArchitectureClean Architecture
Dependency directionTop-down (presentation → domain → infra)Always inward (infra → adapters → use cases → entities)
Database positionInfrastructure layer, but the domain often couples to the DBAn implementation detail swappable at any time
TestabilityGood if interfaces are usedExcellent — the domain needs no DB to be tested
Learning curveLow — familiar to most developersHigher — many layers and interfaces
Best forMedium systems, teams new to architectureComplex, long-lived systems
OverheadLow — just 4 foldersHigher — many interfaces and mappings
RiskAnemic domain model, database couplingOver-engineering for small systems

Layered Architecture is a good entry point to more mature architectures. Many teams start with Layered and evolve to Clean or Hexagonal as complexity increases — and this transition is easier if Layered Architecture is applied with discipline from the start.


Anti-Patterns to Avoid #

// ✗ Business logic in the presentation layer
func (h *EmployeeHandler) Promote(w http.ResponseWriter, r *http.Request) {
	emp := getEmployee(r)
	// ✗ business validation in the HTTP handler
	if emp.Salary >= 10000000 {
		writeError(w, 400, "salary has reached the maximum limit")
		return
	}
	emp.Salary += 1000000 // ✗ direct mutation in the handler
	saveEmployee(emp)
}

// ✓ The handler only parses and delegates
func (h *EmployeeHandler) Promote(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req promoteRequest
	json.NewDecoder(r.Body).Decode(&req)
	output, err := h.svc.PromoteEmployee(id, req.NewSalary) // ✓ delegate
	if err != nil { writeError(w, 422, err.Error()); return }
	writeJSON(w, 200, output)
}

// ✗ Skipping layers — presentation straight to infrastructure
func (h *EmployeeHandler) GetEmployee(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	// ✗ the handler queries the database directly — skipping application and domain
	var emp employeeRow
	db.QueryRow("SELECT * FROM employees WHERE id = $1", id).Scan(&emp)
	json.NewEncoder(w).Encode(emp)
}

// ✓ The flow always goes through the right layers
func (h *EmployeeHandler) GetEmployee(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	output, err := h.svc.GetEmployee(id) // ✓ through the application service
	if err != nil { writeError(w, 404, err.Error()); return }
	writeJSON(w, 200, output)
}

// ✗ Circular dependency — domain importing from application
package domain // ✗

import "myapp/application" // DON'T — dependency cycle!

// ✓ Dependencies are always one-way downward
// presentation imports application
// application imports domain
// domain imports nothing from upper layers

When Layered Architecture Is Enough, When to Evolve #

Layered Architecture is great if:
  ✓ Small to medium systems (1–15 developers)
  ✓ The business domain is not too complex — CRUD with some business rules
  ✓ The team is new to architecture — the low learning curve helps onboarding
  ✓ Used as the internal architecture of individual microservices
  ✓ Fast development time is a priority

Consider Clean / Hexagonal / Onion if:
  ✗ Business rules are very complex and continuously growing
  ✗ Many external integrations may be swapped
  ✗ Testability becomes a priority — unit tests without a database needed
  ✗ A large team with many developers working in parallel
  ✗ The anemic domain model is already being felt — services keep bloating

Signs Layered Architecture needs tightening or evolution:
  □ Service layer files longer than 500 lines
  □ Entities contain only getters/setters without business methods
  □ Presenters/handlers contain business conditionals
  □ Unit tests need a real database to run
  □ Database schema changes cause many files to change

Layered Architecture Review Checklist #

PRESENTATION LAYER:
  □ Handlers only parse requests and format responses
  □ No business logic (business conditionals, calculations) in handlers
  □ Format validation (required fields, data types) is allowed here — but not business rules
  □ HTTP status codes are chosen based on the error type from the service

APPLICATION LAYER:
  □ Services orchestrate use cases — not contain business logic
  □ Business logic lives in domain entities, not in services
  □ DTOs (Input/Output) are used for inter-layer communication
  □ Services depend on domain repository interfaces, not concrete implementations

DOMAIN LAYER:
  □ No imports from external libraries (database, HTTP, cache)
  □ Entities have methods enforcing business rules and invariants
  □ Repository interfaces are defined in the domain layer
  □ Entities use private fields with controlled public getters

INFRASTRUCTURE LAYER:
  □ Repository implementations use persistence models separate from domain entities
  □ Mappers exist: persistence model → domain entity and vice versa
  □ Compile-time interface check: var _ domain.Repo = (*ImplRepo)(nil)
  □ No business logic in repositories

DEPENDENCIES:
  □ No circular dependencies between layers
  □ No layer skipping to a layer that is not directly below (except documented relaxed layering)
  □ All dependencies to external systems go through interfaces

TESTING:
  □ Domain entities are tested purely without any dependencies
  □ Application services are tested with mock repositories
  □ Repositories are tested with integration tests
  □ go test -race is run regularly

Summary #

  • Layered Architecture is the most classic and most understandable pattern — four layers with clear responsibilities: presentation, application, domain, and infrastructure; dependencies always flow downward.
  • Each layer has one responsibility — handlers parse requests, services orchestrate, the domain contains business rules, infrastructure interacts with external technology; do not mix responsibilities.
  • Keep business logic in the domain layer — if validation and business calculations start creeping into services or handlers, that is a sign of an anemic domain model that needs immediate fixing.
  • Repository interfaces in the domain layer — the domain defines its needs through interfaces, infrastructure implements them; this allows the database to be swapped without touching the domain.
  • Separate the persistence model from the domain entity — ORM annotations and JSON tags must not exist in domain entities; use mappers for conversion between the two.
  • Thin handlers are a sign of architectural health — if a handler exceeds 30 lines, business logic that should be in a service or the domain has probably leaked into the presentation layer.
  • Strict layering prevents hidden coupling — do not let the presentation layer skip directly to infrastructure; every layer should go through the layer directly below it.
  • Layered Architecture is an entry point to more mature architectures — when business rules become more complex or testability becomes a priority, evolving to Clean or Hexagonal Architecture is easier if Layered was applied with discipline.
  • The anemic domain model is the most common anti-pattern — entities containing only getters/setters are a sign the architecture is not exploiting OOP’s power; the domain must contain behavior, not just data.
  • Great for teams new to architecture — the low learning curve and familiar structure help onboarding and consistency; once the team matures, consider evolving according to system needs.

← Previous: Onion   Next: Monolithic →

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