MVC Architecture #
Jika ada satu pola arsitektur yang bisa diklaim sebagai paling banyak diimplementasikan dalam sejarah software engineering, jawabannya hampir pasti MVC. Dari Smalltalk di tahun 1970-an, melewati Ruby on Rails yang mempopulerkannya di web, hingga Flutter dan SwiftUI yang mengadaptasi konsepnya untuk mobile — ide di balik MVC tetap sama: pisahkan tiga tanggung jawab yang berbeda dalam membangun antarmuka aplikasi. Model mengurus data dan logika bisnis. View mengurus tampilan. Controller mengurus alur dari input pengguna ke output yang ditampilkan. Tiga kata, tiga tanggung jawab, satu aturan: jangan campur ketiganya. Di Go, MVC sering muncul secara alami bahkan tanpa framework khusus — handler adalah Controller, template atau json.Marshal adalah View, dan struct dengan business logic adalah Model. Memahami MVC dengan baik berarti memahami mengapa pemisahan ini ada dan apa yang terjadi ketika dilanggar.
Tiga Komponen dan Tanggung Jawabnya #
flowchart LR
U([User])
subgraph MVC["MVC Architecture"]
V["View\n(Tampilan)"]
C["Controller\n(Koordinator)"]
M["Model\n(Data & Logic)"]
end
DB[(Database)]
U -->|"input (klik, form submit)"| C
C -->|"query data"| M
M -->|"baca/tulis"| DB
DB -->|"data"| M
M -->|"domain object / DTO"| C
C -->|"pilih view + kirim data"| V
V -->|"render tampilan"| U
| Komponen | Tanggung Jawab | Tidak Boleh |
|---|---|---|
| Model | Data, business rules, validasi, akses database | Tahu tentang tampilan atau HTTP |
| View | Render output (HTML, JSON, XML) | Mengandung business logic atau akses DB |
| Controller | Parse input, koordinasi Model dan View | Business logic berat, akses DB langsung |
Aturan utama yang tidak boleh dilanggar: data mengalir satu arah melalui Controller. View tidak boleh memanggil Model langsung, dan Model tidak boleh tahu View mana yang akan menampilkan datanya.
Alur Request-Response di Go #
Di Go tanpa framework, komponen MVC dipetakan ke konsep yang sudah ada secara native:
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
Implementasi Go: Server-Side Rendering #
Implementasi pertama adalah MVC klasik dengan HTML template — seperti yang digunakan oleh aplikasi web tradisional.
// 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 menerbitkan artikel — aturan bisnis ada di dalam model
func (a *Article) Publish() error {
if strings.TrimSpace(a.Title) == "" {
return errors.New("judul artikel tidak boleh kosong")
}
if strings.TrimSpace(a.Body) == "" {
return errors.New("isi artikel tidak boleh kosong")
}
if a.PublishedAt != nil {
return errors.New("artikel sudah diterbitkan")
}
now := time.Now()
a.PublishedAt = &now
return nil
}
func (a *Article) IsPublished() bool {
return a.PublishedAt != nil
}
// ArticleRepository adalah interface untuk 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 (bagian dari Model)
// Mengorkestrasikan use case menggunakan domain entity
package service
import (
"context"
"errors"
"myapp/model"
)
// ArticleService adalah application service — bagian dari "M" dalam 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("ID tidak valid")
}
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: koordinator antara Model dan View
package controller
import (
"html/template"
"net/http"
"strconv"
"myapp/service"
)
// ArticleController menangani semua HTTP request terkait artikel
// ✓ Tipis — hanya parse input, delegate ke service, render view
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 menampilkan semua artikel yang sudah diterbitkan
func (c *ArticleController) Index(w http.ResponseWriter, r *http.Request) {
articles, err := c.articleSvc.FindPublished(r.Context())
if err != nil {
http.Error(w, "gagal mengambil artikel", http.StatusInternalServerError)
return
}
// Render view dengan data dari model
if err := c.templates.ExecuteTemplate(w, "articles/index.html", map[string]interface{}{
"Articles": articles,
"Title": "Semua Artikel",
}); err != nil {
http.Error(w, "gagal render halaman", http.StatusInternalServerError)
}
}
// Show menampilkan satu artikel berdasarkan ID
func (c *ArticleController) Show(w http.ResponseWriter, r *http.Request) {
// Parse ID dari URL path
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "ID tidak valid", http.StatusBadRequest)
return
}
article, err := c.articleSvc.FindByID(r.Context(), id)
if err != nil {
http.Error(w, "artikel tidak ditemukan", http.StatusNotFound)
return
}
c.templates.ExecuteTemplate(w, "articles/show.html", map[string]interface{}{
"Article": article,
"Title": article.Title,
})
}
// New menampilkan form untuk membuat artikel baru
func (c *ArticleController) New(w http.ResponseWriter, r *http.Request) {
c.templates.ExecuteTemplate(w, "articles/new.html", map[string]interface{}{
"Title": "Artikel Baru",
})
}
// Create memproses form submission untuk membuat artikel
func (c *ArticleController) Create(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "form tidak valid", http.StatusBadRequest)
return
}
// Parse input dari form
title := r.FormValue("title")
body := r.FormValue("body")
publish := r.FormValue("publish") == "true"
// Validasi input minimal di controller (format, bukan business rule)
if title == "" || body == "" {
c.templates.ExecuteTemplate(w, "articles/new.html", map[string]interface{}{
"Title": "Artikel Baru",
"Error": "Judul dan isi wajib diisi",
"Values": map[string]string{"title": title, "body": body},
})
return
}
// Delegate ke service — business validation ada di sana
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": "Artikel Baru",
"Error": err.Error(),
"Values": map[string]string{"title": title, "body": body},
})
return
}
// Redirect ke halaman artikel yang baru dibuat
http.Redirect(w, r, "/articles/"+strconv.FormatInt(article.ID, 10), http.StatusSeeOther)
}
// router/router.go — routing yang menghubungkan URL ke Controller method
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
}
Implementasi Go: JSON API (MVC tanpa View Template) #
Di aplikasi API-only, “View” digantikan oleh serialisasi JSON. Controller tetap bertugas menentukan format output:
// controller/api/article_api_controller.go — Controller untuk JSON API
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"myapp/service"
)
// ArticleResponse adalah View Model — DTO yang dioptimalkan untuk response JSON
// ✓ Berbeda dari domain entity — hanya field yang relevan untuk 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 adalah wrapper untuk list response
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, "gagal mengambil artikel")
return
}
// Map domain object ke View Model (response DTO)
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, "ID tidak valid")
return
}
article, err := c.articleSvc.FindByID(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "artikel tidak ditemukan")
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, "request body tidak valid")
return
}
// Validasi format minimal di controller
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title wajib diisi")
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 — "View" di 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})
}
Menghindari Fat Controller dengan Service Layer #
Fat Controller adalah anti-pattern paling umum di MVC — ketika semua business logic berkumpul di Controller karena tampaknya itu “mudah”. Solusinya adalah Service Layer:
flowchart LR
subgraph FAT["❌ Fat Controller — Logic Menumpuk"]
FC["Controller\n• Parse HTTP\n• Validate business rule\n• Query database\n• Calculate discount\n• Send email\n• Format response"]
end
subgraph THIN["✓ Thin Controller + Service Layer"]
TC["Controller\n• Parse HTTP\n• Validate format\n• Call service\n• Format response"]
SVC["Service\n• Validate business rule\n• Coordinate domain\n• Send email\n• Return result"]
DOM["Model/Domain\n• Core business rules\n• Invariants\n• State machine"]
TC --> SVC --> DOM
end
// ✗ Fat Controller — business logic di handler
func (c *ArticleController) Publish(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
// ✗ Business logic di controller
article, _ := c.db.QueryRow("SELECT * FROM articles WHERE id = $1", id)
if article.PublishedAt != nil { // ✗ business rule di controller
http.Error(w, "artikel sudah diterbitkan", 400)
return
}
if article.Title == "" { // ✗ business validation di controller
http.Error(w, "judul belum ada", 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) di controller
c.mailer.Send(article.AuthorEmail, "Artikel Diterbitkan", "...")
writeJSON(w, 200, article)
}
// ✓ Thin Controller — delegate ke 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, "ID tidak valid") // ✓ hanya validasi format
return
}
// ✓ delegate semua business logic ke service
article, err := c.articleSvc.Publish(r.Context(), id)
if err != nil {
writeError(w, 422, err.Error())
return
}
writeJSON(w, 200, toArticleResponse(article))
}
Variasi MVC Modern: API + Frontend Terpisah #
MVC klasik melakukan server-side rendering — Controller menghasilkan HTML yang langsung dikirim ke browser. Di arsitektur modern, “View” sering dipindahkan ke frontend (React, Vue, Flutter):
flowchart LR
subgraph CLASSIC["MVC Klasik\n(Server-Side Rendering)"]
B1["Browser"] --> C1["Controller"]
C1 --> M1["Model"]
M1 --> C1
C1 --> V1["View (HTML Template)"]
V1 --> B1
end
subgraph MODERN["MVC Modern\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
Di Go, perbedaannya minimal dari sisi backend — Controller tetap bertanggung jawab menerima request dan mengembalikan output, hanya saja output-nya JSON bukan HTML.
Struktur Direktori #
myapp/
├── model/ ← Model: domain entities
│ ├── article.go ← Article entity + business rules
│ ├── user.go
│ └── repository.go ← Repository interfaces
│
├── service/ ← Service Layer (bagian dari 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 semua komponen
MVC vs Layered vs Clean Architecture #
MVC adalah pola di level presentation layer — ia menjawab bagaimana request HTTP diproses dan response dihasilkan, tapi tidak menjawab bagaimana domain dan infrastruktur diorganisir. Ini sering menyebabkan kebingungan:
| Aspek | MVC | Layered Architecture | Clean Architecture |
|---|---|---|---|
| Level | Presentation pattern | Seluruh aplikasi | Seluruh aplikasi |
| Menjawab | Bagaimana UI dan request diorganisir | Bagaimana layer dipisahkan | Bagaimana dependency diatur |
| Bisa dikombinasikan | Ya — MVC di presentation, layered di dalam | Ya | Ya |
| Cocok untuk | Web app, API sederhana | Sistem menengah | Sistem kompleks dengan umur panjang |
| Database | Tidak diatur | Di infrastructure layer | Detail implementasi |
MVC dan Layered Architecture bisa dikombinasikan: Controller = Presentation Layer, Service = Application Layer, Repository = Infrastructure Layer.
Anti-Pattern yang Harus Dihindari #
// ✗ God Controller — satu controller untuk semua
type AppController struct{}
func (c *AppController) HandleEverything(w http.ResponseWriter, r *http.Request) {
// ✗ switch path untuk semua endpoint
switch r.URL.Path {
case "/users":
// handle users...
case "/articles":
// handle articles + business logic di sini
case "/payments":
// handle payments + kalkulasi di sini
}
}
// ✓ Satu controller per domain
type UserController struct{ userSvc *service.UserService }
type ArticleController struct{ articleSvc *service.ArticleService }
type PaymentController struct{ paymentSvc *service.PaymentService }
// ✗ Business logic di View (template)
// articles/show.html
// {{ if gt (len .Article.Body) 100 }} ... {{ end }} — business rule di template
// ✓ Hitung di controller/service, kirim flag ke view
// Controller:
// data["IsLongArticle"] = len(article.Body) > 100
// Template:
// {{ if .IsLongArticle }} ... {{ end }}
// ✗ View mengakses database langsung (Active Record anti-pattern di template)
// articles/show.html
// {{ range .Article.GetComments }} ... {{ end }}
// — GetComments() di dalam model melakukan query DB saat template di-render
// ✓ Controller fetch semua data yang dibutuhkan sebelum render
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 di controller
c.templates.ExecuteTemplate(w, "articles/show.html", map[string]interface{}{
"Article": article,
"Comments": comments, // ✓ semua data sudah siap, template hanya render
})
}
// ✗ Model tahu tentang HTTP atau View
type Article struct{}
func (a *Article) ToHTML() string { // ✗ Model tidak boleh tahu tentang HTML
return "<h1>" + a.Title + "</h1>"
}
func (a *Article) GetHTTPStatusCode() int { // ✗ Model tidak boleh tahu tentang HTTP
if a.IsPublished() {
return 200
}
return 404
}
// ✓ Model hanya berisi data dan 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 } // ✓
Checklist Review MVC Architecture #
MODEL:
□ Model tidak memiliki import dari net/http atau html/template
□ Business rules dan validasi ada di dalam model, bukan di controller
□ Repository interface didefinisikan di package model
□ Model tidak tahu View mana yang akan menampilkan datanya
CONTROLLER:
□ Controller tidak mengandung business logic kompleks
□ Controller tidak mengakses database secara langsung
□ Validasi format (tipe data, required field) boleh di controller
□ Business validation didelegasikan ke service atau model
□ Tidak ada "God Controller" — satu controller per domain
VIEW:
□ Template tidak mengandung business logic
□ Template tidak melakukan query atau API call
□ Semua data yang dibutuhkan sudah disiapkan oleh controller sebelum render
□ Formatting dan presentasi data boleh di helper template
SERVICE LAYER:
□ Ada service layer untuk bisnis logic yang kompleks
□ Controller memanggil service, bukan langsung repository
□ Service menggunakan domain model, bukan HTTP struct
ROUTING:
□ Route terdefinisi dengan jelas, tidak ambigu
□ HTTP method sesuai dengan semantiknya (GET tidak mengubah state)
□ Middleware (auth, logging) dipisahkan dari business logic
PENGUJIAN:
□ Model bisa diuji tanpa HTTP atau database
□ Service bisa diuji dengan mock repository
□ Controller bisa diuji dengan test HTTP server dan mock service
Ringkasan #
- MVC memisahkan tiga tanggung jawab yang berbeda — Model mengelola data dan business rules, View menampilkan output, Controller mengkoordinasikan keduanya; mencampur ketiganya adalah sumber masalah terbesar.
- Controller harus tipis — parse request, validasi format minimal, panggil service/model, render view; business logic di controller adalah tanda “Fat Controller” yang harus segera diperbaiki.
- Service Layer adalah solusi untuk Fat Controller — ketika business logic terlalu kompleks untuk domain entity dan terlalu berat untuk controller, service layer menjadi rumah yang tepat.
- View hanya untuk rendering — tidak ada query database, tidak ada business calculation; semua data harus disiapkan controller sebelum dikirim ke view.
- Model tidak mengenal HTTP atau View — model yang menghasilkan HTML atau HTTP status code adalah pelanggaran separation of concerns yang serius.
- Di JSON API,
json.Marshaladalah View-nya — tidak ada HTML template, tapi prinsip tetap sama: controller memformat dan memilih data yang tepat untuk dikembalikan.- Pisahkan domain entity dari response DTO — buat View Model (response struct) yang berbeda dari domain entity; ini memberi fleksibilitas untuk mengubah tampilan tanpa mempengaruhi business logic.
- Satu controller per domain —
UserController,ArticleController,PaymentController; God Controller yang menangani semua endpoint adalah anti-pattern yang harus dihindari.- MVC adalah pola presentation, bukan arsitektur penuh — ia menjawab bagaimana request diproses, bukan bagaimana domain dan infrastruktur diorganisir; MVC sering dikombinasikan dengan Layered Architecture atau Clean Architecture.
- Mudah dipelajari, mudah disalahgunakan — MVC yang benar membutuhkan disiplin untuk menjaga controller tetap tipis dan model tetap murni; tanpa disiplin, semua menumpuk di controller.
← Sebelumnya: Serverless Architecture Berikutnya: MVP Architecture →