← Kursa Dön
📄 Text · 12 min

@RestController vs @Controller

Spring MVC'de HTTP isteklerini karşılayan iki temel annotation vardır: @Controller ve @RestController. Bu iki annotation, Spring Boot ile web geliştirmenin temelini oluşturur ve aralarındaki farkı kavramak, doğru mimari kararlar vermenizi sağlar.

Bir an için bir restoranı düşünün. Mutfakta iki tür şef var: biri yemeği tabağa güzelce dizip, dekorasyonuyla birlikte (HTML sayfası) sunan sunum şefi (@Controller), diğeri ise sadece malzeme listesini (JSON verisi) çıkaran hazırlık şefi (@RestController). İkisi de aynı mutfağı kullanır, ama çıktıları farklıdır.

En basit ifadeyle:

@RestController = @Controller + @ResponseBody

Bu eşitlik çok şey anlatır. @Controller geleneksel MVC uygulamalarında view (template) döndürmek için kullanılırken, @RestController REST API geliştirmede doğrudan veri (JSON/XML) döndürmek için kullanılır.

@Controller — Template Rendering

@Controller annotation'ı ile işaretlenen sınıflar, genellikle bir view adı (template name) döndürür. Spring MVC, bu view adını ViewResolver aracılığıyla bir HTML template dosyasına eşleştirir ve bu template'i render ederek kullanıcıya HTML yanıtı gönderir.

Bu mekanizma, geleneksel Server-Side Rendering (SSR) yaklaşımıdır. Sunucu tarafında HTML üretilir ve tarayıcıya gönderilir. Tarayıcı sadece bu HTML'i gösterir — ekstra JavaScript çalıştırmasına gerek yoktur.

@Controller
public class WebPageController {

    private final UserService userService;

    public WebPageController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/home")
    public String homePage(Model model) {
        model.addAttribute("title", "Ana Sayfa");
        model.addAttribute("message", "Hoş Geldiniz!");
        return "home"; // → templates/home.html (Thymeleaf)
    }

    @GetMapping("/users")
    public String userList(Model model) {
        List<User> users = userService.findAll();
        model.addAttribute("users", users);
        return "user-list"; // → templates/user-list.html
    }

    @GetMapping("/users/{id}")
    public String userDetail(@PathVariable Long id, Model model) {
        User user = userService.findById(id);
        model.addAttribute("user", user);
        model.addAttribute("orders", user.getOrders());
        return "user-detail"; // → templates/user-detail.html
    }
}

Burada Model nesnesi, controller'dan view'a veri aktarmak için kullanılır. model.addAttribute() ile eklenen veriler, template içinde kullanılabilir hale gelir. Bu mekanizma, bir bavulun içine eşya koyup başka birine teslim etmeye benzer — controller bavulu doldurur, template bavulu açıp eşyaları yerleştirir.

Thymeleaf template'i şöyle görünür:

<!-- templates/home.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="${title}">Default Title</title>
</head>
<body>
    <h1 th:text="${message}">Default Message</h1>
    
    <table>
        <tr th:each="user : ${users}">
            <td th:text="${user.name}">İsim</td>
            <td th:text="${user.email}">Email</td>
            <td>
                <a th:href="@{/users/{id}(id=${user.id})}">Detay</a>
            </td>
        </tr>
    </table>
</body>
</html>

View Resolution Süreci

Spring MVC'de bir controller metodu String döndürdüğünde, bu String doğrudan tarayıcıya gönderilmez. ViewResolver zinciri devreye girer:

return "home"
    ↓
ViewResolver: prefix + viewName + suffix
    ↓
"classpath:/templates/" + "home" + ".html"
    ↓
templates/home.html dosyası bulunur
    ↓
Template engine (Thymeleaf) dosyayı render eder
    ↓
Oluşan HTML tarayıcıya gönderilir

Spring Boot'ta Thymeleaf varsayılan yapılandırması classpath:/templates/ prefix'i ve .html suffix'ini kullanır. Bu değerleri application.properties'de değiştirebilirsiniz:

spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false  # Geliştirme sırasında cache kapatın

Model, ModelMap ve ModelAndView

Veri aktarımı için birkaç yol vardır:

@Controller
public class DataPassingController {

    // 1. Model interface (önerilen)
    @GetMapping("/v1")
    public String withModel(Model model) {
        model.addAttribute("data", "Hello");
        return "page";
    }

    // 2. ModelMap (Model'in implementasyonu)
    @GetMapping("/v2")
    public String withModelMap(ModelMap modelMap) {
        modelMap.addAttribute("data", "Hello");
        return "page";
    }

    // 3. ModelAndView (view adı ve veri birlikte)
    @GetMapping("/v3")
    public ModelAndView withModelAndView() {
        ModelAndView mav = new ModelAndView("page");
        mav.addObject("data", "Hello");
        return mav;
    }

    // 4. @ModelAttribute ile (tüm metotlar için ortak veri)
    @ModelAttribute("appName")
    public String appName() {
        return "My Application"; // Her view'da ${appName} kullanılabilir
    }
}

💡 İpucu: Model interface'i en temiz yaklaşımdır. ModelAndView ise view adı ve veriyi tek nesnede taşımak istediğinizde kullanışlıdır. Pratikte Model kullanımı daha yaygın ve okunabilirdir.

@ResponseBody — JSON Döndürme

Peki ya bir controller metodundan view değil, doğrudan veri döndürmek isterseniz? İşte burada @ResponseBody devreye girer. Bu annotation, Spring'e "dönüş değerini view adı olarak yorumlama, doğrudan HTTP yanıt gövdesine yaz" der.

@Controller
public class ApiController {

    private final UserService userService;

    public ApiController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/api/users")
    @ResponseBody // ← Bu annotation olmasaydı, Spring "users" adlı template arardı
    public List<User> getUsers() {
        return userService.findAll();
        // Jackson otomatik olarak JSON'a çevirir
    }

    @GetMapping("/api/users/{id}")
    @ResponseBody
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }

    @GetMapping("/api/status")
    @ResponseBody
    public Map<String, String> getStatus() {
        return Map.of(
            "status", "UP",
            "version", "1.2.3",
            "timestamp", LocalDateTime.now().toString()
        );
    }
}

@ResponseBody kullanıldığında Spring, dönüş değerini HttpMessageConverter aracılığıyla uygun formata çevirir. Spring Boot'ta Jackson kütüphanesi otomatik olarak yapılandırıldığı için, Java nesneleri otomatik olarak JSON'a serialize edilir.

HttpMessageConverter Zinciri

Spring MVC, @ResponseBody ile döndürülen nesneleri HTTP yanıtına dönüştürmek için converter zinciri kullanır. İstemcinin Accept header'ına göre uygun converter seçilir:

Java Nesnesi → HttpMessageConverter → HTTP Yanıt Gövdesi

MappingJackson2HttpMessageConverter → application/json
MappingJackson2XmlHttpMessageConverter → application/xml
StringHttpMessageConverter → text/plain
ByteArrayHttpMessageConverter → application/octet-stream

Spring Boot, Jackson'ı spring-boot-starter-web ile otomatik yapılandırır. Hiçbir ek ayar yapmadan JSON serializasyonu çalışır.

@RestController — @Controller + @ResponseBody

Her metoda tek tek @ResponseBody yazmak zahmetlidir. REST API geliştirirken neredeyse her metot veri döndürür. İşte bu yüzden Spring 4.0'da @RestController eklendi:

// @RestController = sınıf seviyesinde @Controller + @ResponseBody
@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public List<Product> getAllProducts() {
        return productService.findAll();
        // @ResponseBody'ye gerek yok — @RestController zaten sağlıyor
    }

    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }

    @PostMapping
    public Product createProduct(@RequestBody Product product) {
        return productService.save(product);
    }

    @PutMapping("/{id}")
    public Product updateProduct(@PathVariable Long id, @RequestBody Product product) {
        return productService.update(id, product);
    }

    @DeleteMapping("/{id}")
    public void deleteProduct(@PathVariable Long id) {
        productService.delete(id);
    }
}

