MVC Architecture #

If there is one architectural pattern that can claim to be the most widely implemented in the history of software engineering, the answer is almost certainly MVC. From Smalltalk in the 1970s, through Ruby on Rails which popularized it on the web, to Flutter and SwiftUI adapting its concepts for mobile — the idea behind MVC has remained the same: separate three different responsibilities when building an application interface. The Model handles data and business logic. The View handles presentation. The Controller handles the flow from user input to displayed output. Three words, three responsibilities, one rule: do not mix them. In Go, MVC often emerges naturally even without a special framework — handler is the Controller, template or json.Marshal is the View, and a struct with business logic is the Model. Understanding MVC well means understanding why this separation exists and what happens when it is violated.

The Three Components and Their Responsibilities #

flowchart LR
    U([User])

    subgraph MVC["MVC Architecture"]
        V["View\\n(Presentation)"]
        C["Controller\\n(Coordinator)"]
        M["Model\\n(Data & Logic)"]
    end

    DB[(Database)]

    U -->|"input (click, form submit)"| C
    C -->|"query data"| M
    M -->|"read/write"| DB
    DB -->|"data"| M
    M -->|"domain object / DTO"| C
    C -->|"choose view + send data"| V
    V -->|"render the display"| U
ComponentResponsibilityMust Not
ModelData, business rules, validation, database accessKnow about the view or HTTP
ViewRender output (HTML, JSON, XML)Contain business logic or DB access
ControllerParse input, coordinate Model and ViewHeavy business logic, direct DB access

The main rule that must never be violated: data flows one way through the Controller. The View must not call the Model directly, and the Model must not know which View will display its data.


The Request-Response Flow in Go #

In Go without a framework, MVC components map to concepts that already exist natively:

sequenceDiagram
    participant Browser
    participant Router as Router/Mux
    participant Controller as Controller (Handler)
    participant Model as Model (Service + Domain)
    participant View as View (Template/JSON)
    participant DB as Database

    Browser->>Router: GET /articles/42
    Router->>Controller: ArticleController.Show(w, r)
    Controller->>Model: articleService.FindByID(ctx, "42")
    Model->>DB: SELECT * FROM articles WHERE id = 42
    DB-->>Model: row data
    Model-->>Controller: *Article domain object
    Controller->>View: tmpl.Execute(w, article)
    View-->>Browser: HTML response

    Browser->>Router: POST /articles
    Router->>Controller: ArticleController.Create(w, r)
    Controller->>Controller: parse + validate request body
    Controller->>Model: articleService.Create(ctx, input)
    Model->>Model: domain validation
    Model->>DB: INSERT INTO articles
    DB-->>Model: ok
    Model-->>Controller: *Article
    Controller->>Browser: 201 Created + JSON

Go Implementation: Server-Side Rendering #

The first implementation is classic MVC with HTML templates — like traditional web applications use.

// model/article.go — Model: domain entity + business rules
package model

import (
	"context"
	"errors"
	"strings"
	"time"
)

type Article struct {
	ID          int64
	Title       string
	Body        string
	AuthorID    int64
	PublishedAt *time.Time
	CreatedAt   time.Time
}

// Publish publishes the article — business rules live inside the model
func (a *Article) Publish() error {
	if strings.TrimSpace(a.Title) == "" {
		return errors.New("article title must not be empty")
	}
	if strings.TrimSpace(a.Body) == "" {
		return errors.New("article body must not be empty")
	}
	if a.PublishedAt != nil {
		return errors.New("article is already published")
	}
	now := time.Now()
	a.PublishedAt = &now
	return nil
}

func (a *Article) IsPublished() bool {
	return a.PublishedAt != nil
}

// ArticleRepository is the interface for data access
type ArticleRepository interface {
	FindByID(ctx context.Context, id int64) (*Article, error)
	FindAll(ctx context.Context) ([]*Article, error)
	FindPublished(ctx context.Context) ([]*Article, error)
	Save(ctx context.Context, article *Article) error
	Delete(ctx context.Context, id int64) error
}
// service/article_service.go — Service Layer (part of the Model)
// Orchestrates use cases using domain entities
package service

import (
	"context"
	"errors"

	"myapp/model"
)

// ArticleService is the application service — part of the "M" in MVC
type ArticleService struct {
	repo model.ArticleRepository
}

func NewArticleService(repo model.ArticleRepository) *ArticleService {
	return &ArticleService{repo: repo}
}

