Skip to content

How to build Spring Boot multi-client architecture for web and mobile

Purpose

This post demonstrates how to build a single Spring Boot application that serves both web browsers and mobile clients from the same backend.

Environment

  • Spring Boot 3.x
  • Java 21
  • Spring MVC
  • Maven/Gradle

The Problem

When I started developing applications that needed to support both web browsers and mobile clients, I got this problem:

How do I serve HTML views for browsers and JSON responses for mobile apps from the same Spring Boot application?

I found many developers overcomplicate this by creating separate Spring Boot applications or forcing everything through one controller that returns different response types based on headers.

What I tried first

I tried putting everything in one controller and checking headers to determine the response format:

@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public Object getUsers(@RequestHeader("Accept") String acceptHeader) {
List<User> users = userService.getAllUsers();
if (acceptHeader.contains("text/html")) {
// This doesn't work - @RestController doesn't return HTML
ModelAndView mav = new ModelAndView("users/list");
mav.addObject("users", users);
return mav;
}
return users;
}
}

But when I run this, I got this error:

java.lang.IllegalStateException: ModelAndView requires a ViewResolver

The problem is @RestController doesn’t know how to handle ModelAndView objects. I need to use the right annotation for each client type.

The Solution: Separate Controllers

I discovered the right approach is using separate controllers for each client type:

// Web Controller for HTML responses
@Controller
@RequestMapping("/users")
public class UserWebController {
@Autowired
private UserService userService;
@GetMapping
public String listUsers(Model model) {
model.addAttribute("users", userService.getAllUsers());
return "users/list"; // Thymeleaf template
}
@GetMapping("/{id}")
public String getUser(@PathVariable Long id, Model model) {
model.addAttribute("user", userService.getUserById(id));
return "users/detail";
}
}
// API Controller for JSON responses
@RestController
@RequestMapping("/api/users")
public class UserApiController {
@Autowired
private UserService userService;
@GetMapping
public List<User> getAllUsers() {
return userService.getAllUsers();
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.getUserById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

Now test again:

# For web browser (Accept: text/html)
GET //users
Returns: users/list.html template with user data
# For mobile app (Accept: application/json)
GET /api/users
Returns: [{"id": 1, "name": "User 1"}, ...]

You can see that I succeeded to serve both client types properly.

The Shared Service Layer

I found the key is keeping business logic separate from presentation logic:

@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
public List<User> getAllUsers() {
return userRepository.findAll();
}
public Optional<User> getUserById(Long id) {
return userRepository.findById(id);
}
// Common business logic
public User createUser(UserDTO userDTO) {
User user = new User();
user.setName(userDTO.getName());
user.setEmail(userDTO.getEmail());
// Additional validation and business logic
return userRepository.save(user);
}
}

This service layer is shared between both controllers, ensuring consistency.

CORS Configuration for Mobile

When I tested my mobile app calls, I got CORS errors:

Access to XMLHttpRequest at 'http://localhost:8080/api/users'
from origin 'https://mobile-app.com' has been blocked by CORS policy.

I fixed this by adding CORS configuration:

@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://mobile-app.com", "http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true);
}
}

Now my mobile app calls work without errors.

Why This Works

I think the key reason this architecture works is:

  1. Clear separation: @Controller handles HTML views, @RestController handles JSON responses
  2. Shared logic: Service classes contain business logic used by both controller types
  3. Spring’s built-in support: Spring automatically handles content negotiation based on annotations
  4. Maintainability: Each controller type stays focused on its responsibility

Common Mistakes I Made

I tried using @RequestMapping with produces attribute on the same controller:

// This doesn't work well
@RequestMapping(value = "/users", produces = {"application/json", "text/html"})
public Object getUsers(...) {
// Complex logic to determine response type
}

This creates messy code. It’s better to have separate controllers.

Another mistake I made was not implementing proper error handling:

// Bad: No error handling
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUserById(id).get(); // Throws exception if not found
}

The correct approach is using ResponseEntity:

@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.getUserById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

The Configuration

Here’s my complete project structure:

src/main/java/com/example/demo/
├── controller/
│ ├── web/
│ │ ├── UserWebController.java
│ │ └── ProductWebController.java
│ └── api/
│ ├── UserApiController.java
│ └── ProductApiController.java
├── service/
│ ├── UserService.java
│ └── ProductService.java
└── config/
└── WebConfig.java
src/main/resources/
├── templates/
│ ├── users/
│ │ ├── list.html
│ │ └── detail.html
│ └── products/
└── static/

Summary

In this post, I showed how to build a Spring Boot application that serves both web and mobile clients. The key point is using separate controller layers (@Controller for HTML, @RestController for JSON) while sharing business logic through service classes. This approach keeps your code clean, maintainable, and scalable for multiple client types.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments