Design patterns (like Singleton, Factory, Strategy) are reusable solutions to small-scale design problems.

Architectural patterns, on the other hand, define the big picture of how systems are structured, how components interact, and how responsibilities are separated.

In this article, weโ€™ll explore four influential architectural patterns โ€” MVC, Hexagonal, CQRS, and Microservices โ€” and see how they apply to Go development.

Architectural Patterns in Go

๐Ÿ–ผ MVC (Modelโ€“Viewโ€“Controller)

The MVC pattern splits an application into three roles:

  • Model โ†’ data and business logic
  • View โ†’ presentation layer (UI, HTML templates, JSON responses)
  • Controller โ†’ handles input and orchestrates between Model and View

Goal: keep responsibilities crisp.

  • Model (Domain): business entities + rules (no framework details).

  • View: JSON/HTML returned to the client.

  • Controller: HTTP entrypoint; validates input, calls services, shapes output.

  • DTO (Data Transfer Object): API-facing structs (request/response). Shields the domain from external shape changes.

  • DAO/Repository: persistence port; hides database from the domain. (DAO is essentially a repository here.)

Why DTO + DAO with MVC?

  • DTOs prevent leaking internal domain fields (e.g., hashed passwords, internal IDs) and stabilize your public API.

  • DAO/Repository makes business logic testable (swap Postgres for in-memory in tests) and keeps SQL out of controllers.

Suggested backend layout (Go)

  • Domain has zero dependency on infra (DB/HTTP).

  • Services orchestrate use cases.

  • Controllers adapt HTTP โ†”๏ธ services and map DTO โ†”๏ธ Model.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.
โ”œโ”€ cmd/
โ”‚  โ””โ”€ server/
โ”‚     โ””โ”€ main.go                  # wire HTTP router, DI, config
โ”œโ”€ internal/
โ”‚  โ”œโ”€ app/
โ”‚  โ”‚  โ”œโ”€ http/
โ”‚  โ”‚  โ”‚  โ””โ”€ controllers/          # controllers (handlers) โ€” โ€œCโ€
โ”‚  โ”‚  โ””โ”€ services/                # application/services layer (use cases)
โ”‚  โ”œโ”€ domain/
โ”‚  โ”‚  โ””โ”€ user/                    # domain models & interfaces โ€” โ€œMโ€
โ”‚  โ”‚     โ”œโ”€ model.go
โ”‚  โ”‚     โ”œโ”€ repository.go         # DAO (port)
โ”‚  โ”‚     โ””โ”€ errors.go
โ”‚  โ”œโ”€ infra/
โ”‚  โ”‚  โ”œโ”€ db/                      # db bootstrapping (sqlx/gorm/pgx)
โ”‚  โ”‚  โ””โ”€ repository/              # DAO impls (adapters) โ€” Postgres/MySQL
โ”‚  โ”‚     โ””โ”€ user_pg.go
โ”‚  โ””โ”€ transport/
โ”‚     โ””โ”€ http/
โ”‚        โ”œโ”€ router.go             # gin/chi mux + routes
โ”‚        โ””โ”€ dto/                  # DTOs โ€” request/response
โ”‚           โ””โ”€ user.go
โ”œโ”€ pkg/
โ”‚  โ”œโ”€ logger/
โ”‚  โ””โ”€ validator/
โ””โ”€ go.mod

MVC Diagram.

flowchart LR
    subgraph Client["Client (Frontend/UI)"]
        A["HTTP Request\nJSON Payload"]
    end

    subgraph Transport["Transport Layer"]
        B["Controller\nGin/Chi Handler"]
        C["DTO\nRequest/Response"]
    end

    subgraph App["Application Layer"]
        D["Service\nBusiness Use Case"]
    end

    subgraph Domain["Domain Layer"]
        E["Domain Model\nEntities & Rules"]
        F["Repository Interface\n(DAO Port)"]
    end

    subgraph Infra["Infrastructure Layer"]
        G["Repository Impl\nPostgres Adapter"]
        H[("Database\nPostgres")]
    end

    A -->|HTTP JSON| B
    B -->|Map to DTO| C
    C -->|Pass Valid Data| D
    D -->|Use Entities| E
    D -->|Call Port| F
    F -->|Implemented by| G
    G -->|SQL Queries| H
    H -->|Result Rows| G
    G -->|Return Entities| F
    F -->|Back to| D
    D -->|Domain โ†’ DTO| C
    C -->|JSON Response| B
    B -->|HTTP Response| A