type CreateArticleInput struct {
	Title    string
	Body     string
	AuthorID int64
	Publish  bool
}

func (s *ArticleService) Create(ctx context.Context, input CreateArticleInput) (*model.Article, error) {
	article := &model.Article{
		Title:    input.Title,
		Body:     input.Body,
		AuthorID: input.AuthorID,
	}

	if input.Publish {
		if err := article.Publish(); err != nil {
			return nil, err
		}
	}

	if err := s.repo.Save(ctx, article); err != nil {
		return nil, err
	}
	return article, nil
}

func (s *ArticleService) FindByID(ctx context.Context, id int64) (*model.Article, error) {
	if id <= 0 {
		return nil, errors.New("invalid ID")
	}
	return s.repo.FindByID(ctx, id)
}

func (s *ArticleService) FindPublished(ctx context.Context) ([]*model.Article, error) {
	return s.repo.FindPublished(ctx)
}

func (s *ArticleService) Publish(ctx context.Context, id int64) (*model.Article, error) {
	article, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if err := article.Publish(); err != nil {
		return nil, err
	}
	if err := s.repo.Save(ctx, article); err != nil {
		return nil, err
	}
	return article, nil
}
// controller/article_controller.go — Controller: coordinator between Model and View
package controller

import (
	"html/template"
	"net/http"
	"strconv"

	"myapp/service"
)

// ArticleController handles all HTTP requests related to articles
// ✓ Thin — only parses input, delegates to the service, renders views
type ArticleController struct {
	articleSvc *service.ArticleService
	templates  *template.Template
}

func NewArticleController(svc *service.ArticleService, tmpl *template.Template) *ArticleController {
	return &ArticleController{articleSvc: svc, templates: tmpl}
}

// Index displays all published articles
func (c *ArticleController) Index(w http.ResponseWriter, r *http.Request) {
	articles, err := c.articleSvc.FindPublished(r.Context())
	if err != nil {
		http.Error(w, "failed to fetch articles", http.StatusInternalServerError)
		return
	}

	// Render the view with data from the model
	if err := c.templates.ExecuteTemplate(w, "articles/index.html", map[string]interface{}{
		"Articles": articles,
		"Title":    "All Articles",
	}); err != nil {
		http.Error(w, "failed to render page", http.StatusInternalServerError)
	}
}

// Show displays one article by ID
func (c *ArticleController) Show(w http.ResponseWriter, r *http.Request) {
	// Parse the ID from the URL path
	idStr := r.PathValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		http.Error(w, "invalid ID", http.StatusBadRequest)
		return
	}

	article, err := c.articleSvc.FindByID(r.Context(), id)
	if err != nil {
		http.Error(w, "article not found", http.StatusNotFound)
		return
	}

	c.templates.ExecuteTemplate(w, "articles/show.html", map[string]interface{}{
		"Article": article,
		"Title":   article.Title,
	})
}

// New displays the form for creating a new article
func (c *ArticleController) New(w http.ResponseWriter, r *http.Request) {
	c.templates.ExecuteTemplate(w, "articles/new.html", map[string]interface{}{
		"Title": "New Article",
	})
}

// Create processes the form submission to create an article
func (c *ArticleController) Create(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid form", http.StatusBadRequest)
		return
	}

	// Parse input from the form
	title := r.FormValue("title")
	body := r.FormValue("body")
	publish := r.FormValue("publish") == "true"

	// Minimal input validation in the controller (format, not business rules)
	if title == "" || body == "" {
		c.templates.ExecuteTemplate(w, "articles/new.html", map[string]interface{}{
			"Title":  "New Article",
			"Error":  "Title and body are required",
			"Values": map[string]string{"title": title, "body": body},
		})
		return
	}

	// Delegate to the service — business validation lives there
	article, err := c.articleSvc.Create(r.Context(), service.CreateArticleInput{
		Title:    title,
		Body:     body,
		AuthorID: getUserIDFromSession(r),
		Publish:  publish,
	})
	if err != nil {
		c.templates.ExecuteTemplate(w, "articles/new.html", map[string]interface{}{
			"Title":  "New Article",
			"Error":  err.Error(),
			"Values": map[string]string{"title": title, "body": body},
		})
		return
	}

	// Redirect to the newly created article page
	http.Redirect(w, r, "/articles/"+strconv.FormatInt(article.ID, 10), http.StatusSeeOther)
}
// router/router.go — routing connecting URLs to Controller methods
package router