@RestController'ın kaynak koduna bakarsanız, gerçekten de iki annotation'ın birleşimi olduğunu görürsünüz:

// Spring Framework kaynak kodu
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
    @AliasFor(annotation = Controller.class)
    String value() default "";
}

Bu meta-annotation yapısı, Spring'in annotation composition mekanizmasının güzel bir örneğidir.

Thymeleaf (SSR) vs REST API Yaklaşımı — Derinlemesine Karşılaştırma

Modern web uygulamaları iki temel mimari yaklaşımla geliştirilebilir. Her birinin güçlü ve zayıf yönleri vardır.

1. Server-Side Rendering (SSR) — @Controller + Thymeleaf

Sunucu HTML'i oluşturur ve tarayıcıya gönderir. Tarayıcı sadece HTML'i gösterir.

Kullanıcı → GET /products → Controller → Model + View Name
                                            ↓
                                      ViewResolver → Thymeleaf
                                            ↓
                                      HTML oluşturulur
                                            ↓
                                      ← HTML yanıt

Avantajları:

  • SEO dostu: Arama motorları HTML'i doğrudan okuyabilir

  • İlk yükleme hızlı: JavaScript bundle indirip çalıştırmaya gerek yok

  • Basit mimari: Frontend-backend arası API katmanı yok

  • CSRF koruması kolay: Form token'ları otomatik

Dezavantajları:

  • Her sayfa değişikliğinde tam sayfa yenilenir (full page reload)

  • Mobil uygulama desteği zor — HTML mobilde kullanışsız

  • Zengin kullanıcı etkileşimleri (drag & drop, gerçek zamanlı güncelleme) zor

  • Frontend ve backend geliştiricileri aynı projede çalışmak zorunda

2. REST API + SPA — @RestController

Sunucu sadece JSON verisi döndürür. Frontend (React, Angular, Vue.js) bu veriyi alır ve arayüzü oluşturur.

Kullanıcı → React App → GET /api/products → RestController → JSON yanıt
                                                                ↓
                                               React veriyi alır
                                                                ↓
                                               DOM güncellenir (sanal DOM)

Avantajları:

  • Tek API, çoklu istemci: Web, mobil, masaüstü aynı API'yi kullanır

  • Zengin kullanıcı deneyimi: SPA ile sayfa yenileme yok, anlık tepki

  • Takım ayrımı: Frontend ve backend bağımsız geliştirilir

  • Ölçeklenebilirlik: API katmanı bağımsız ölçeklenir

Dezavantajları:

  • İlk yükleme daha yavaş (JavaScript bundle)

  • SEO ek çaba gerektirir (SSR, pre-rendering)

  • İki ayrı proje yönetimi

  • CORS yapılandırması gerekli

⚠️ Dikkat: SSR vs SPA seçimi projenin ihtiyaçlarına bağlıdır. "Her zaman REST API kullan" diye bir kural yoktur. Blog siteleri için Thymeleaf mükemmelken, interaktif bir dashboard için React + REST API daha uygundur.

Karar Tablosu

SenaryoTercihNeden
Blog sitesi, kurumsal web@Controller + ThymeleafSEO önemli, basit etkileşim
Admin paneliHer ikisi de olabilirKarmaşıklığa göre karar ver
SPA (React/Vue/Angular) backend@RestControllerFrontend ayrı, sadece JSON gerekli
Mobil uygulama backend@RestControllerMobil uygulamalar JSON bekler
Microservice API@RestControllerServisler arası iletişim JSON/gRPC
Hem web hem mobil destekli@RestControllerTek API, çoklu istemci
İç araçlar, hızlı prototip@Controller + ThymeleafHızlı geliştirme, ekstra frontend yok
E-ticaret sitesiHibrit veya SPASEO + zengin UX ihtiyacı

