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| Component | Responsibility | Must Not |
|---|---|---|
| Model | Data, business rules, validation, database access | Know about the view or HTTP |
| View | Render output (HTML, JSON, XML) | Contain business logic or DB access |
| Controller | Parse input, coordinate Model and View | Heavy 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 + JSONGo 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"]
endIn 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:
| Aspect | MVC | Layered Architecture | Clean Architecture |
|---|---|---|---|
| Level | Presentation pattern | The whole application | The whole application |
| Answers | How UI and requests are organized | How layers are separated | How dependencies are arranged |
| Combinable | Yes — MVC in presentation, layered inside | Yes | Yes |
| Best for | Web apps, simple APIs | Medium systems | Complex, long-lived systems |
| Database | Not addressed | In the infrastructure layer | An 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.Marshalis 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 domain —
UserController,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.