๐Ÿงฉ Domain Model (M)

File: internal/domain/user/model.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package user

import "time"

type ID string

type User struct {
	ID        ID
	Name      string
	Email     string
	Active    bool
	CreatedAt time.Time
}

// Domain invariants/constructors keep the model valid.
func New(name, email string) (User, error) {
	if name == "" {
		return User{}, ErrInvalidName
	}
	if !isValidEmail(email) {
		return User{}, ErrInvalidEmail
	}
	return User{
		ID:     ID(NewID()),
		Name:   name,
		Email:  email,
		Active: true,
		// CreatedAt set in service or repo
	}, nil
}

File: internal/domain/user/repository.go (DAO Port)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package user

import "context"

type Repository interface {
	Create(ctx context.Context, u User) error
	ByID(ctx context.Context, id ID) (User, error)
	ByEmail(ctx context.Context, email string) (User, error)
	List(ctx context.Context, limit, offset int) ([]User, error)
	Update(ctx context.Context, u User) error
	Delete(ctx context.Context, id ID) error
}

๐Ÿงฐ DAO Implementation (Adapter)

File: internal/infra/repository/user_pg.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package repository

import (
	"context"
	"database/sql"
	"errors"
	"time"

	domain "yourapp/internal/domain/user"
)

type UserPG struct {
	db *sql.DB
}

func NewUserPG(db *sql.DB) *UserPG { return &UserPG{db: db} }

func (r *UserPG) Create(ctx context.Context, u domain.User) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO users (id, name, email, active, created_at)
         VALUES ($1,$2,$3,$4,$5)`,
		u.ID, u.Name, u.Email, u.Active, time.Now().UTC(),
	)
	return err
}

func (r *UserPG) ByID(ctx context.Context, id domain.ID) (domain.User, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT id, name, email, active, created_at FROM users WHERE id=$1`, id)
	var u domain.User
	if err := row.Scan(&u.ID, &u.Name, &u.Email, &u.Active, &u.CreatedAt); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.User{}, domain.ErrNotFound
		}
		return domain.User{}, err
	}
	return u, nil
}

// ... ByEmail, List, Update, Delete similarly

๐Ÿง  Application Service (Use Case Layer)

  • Services speak domain and depend on ports (interfaces).

  • Theyโ€™re trivial to unit-test with a fake repository.

File: internal/app/services/user_service.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package services

import (
	"context"
	"time"

	domain "yourapp/internal/domain/user"
)

type UserService struct {
	repo domain.Repository
}

func NewUserService(repo domain.Repository) *UserService {
	return &UserService{repo: repo}
}

func (s *UserService) Register(ctx context.Context, name, email string) (domain.User, error) {
	u, err := domain.New(name, email)
	if err != nil {
		return domain.User{}, err
	}
	// set creation time here if not in repo
	u.CreatedAt = time.Now().UTC()
	if err := s.repo.Create(ctx, u); err != nil {
		return domain.User{}, err
	}
	return u, nil
}

func (s *UserService) Get(ctx context.Context, id domain.ID) (domain.User, error) {
	return s.repo.ByID(ctx, id)
}

๐Ÿ“ฆ DTOs (Requests/Responses)

  • Keep validation on DTOs (with validator).

  • Mapping functions isolate domain โ†”๏ธ transport conversion.

File: internal/transport/http/dto/user.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package dto

import (
	domain "yourapp/internal/domain/user"
)

type CreateUserRequest struct {
	Name  string `json:"name" validate:"required,min=2"`
	Email string `json:"email" validate:"required,email"`
}

type UserResponse struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Email  string `json:"email"`
	Active bool   `json:"active"`
}

func ToUserResponse(u domain.User) UserResponse {
	return UserResponse{
		ID:     string(u.ID),
		Name:   u.Name,
		Email:  u.Email,
		Active: u.Active,
	}
}

๐ŸŽฎ Controller (C) โ€” HTTP Handlers

File: internal/app/http/controllers/user_controller.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package controllers

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"yourapp/internal/app/services"
	"yourapp/internal/transport/http/dto"
	domain "yourapp/internal/domain/user"
)

type UserController struct {
	svc *services.UserService
}

func NewUserController(svc *services.UserService) *UserController {
	return &UserController{svc: svc}
}