Karma Kullanım — Aynı Projede Her İkisi

Bazı projelerde hem template rendering hem de REST API gerekebilir. Örneğin, bir e-ticaret sitesi düşünün: admin paneli Thymeleaf ile server-side render edilirken, ürün arama ve filtreleme mobil uygulama için REST API sunar.

// Web sayfaları için — @Controller
@Controller
@RequestMapping("/web")
public class WebController {

    private final DashboardService dashboardService;

    public WebController(DashboardService dashboardService) {
        this.dashboardService = dashboardService;
    }

    @GetMapping("/dashboard")
    public String dashboard(Model model) {
        model.addAttribute("stats", dashboardService.getStats());
        model.addAttribute("recentOrders", dashboardService.getRecentOrders(10));
        return "dashboard"; // HTML template döner
    }

    @GetMapping("/reports")
    public String reports(Model model) {
        model.addAttribute("monthlyReport", dashboardService.getMonthlyReport());
        return "reports"; // HTML template döner
    }
}

// REST API için — @RestController
@RestController
@RequestMapping("/api/v1")
public class ApiController {

    private final DashboardService dashboardService;

    public ApiController(DashboardService dashboardService) {
        this.dashboardService = dashboardService;
    }

    @GetMapping("/stats")
    public DashboardStats getStats() {
        return dashboardService.getStats(); // JSON döner
    }

    @GetMapping("/orders/recent")
    public List<OrderDto> getRecentOrders(@RequestParam(defaultValue = "10") int limit) {
        return dashboardService.getRecentOrders(limit); // JSON döner
    }
}