import (
	"net/http"

	"myapp/controller"
)

func Setup(articleCtrl *controller.ArticleController) http.Handler {
	mux := http.NewServeMux()

	// Article routes
	mux.HandleFunc("GET /articles", articleCtrl.Index)
	mux.HandleFunc("GET /articles/new", articleCtrl.New)
	mux.HandleFunc("POST /articles", articleCtrl.Create)
	mux.HandleFunc("GET /articles/{id}", articleCtrl.Show)

	return mux
}

Go Implementation: JSON API (MVC without View Templates) #

In API-only applications, the “View” is replaced by JSON serialization. The Controller still determines the output format:

// controller/api/article_api_controller.go — Controller for the JSON API
package api

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

	"myapp/service"
)

// ArticleResponse is the View Model — a DTO optimized for JSON responses
// ✓ Different from the domain entity — only fields relevant to the client
type ArticleResponse struct {
	ID          int64      `json:"id"`
	Title       string     `json:"title"`
	Body        string     `json:"body"`
	IsPublished bool       `json:"is_published"`
	PublishedAt *time.Time `json:"published_at,omitempty"`
	CreatedAt   time.Time  `json:"created_at"`
}

// ArticleListResponse is the wrapper for list responses
type ArticleListResponse struct {
	Data  []*ArticleResponse `json:"data"`
	Total int                `json:"total"`
}

type ArticleAPIController struct {
	articleSvc *service.ArticleService
}

func NewArticleAPIController(svc *service.ArticleService) *ArticleAPIController {
	return &ArticleAPIController{articleSvc: svc}
}

func (c *ArticleAPIController) Index(w http.ResponseWriter, r *http.Request) {
	articles, err := c.articleSvc.FindPublished(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "failed to fetch articles")
		return
	}

	// Map domain objects to View Models (response DTOs)
	responses := make([]*ArticleResponse, len(articles))
	for i, a := range articles {
		responses[i] = toArticleResponse(a)
	}

	writeJSON(w, http.StatusOK, ArticleListResponse{
		Data:  responses,
		Total: len(responses),
	})
}

func (c *ArticleAPIController) Show(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid ID")
		return
	}

	article, err := c.articleSvc.FindByID(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "article not found")
		return
	}

	writeJSON(w, http.StatusOK, toArticleResponse(article))
}

type CreateArticleRequestBody struct {
	Title   string `json:"title"`
	Body    string `json:"body"`
	Publish bool   `json:"publish"`
}

func (c *ArticleAPIController) Create(w http.ResponseWriter, r *http.Request) {
	var req CreateArticleRequestBody
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}

	// Minimal format validation in the controller
	if req.Title == "" {
		writeError(w, http.StatusBadRequest, "title is required")
		return
	}

	article, err := c.articleSvc.Create(r.Context(), service.CreateArticleInput{
		Title:    req.Title,
		Body:     req.Body,
		AuthorID: getAuthorIDFromToken(r),
		Publish:  req.Publish,
	})
	if err != nil {
		writeError(w, http.StatusUnprocessableEntity, err.Error())
		return
	}

	writeJSON(w, http.StatusCreated, toArticleResponse(article))
}

// Helper functions — the "View" in a JSON API
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	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})
}

Avoiding the Fat Controller with a Service Layer #

The Fat Controller is the most common MVC anti-pattern — when all business logic accumulates in the Controller because it seems “easy”. The solution is a Service Layer:

flowchart LR
    subgraph FAT["❌ Fat Controller — Logic Piling Up"]
        FC["Controller\\n• Parse HTTP\\n• Validate business rules\\n• Query database\\n• Calculate discounts\\n• Send emails\\n• Format responses"]
    end

    subgraph THIN["✓ Thin Controller + Service Layer"]
        TC["Controller\\n• Parse HTTP\\n• Validate format\\n• Call the service\\n• Format responses"]
        SVC["Service\\n• Validate business rules\\n• Coordinate the domain\\n• Send emails\\n• Return results"]
        DOM["Model/Domain\\n• Core business rules\\n• Invariants\\n• State machine"]
        TC --> SVC --> DOM
    end