func (uc *UserController) Register(c *gin.Context) {
	var req dto.CreateUserRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_payload"})
		return
	}
	// optional: validate req with pkg/validator

	user, err := uc.svc.Register(c.Request.Context(), req.Name, req.Email)
	if err != nil {
		switch err {
		case domain.ErrInvalidEmail, domain.ErrInvalidName:
			c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
		default:
			c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"})
		}
		return
	}
	c.JSON(http.StatusCreated, dto.ToUserResponse(user))
}

func (uc *UserController) Get(c *gin.Context) {
	id := domain.ID(c.Param("id"))
	user, err := uc.svc.Get(c.Request.Context(), id)
	if err != nil {
		if err == domain.ErrNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"})
		return
	}
	c.JSON(http.StatusOK, dto.ToUserResponse(user))
}

File: internal/transport/http/router.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package http

import (
	"github.com/gin-gonic/gin"
	"yourapp/internal/app/http/controllers"
)

func NewRouter(userCtrl *controllers.UserController) *gin.Engine {
	r := gin.New()
	r.Use(gin.Recovery())

	v1 := r.Group("/v1")
	{
		v1.POST("/users", userCtrl.Register)
		v1.GET("/users/:id", userCtrl.Get)
	}
	return r
}

๐Ÿš€ Wiring (main)

File: cmd/server/main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
	"database/sql"
	"log"
	"net/http"

	_ "github.com/lib/pq"

	"yourapp/internal/app/http/controllers"
	"yourapp/internal/app/services"
	"yourapp/internal/infra/repository"
	transport "yourapp/internal/transport/http"
)

func main() {
	db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/app?sslmode=disable")
	if err != nil { log.Fatal(err) }

	userRepo := repository.NewUserPG(db)          // DAO impl
	userSvc  := services.NewUserService(userRepo) // Service
	userCtrl := controllers.NewUserController(userSvc)

	router := transport.NewRouter(userCtrl)
	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", router))
}

โœ… Checklist & Tips

  • Controller: tiny; only HTTP + mapping + error codes.

  • DTO: versioned (e.g., /v1), validated, stable to external changes.

  • Service: business orchestration; no HTTP/SQL.

  • Domain: invariants, pure logic; no frameworks.

  • DAO/Repository: concrete DB code; easily mocked.

  • Testing: unit test services with in-memory repo; integration test DAO with a test DB.

  • Errors: map domain errors to HTTP status codes in controllers.

  • Versioning: keep DTOs under transport/http/dto/v1 if you plan multiple API versions.

โœ… When to use:

  • Web applications with clear input/output flows

  • Great for monolithic Go services

  • โš ๏ธ Pitfall: Controllers can easily become โ€œfatโ€ if not managed well.


๐Ÿ›ก Hexagonal Architecture (Ports & Adapters)

Idea: keep your domain core pure and push frameworks, DBs, and transports to the edges.

  • Domain (core): entities, value objects, domain services, errors.

  • Ports: interfaces the core depends on (e.g., UserRepository, Mailer).

  • Adapters: implementations for ports (Postgres, Redis, SMTP, HTTP clients).

  • Drivers: incoming adapters (HTTP/gRPC/CLI/Jobs) that call the core.

Suggested layout (Go)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.
โ”œโ”€ cmd/api/                         # app entrypoint(s)
โ”‚  โ””โ”€ main.go
โ”œโ”€ internal/
โ”‚  โ”œโ”€ domain/                       # PURE core (no imports of gin/sql/http)
โ”‚  โ”‚  โ””โ”€ user/
โ”‚  โ”‚     โ”œโ”€ entity.go               # entities/value objects
โ”‚  โ”‚     โ”œโ”€ service.go              # domain services (pure)
โ”‚  โ”‚     โ”œโ”€ ports.go                # ports (interfaces) e.g., UserRepo, Mailer
โ”‚  โ”‚     โ””โ”€ errors.go
โ”‚  โ”œโ”€ app/                          # use-cases/application services
โ”‚  โ”‚  โ””โ”€ user/
โ”‚  โ”‚     โ””โ”€ usecase.go              # RegisterUser, ActivateUser, etc.
โ”‚  โ”œโ”€ adapters/
โ”‚  โ”‚  โ”œโ”€ in/                        # driving adapters
โ”‚  โ”‚  โ”‚  โ””โ”€ http/                   # HTTP handlers (gin/chi)
โ”‚  โ”‚  โ”‚     โ”œโ”€ router.go
โ”‚  โ”‚  โ”‚     โ””โ”€ user_controller.go
โ”‚  โ”‚  โ””โ”€ out/                       # driven adapters
โ”‚  โ”‚     โ”œโ”€ postgres/
โ”‚  โ”‚     โ”‚  โ””โ”€ user_repo.go         # implements domain.UserRepository
โ”‚  โ”‚     โ””โ”€ mail/
โ”‚  โ”‚        โ””โ”€ smtp_mailer.go       # implements domain.Mailer
โ”‚  โ””โ”€ platform/                     # cross-cutting infra (db, config, log)
โ”‚     โ”œโ”€ db.go
โ”‚     โ”œโ”€ config.go
โ”‚     โ””โ”€ logger.go
โ””โ”€ go.mod

