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:#1b4332Each layer has one clear, non-overlapping responsibility:
| Layer | Responsibility | Must Not |
|---|---|---|
| Presentation | Parse requests, validate formats, format responses | Business logic, direct database access |
| Application | Use case orchestration, transactions, inter-service coordination | HTTP details, JSON/XML formatting |
| Domain | Business rules, invariants, entity state machines | Database imports, HTTP frameworks |
| Infrastructure | Repository implementations, DB connections, external integrations | Business 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"]
endFor 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)"]
endWhen 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:
| Aspect | Layered Architecture | Clean Architecture |
|---|---|---|
| Dependency direction | Top-down (presentation → domain → infra) | Always inward (infra → adapters → use cases → entities) |
| Database position | Infrastructure layer, but the domain often couples to the DB | An implementation detail swappable at any time |
| Testability | Good if interfaces are used | Excellent — the domain needs no DB to be tested |
| Learning curve | Low — familiar to most developers | Higher — many layers and interfaces |
| Best for | Medium systems, teams new to architecture | Complex, long-lived systems |
| Overhead | Low — just 4 folders | Higher — many interfaces and mappings |
| Risk | Anemic domain model, database coupling | Over-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.