// ✗ Fat Controller — business logic in the handler
func (c *ArticleController) Publish(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)

	// ✗ Business logic in the controller
	article, _ := c.db.QueryRow("SELECT * FROM articles WHERE id = $1", id)

	if article.PublishedAt != nil { // ✗ business rule in the controller
		http.Error(w, "article is already published", 400)
		return
	}
	if article.Title == "" { // ✗ business validation in the controller
		http.Error(w, "title is missing", 400)
		return
	}

	now := time.Now()
	article.PublishedAt = &now
	c.db.Exec("UPDATE articles SET published_at = $1 WHERE id = $2", now, id)

	// ✗ side effect (email) in the controller
	c.mailer.Send(article.AuthorEmail, "Article Published", "...")

	writeJSON(w, 200, article)
}

// ✓ Thin Controller — delegate to the service
func (c *ArticleController) Publish(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeError(w, 400, "invalid ID") // ✓ only format validation
		return
	}

	// ✓ delegate all business logic to the service
	article, err := c.articleSvc.Publish(r.Context(), id)
	if err != nil {
		writeError(w, 422, err.Error())
		return
	}

	writeJSON(w, 200, toArticleResponse(article))
}

Modern MVC Variation: Separate API + Frontend #

Classic MVC does server-side rendering — the Controller produces HTML sent directly to the browser. In modern architecture, the “View” is often moved to the frontend (React, Vue, Flutter):

flowchart LR
    subgraph CLASSIC["Classic MVC\\n(Server-Side Rendering)"]
        B1["Browser"] --> C1["Controller"]
        C1 --> M1["Model"]
        M1 --> C1
        C1 --> V1["View (HTML Template)"]
        V1 --> B1
    end

    subgraph MODERN["Modern MVC\\n(API + SPA)"]
        FE["Frontend\\n(React/Vue/Flutter)"] -->|"HTTP API call"| C2["Controller\\n(API Endpoint)"]
        C2 --> M2["Model"]
        M2 --> C2
        C2 -->|"JSON response"| FE
        FE -->|"render"| U2["User"]
        Note["View = Frontend App\\nController = REST API Handler\\nModel = Backend Service + Domain"]
    end

In Go, the difference is minimal on the backend side — the Controller is still responsible for receiving requests and returning output, only the output is JSON instead of HTML.


Directory Structure #

myapp/
├── model/                      ← Model: domain entities
│   ├── article.go              ← Article entity + business rules
│   ├── user.go
│   └── repository.go          ← Repository interfaces
│
├── service/                    ← Service Layer (part of the Model)
│   ├── article_service.go      ← Application logic
│   └── user_service.go
│
├── controller/                 ← Controller: HTTP handlers
│   ├── article_controller.go   ← Server-side rendering
│   └── api/
│       └── article_api_controller.go ← JSON API
│
├── view/                       ← View: HTML templates
│   └── templates/
│       └── articles/
│           ├── index.html
│           ├── show.html
│           └── new.html
│
├── router/                     ← Route registration
│   └── router.go
│
├── infrastructure/             ← Repository implementations
│   └── postgres/
│       └── article_repo.go
│
└── cmd/
    └── main.go                 ← Wiring all components

MVC vs Layered vs Clean Architecture #

MVC is a pattern at the presentation layer level — it answers how HTTP requests are processed and responses generated, but not how the domain and infrastructure are organized. This often causes confusion:

AspectMVCLayered ArchitectureClean Architecture
LevelPresentation patternThe whole applicationThe whole application
AnswersHow UI and requests are organizedHow layers are separatedHow dependencies are arranged
CombinableYes — MVC in presentation, layered insideYesYes
Best forWeb apps, simple APIsMedium systemsComplex, long-lived systems
DatabaseNot addressedIn the infrastructure layerAn implementation detail

MVC and Layered Architecture can be combined: Controller = Presentation Layer, Service = Application Layer, Repository = Infrastructure Layer.


Anti-Patterns to Avoid #

// ✗ God Controller — one controller for everything
type AppController struct{}

func (c *AppController) HandleEverything(w http.ResponseWriter, r *http.Request) {
	// ✗ switch on the path for all endpoints
	switch r.URL.Path {
	case "/users":
		// handle users...
	case "/articles":
		// handle articles + business logic here
	case "/payments":
		// handle payments + calculations here
	}
}

// ✓ One controller per domain
type UserController struct{ userSvc *service.UserService }
type ArticleController struct{ articleSvc *service.ArticleService }
type PaymentController struct{ paymentSvc *service.PaymentService }