💡 İpucu: Karma kullanımda URL yapısını net tutun: /web/** web sayfaları için, /api/** REST API için. Bu ayırım, CORS ve güvenlik yapılandırmasını da kolaylaştırır.

@Controller ile JSON Döndürmenin Diğer Yolları

@Controller sınıfı içinde bazı metodların JSON, bazılarının view döndürmesini istiyorsanız ResponseEntity kullanabilirsiniz:

@Controller
public class HybridController {

    @GetMapping("/page")
    public String renderPage(Model model) {
        model.addAttribute("title", "Sayfa Başlığı");
        return "my-page"; // Template döndürür
    }

    @GetMapping("/data")
    public ResponseEntity<Map<String, Object>> getData() {
        Map<String, Object> data = Map.of(
            "key", "value",
            "count", 42,
            "items", List.of("a", "b", "c")
        );
        return ResponseEntity.ok(data); // JSON döndürür — @ResponseBody gerekmez
    }

    @GetMapping("/download")
    public ResponseEntity<byte[]> downloadFile() {
        byte[] content = fileService.getFileContent("report.pdf");
        
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_PDF);
        headers.setContentDispositionFormData("attachment", "report.pdf");
        
        return new ResponseEntity<>(content, headers, HttpStatus.OK);
    }
}

ResponseEntity döndürdüğünüzde Spring otomatik olarak bunu HTTP yanıt gövdesine yazar — @ResponseBody gerekmez. Çünkü ResponseEntity zaten HTTP yanıtının tamamını (status code + headers + body) temsil eder.

Spring MVC Request İşleme Akışı

Her iki annotation'ın arka planda nasıl çalıştığını anlamak için, Spring MVC'nin istek işleme akışını bilmek önemlidir:

HTTP İsteği
    ↓
DispatcherServlet (Front Controller)
    ↓
HandlerMapping → Uygun Controller metodu bulunur
    ↓
HandlerAdapter → Metot çağrılır
    ↓
┌──────────────────────────────┐
│ @Controller                   │
│  return "viewName"           │
│      ↓                       │
│  ViewResolver → Template     │
│      ↓                       │
│  Template Engine → HTML      │
│      ↓                       │
│  HTML Response               │
└──────────────────────────────┘

┌──────────────────────────────┐
│ @RestController              │
│  return JavaObject           │
│      ↓                       │
│  HttpMessageConverter        │
│      ↓                       │
│  JSON/XML Response           │
└──────────────────────────────┘

DispatcherServlet, Spring MVC'nin kalbidir — tüm HTTP isteklerini karşılar ve uygun handler'a yönlendirir. @Controller kullanıldığında ViewResolver devreye girer; @RestController kullanıldığında HttpMessageConverter devreye girer.

Gerçek Dünya Senaryosu: E-Ticaret Uygulaması

Bir e-ticaret uygulamasında hem web arayüzü hem de API katmanı gerekir. İşte tam bir örnek:

// DTO — Her iki controller'da da kullanılır
public record ProductDto(
    Long id,
    String name,
    String description,
    BigDecimal price,
    String imageUrl,
    int stockCount
) {
    public static ProductDto fromEntity(Product product) {
        return new ProductDto(
            product.getId(),
            product.getName(),
            product.getDescription(),
            product.getPrice(),
            product.getImageUrl(),
            product.getStockCount()
        );
    }
}

// Web Controller — SSR
@Controller
@RequestMapping("/shop")
public class ShopWebController {

    private final ProductService productService;
    private final CategoryService categoryService;

    public ShopWebController(ProductService productService, 
                              CategoryService categoryService) {
        this.productService = productService;
        this.categoryService = categoryService;
    }

    @GetMapping
    public String shopPage(Model model,
                           @RequestParam(required = false) String category,
                           @RequestParam(defaultValue = "0") int page) {
        Page<ProductDto> products = productService.findByCategory(category, page, 20);
        model.addAttribute("products", products);
        model.addAttribute("categories", categoryService.findAll());
        model.addAttribute("selectedCategory", category);
        return "shop"; // templates/shop.html
    }

    @GetMapping("/product/{slug}")
    public String productDetail(@PathVariable String slug, Model model) {
        ProductDto product = productService.findBySlug(slug);
        model.addAttribute("product", product);
        model.addAttribute("relatedProducts", productService.findRelated(product.id(), 4));
        return "product-detail";
    }
}

// REST API Controller — Mobil + SPA
@RestController
@RequestMapping("/api/v1/products")
public class ProductApiController {

    private final ProductService productService;

    public ProductApiController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public ResponseEntity<Page<ProductDto>> listProducts(
            @RequestParam(required = false) String category,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        Page<ProductDto> products = productService.findByCategory(category, page, size);
        return ResponseEntity.ok(products);
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductDto> getProduct(@PathVariable Long id) {
        return productService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<ProductDto> createProduct(@RequestBody @Valid CreateProductRequest request) {
        ProductDto created = productService.create(request);
        URI location = URI.create("/api/v1/products/" + created.id());
        return ResponseEntity.created(location).body(created);
    }
}

Bu örnekte aynı ProductService hem web controller hem de API controller tarafından kullanılır. İş mantığı service katmanında, sunum mantığı controller'larda ayrılmıştır.

Yaygın Hatalar ve Çözümleri

Hata 1: @Controller ile JSON Döndürmeye Çalışmak

// ❌ YANLIŞ — "userList" adında template arar ve 500 hatası verir
@Controller
public class UserController {
    @GetMapping("/api/users")
    public List<User> getUsers() {
        return userService.findAll(); // Template resolver hatası!
    }
}

// ✅ DOĞRU — @ResponseBody ekleyin veya @RestController kullanın
@Controller
public class UserController {
    @GetMapping("/api/users")
    @ResponseBody
    public List<User> getUsers() {
        return userService.findAll(); // JSON döner
    }
}

Hata 2: @RestController ile Template Döndürmeye Çalışmak

// ❌ YANLIŞ — "home" stringini doğrudan JSON olarak döner: "home"
@RestController
public class PageController {
    @GetMapping("/home")
    public String homePage() {
        return "home"; // Template render edilmez, "home" stringi döner!
    }
}

// ✅ DOĞRU — HTML sayfaları için @Controller kullanın
@Controller
public class PageController {
    @GetMapping("/home")
    public String homePage(Model model) {
        return "home"; // templates/home.html render edilir
    }
}

Hata 3: İki Controller Türünü Aynı Sınıfta Karıştırmak

// ❌ YANLIŞ — @RestController tüm metotlara @ResponseBody uygular
@RestController
public class MixedController {
    @GetMapping("/page")
    public String page() {
        return "my-page"; // "my-page" stringi JSON olarak döner, template değil!
    }
    
    @GetMapping("/data")
    public User data() {
        return userService.findById(1L); // JSON olarak döner ✓
    }
}

// ✅ DOĞRU — İki ayrı sınıf kullanın
@Controller
public class WebController {
    @GetMapping("/page")
    public String page(Model model) { return "my-page"; }
}

@RestController  
@RequestMapping("/api")
public class ApiController {
    @GetMapping("/data")
    public User data() { return userService.findById(1L); }
}

⚠️ Dikkat: Bir sınıfta @RestController kullanıyorsanız, o sınıftaki HİÇBİR metot template döndüremez. Template ve JSON'ı karıştırmanız gereken nadir durumlarda, iki ayrı controller sınıfı oluşturmak en temiz yaklaşımdır.

@RestController ile Unit Test

@RestController'lar genellikle MockMvc ile test edilir:

@WebMvcTest(ProductApiController.class)
class ProductApiControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Test
    void shouldReturnProductList() throws Exception {
        List<ProductDto> products = List.of(
            new ProductDto(1L, "Laptop", "Desc", BigDecimal.valueOf(999.99), null, 10)
        );
        when(productService.findByCategory(any(), anyInt(), anyInt()))
            .thenReturn(new PageImpl<>(products));

        mockMvc.perform(get("/api/v1/products"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content[0].name").value("Laptop"))
            .andExpect(jsonPath("$.content[0].price").value(999.99));
    }

    @Test
    void shouldReturn404WhenProductNotFound() throws Exception {
        when(productService.findById(999L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/v1/products/999"))
            .andExpect(status().isNotFound());
    }
}

Performans ve Best Practice'ler

  1. DTO Kullanın: Entity'leri doğrudan döndürmeyin. Entity'ler JPA proxy'leri, lazy-loading koleksiyonları ve veritabanı şemasına bağımlılık içerir. DTO'lar API sözleşmenizi entity değişikliklerinden korur.

  2. ResponseEntity Tercih Edin: @RestController ile bile ResponseEntity kullanmak, status kodları ve header'lar üzerinde kontrol sağlar.

  3. API Versiyonlama: REST API'lerde URL'de versiyon kullanın (/api/v1/products). Bu, eski istemcileri bozmadan API'yi geliştirebilmenizi sağlar.

  4. Paket Yapısı: Web controller'ları ve API controller'ları ayrı paketlerde tutun:

`` com.example.app ├── web/ # @Controller sınıfları │ ├── ShopWebController │ └── AdminWebController └── api/ # @RestController sınıfları ├── ProductApiController └── UserApiController ``

Özet

  • `@Controller`, HTML template (Thymeleaf, Freemarker) render eder. Model ile view'a veri aktarılır. SSR uygulamaları için kullanılır.

  • `@RestController`, @Controller + @ResponseBody kısayoludur. JSON/XML veri döndürür. REST API geliştirmek için kullanılır.

  • `@ResponseBody`, controller metodunun dönüş değerini doğrudan HTTP yanıt gövdesine yazar. @RestController sınıflarında otomatik uygulanır.

  • Karma kullanım gerektiğinde, web ve API controller'ları ayrı sınıflarda tanımlayın. URL prefix'lerini (/web/**, /api/**) net tutun.

  • Modern projelerde REST API + SPA yaklaşımı baskındır, ancak SEO gereksinimleri veya hızlı prototipleme için SSR hâlâ güçlü bir seçenektir.

  • Karar verirken: Tek bir istemci (web) → SSR düşünün. Çoklu istemci (web + mobil + 3. parti) → REST API zorunlu.