Minimal Go example

File: internal/domain/user/ports.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package user

import "context"

type Repository interface {
	Save(ctx context.Context, u User) error
	ByID(ctx context.Context, id ID) (User, error)
}

type Mailer interface {
	SendWelcome(ctx context.Context, email string) error
}

File: internal/app/user/usecase.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package userapp

import (
	"context"

	domain "yourapp/internal/domain/user"
)

type RegisterUser struct {
	Repo   domain.Repository
	Mailer domain.Mailer
}

func (uc RegisterUser) Do(ctx context.Context, name, email string) (domain.User, error) {
	u, err := domain.New(name, email)
	if err != nil {
		return domain.User{}, err
	}
	if err := uc.Repo.Save(ctx, u); err != nil {
		return domain.User{}, err
	}
	_ = uc.Mailer.SendWelcome(ctx, u.Email) // best-effort, log on failure
	return u, nil
}

Hexagonal Diagram

flowchart LR
    subgraph Drivers["Drivers (Incoming Adapters)"]
        A["HTTP\n(gin/chi)"]
        B["CLI\nCron/Jobs"]
        C["gRPC\nGateway"]
    end

    subgraph Core["Core (Domain + Application)"]
        D["Application Services\nUse Cases"]
        E["Domain Entities\nValue Objects\nDomain Services"]
        F["Ports\n(Repo, Mailer, Cache)"]
    end

    subgraph Adapters["Driven Adapters (Infra)"]
        G["Postgres Repo\nimplements Repo"]
        H["SMTP Mailer\nimplements Mailer"]
        I["Redis Cache\nimplements Cache"]
    end

    A --> D
    B --> D
    C --> D
    D --> E
    D --> F
    F --> G
    F --> H
    F --> I

โœ… Checklist & Tips

  • Keep internal/domain import-clean (no framework/DB imports).

  • Define ports in the domain; implement them in adapters.

  • Tests: unit-test use-cases with fakes for ports; integration-test adapters.

โœ… When to use:

  • Systems with high read/write load

  • Event-sourced systems (CQRS often pairs with Event Sourcing)

  • โš ๏ธ Pitfall: Adds complexity โ€” not always worth it for simple apps.


๐Ÿ”€ CQRS (Command Query Responsibility Segregation)

CQRS separates read and write responsibilities into different models:

Commands โ†’ update state (writes)

Queries โ†’ read state (reads)

This avoids having one bloated model handling both responsibilities.

๐Ÿ“ Go Example:

1
2
3
4
5
6
7
8
9
// Command Handler
func CreateUser(repo UserRepository, user User) error {
    return repo.Save(user)
}

// Query Handler
func GetUser(repo UserRepository, id int) (User, error) {
    return repo.Find(id)
}

CQRS Diagram

flowchart LR
    subgraph API["API Layer"]
        A["HTTP Endpoints\n/commands\n/queries"]
    end

    subgraph WriteSide["Write Side"]
        B["Command Handler\n(Validate + Execute)"]
        C["Write Model\n(Domain + Repo)"]
        D[("Write DB")]
    end

    subgraph ReadSide["Read Side"]
        E["Projector\n(Update Projections)"]
        F["Read Model\n(DTO Repository)"]
        G[("Read DB")]
        H["Query Handler\n(Reads Only)"]
    end

    subgraph Bus["Event Bus (Optional)"]
        X["Domain Events"]
    end

    %% Command path
    A -->|POST /commands| B
    B --> C
    C --> D
    C -->|Emit Events| X

    %% Projection path
    X --> E
    E --> F
    F --> G

    %% Query path
    A -->|GET /queries| H
    H --> F

    %% Notes
    classDef dim fill:#f7f7f7,stroke:#bbb,color:#333
    class API,WriteSide,ReadSide,Bus dim

โœ… When to use:

  • Systems with high read/write load

  • Event-sourced systems (CQRS often pairs with Event Sourcing)

  • โš ๏ธ Pitfall: Adds complexity โ€” not always worth it for simple apps.


โ˜๏ธ Microservices

A Microservices architecture structures applications as a collection of small, independent services:

  • Each service owns its data and logic

  • Services communicate via APIs (HTTP, gRPC, messaging)

  • Each can be deployed and scaled independently

Idea: split a system into small, independently deployable services that each own their data and domain.

Services communicate via synchronous (HTTP/gRPC) or asynchronous (Kafka/NATS) channels.

Each service has its own database (no shared schema).

Requires solid platform engineering: CI/CD, observability, API governance, SLOs.

Suggested repo layout (mono-repo)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
.
โ”œโ”€ services/
โ”‚  โ”œโ”€ usersvc/
โ”‚  โ”‚  โ”œโ”€ cmd/usersvc/main.go
โ”‚  โ”‚  โ”œโ”€ internal/...
โ”‚  โ”‚  โ””โ”€ api/openapi.yaml
โ”‚  โ”œโ”€ ordersvc/
โ”‚  โ”‚  โ”œโ”€ cmd/ordersvc/main.go
โ”‚  โ”‚  โ”œโ”€ internal/...
โ”‚  โ”‚  โ””โ”€ api/openapi.yaml
โ”‚  โ””โ”€ paymentsvc/
โ”‚     โ”œโ”€ cmd/paymentsvc/main.go
โ”‚     โ”œโ”€ internal/...
โ”‚     โ””โ”€ api/openapi.yaml
โ”œโ”€ pkg/                         # shared libs (careful: avoid domain leakage)
โ”‚  โ”œโ”€ logger/
โ”‚  โ”œโ”€ tracing/
โ”‚  โ””โ”€ httpx/
โ”œโ”€ deploy/
โ”‚  โ”œโ”€ k8s/                      # Helm/Manifests
โ”‚  โ””โ”€ infra/                    # Terraform (DBs, queues, buckets)
โ””โ”€ Makefile / Taskfile.yaml

Minimal service skeleton (Gin)

File: services/usersvc/cmd/usersvc/main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.New()
	r.Use(gin.Recovery())

	r.GET("/v1/users/:id", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"id": c.Param("id"), "name": "Alice"})
	})

	log.Println("usersvc listening on :8081")
	log.Fatal(http.ListenAndServe(":8081", r))
}

Microservice Diagram

flowchart LR
    subgraph Clients["Clients"]
        A["Web\nMobile"]
    end

    subgraph Edge["API Gateway\nIngress"]
        B["Routing\nAuth\nRate Limit"]
    end

    subgraph Services["Microservices"]
        U["User Service\nDB owned by service"]
        O["Order Service\nDB owned by service"]
        P["Payment Service\nDB owned by service"]
    end

    subgraph Async["Async Messaging"]
        K["Kafka/NATS\nTopics"]
    end

    A --> B
    B --> U
    B --> O
    B --> P

    O <-->|Events| K
    P <-->|Events| K
    U <-->|Events| K

โœ… Checklist & Tips

  • Start with modular monolith โ†’ extract services when boundaries stabilize.

  • Each service: own DB schema, own CI, own versioning.

  • Invest early in observability (traces, logs, metrics) and API contracts.

โœ… When to use:

  • Large, complex systems needing scalability

  • Teams working on independent modules

  • โš ๏ธ Pitfall: Operational overhead (DevOps, CI/CD, observability, networking).


๐Ÿ”š Wrap-up pointers

  • Hexagonal: best baseline for testability and longevity; add adapters as you go.

  • CQRS: apply where read/write divergence brings value; donโ€™t over-split prematurely.

  • Microservices: only when team size, domain boundaries, and scaling needs justify the operational cost.


๐Ÿง  Summary

  • MVC โ†’ Clear separation of concerns in monolithic apps

  • Hexagonal โ†’ Isolate core logic, improve testability

  • CQRS โ†’ Split reads and writes for clarity and scalability

  • Microservices โ†’ Independent, scalable services for large systems

๐Ÿ‘‰ Think of it this way:

  • Design patterns = small tools (Singleton, Observer, Strategy)

  • Architectural patterns = the blueprint of the entire building

  • Both are essential, but at different levels of abstraction.


๐Ÿš€ Follow me on norbix.dev for more insights on Go, Python, AI, system design, and engineering wisdom.