// ✗ Business logic in the View (template)
// articles/show.html
// {{ if gt (len .Article.Body) 100 }} ... {{ end }} — business rule in the template

// ✓ Calculate in the controller/service, send a flag to the view
// Controller:
// data["IsLongArticle"] = len(article.Body) > 100
// Template:
// {{ if .IsLongArticle }} ... {{ end }}

// ✗ View accessing the database directly (Active Record anti-pattern in templates)
// articles/show.html
// {{ range .Article.GetComments }} ... {{ end }}
// — GetComments() in the model runs DB queries while the template renders

// ✓ The controller fetches all needed data before rendering
func (c *ArticleController) Show(w http.ResponseWriter, r *http.Request) {
	article, _ := c.articleSvc.FindByID(r.Context(), id)
	comments, _ := c.commentSvc.FindByArticle(r.Context(), id) // ✓ fetch in the controller
	c.templates.ExecuteTemplate(w, "articles/show.html", map[string]interface{}{
		"Article":  article,
		"Comments": comments, // ✓ all data ready, the template only renders
	})
}

// ✗ The Model knowing about HTTP or the View
type Article struct{}

func (a *Article) ToHTML() string { // ✗ the Model must not know about HTML
	return "<h1>" + a.Title + "</h1>"
}

func (a *Article) GetHTTPStatusCode() int { // ✗ the Model must not know about HTTP
	if a.IsPublished() {
		return 200
	}
	return 404
}

// ✓ The Model only contains data and business rules
type Article struct {
	Title string
	Body  string
}

func (a *Article) IsPublished() bool { return a.PublishedAt != nil } // ✓
func (a *Article) Publish() error    { /* business rule */ return nil } // ✓

MVC Architecture Review Checklist #

MODEL:
  □ The Model has no imports from net/http or html/template
  □ Business rules and validation live in the model, not the controller
  □ Repository interfaces are defined in the model package
  □ The Model does not know which View will display its data

CONTROLLER:
  □ Controllers contain no complex business logic
  □ Controllers do not access the database directly
  □ Format validation (data types, required fields) is allowed in controllers
  □ Business validation is delegated to the service or model
  □ No "God Controller" — one controller per domain

VIEW:
  □ Templates contain no business logic
  □ Templates do not run queries or API calls
  □ All needed data is prepared by the controller before rendering
  □ Data formatting and presentation may live in template helpers

SERVICE LAYER:
  □ A service layer exists for complex business logic
  □ Controllers call services, not repositories directly
  □ Services use domain models, not HTTP structs

ROUTING:
  □ Routes are clearly defined, not ambiguous
  □ HTTP methods match their semantics (GET does not change state)
  □ Middleware (auth, logging) is separated from business logic

TESTING:
  □ The Model can be tested without HTTP or a database
  □ Services can be tested with mock repositories
  □ Controllers can be tested with a test HTTP server and mock services

Summary #

  • MVC separates three different responsibilities — the Model manages data and business rules, the View displays output, the Controller coordinates the two; mixing them is the biggest source of problems.
  • Controllers must be thin — parse requests, minimal format validation, call the service/model, render views; business logic in a controller is a sign of a “Fat Controller” that must be fixed immediately.
  • The Service Layer is the solution to the Fat Controller — when business logic is too complex for a domain entity and too heavy for a controller, the service layer is the right home.
  • The View is only for rendering — no database queries, no business calculations; all data must be prepared by the controller before being sent to the view.
  • The Model does not know HTTP or the View — a model producing HTML or HTTP status codes is a serious separation of concerns violation.
  • In JSON APIs, json.Marshal is the View — no HTML templates, but the principle stays the same: the controller formats and chooses the right data to return.
  • Separate domain entities from response DTOs — create View Models (response structs) different from domain entities; this gives flexibility to change the presentation without affecting business logic.
  • One controller per domainUserController, ArticleController, PaymentController; a God Controller handling all endpoints is an anti-pattern to avoid.
  • MVC is a presentation pattern, not a full architecture — it answers how requests are processed, not how the domain and infrastructure are organized; MVC is often combined with Layered Architecture or Clean Architecture.
  • Easy to learn, easy to misuse — correct MVC requires discipline to keep controllers thin and models pure; without discipline, everything piles up in the controller.

← Previous: Serverless   Next: MVP & MVVM →

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