Skip to content

How to organize Spring Boot controllers: domain vs access rules

Problem

When designing a Spring Boot REST API, I faced a common architectural dilemma: How should I organize my controllers?

Should I separate them by who accesses them (internal vs external APIs), or by what business domain they serve?

The two approaches look like this:

Access-based approach (questionable)
com.example.demo
├── controller
│ ├── internal/
│ │ ├── InternalUserController.java
│ │ └── InternalOrderController.java
│ └── external/
│ ├── ExternalUserController.java
│ └── ExternalOrderController.java
Domain-driven approach (recommended)
com.example.demo
├── controller
│ ├── user/
│ │ └── UserController.java
│ ├── order/
│ │ └── OrderController.java
│ └── product/
│ └── ProductController.java

I tried the access-based approach first because it seemed logical - different consumers need different endpoints. But I quickly ran into problems.

What happened?

I started with the access-based structure. Here’s what my controllers looked like:

InternalUserController.java
@RestController
@RequestMapping("/api/internal/users")
public class InternalUserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<UserInternalDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getInternalUser(id));
}
@GetMapping
public ResponseEntity<List<UserInternalDto>> getAllUsers() {
return ResponseEntity.ok(userService.getAllInternalUsers());
}
}
ExternalUserController.java
@RestController
@RequestMapping("/api/external/users")
public class ExternalUserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<UserPublicDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getPublicUser(id));
}
@GetMapping("/search")
public ResponseEntity<List<UserPublicDto>> searchUsers(@RequestParam String query) {
return ResponseEntity.ok(userService.searchPublicUsers(query));
}
}

The problem became obvious when I needed to add a new user endpoint. I had to add it to both controllers if it was relevant to both internal and external users. This led to:

  • Code duplication across controllers
  • Business logic scattered in multiple places
  • Difficulty maintaining consistency between internal and external APIs
  • Violation of DRY principle

I realized I was duplicating logic like validation, error handling, and service calls in two different places.

How to solve it?

I tried consolidating into a single domain-driven controller:

UserController.java
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
// Public endpoint - no authentication required
@GetMapping("/public/profile/{id}")
public ResponseEntity<UserPublicDto> getPublicProfile(@PathVariable Long id) {
return ResponseEntity.ok(userService.getPublicUser(id));
}
@GetMapping("/public/search")
public ResponseEntity<List<UserPublicDto>> searchUsers(@RequestParam String query) {
return ResponseEntity.ok(userService.searchPublicUsers(query));
}
// Authenticated endpoint
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<UserDto> getCurrentUser(Principal principal) {
return ResponseEntity.ok(userService.getCurrentUser(principal.getName()));
}
// Admin endpoint
@GetMapping("/admin/all")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<UserInternalDto>> getAllUsers() {
return ResponseEntity.ok(userService.getAllInternalUsers());
}
// Service-to-service endpoint
@GetMapping("/internal/{id}")
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<UserInternalDto> getInternalUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getInternalUser(id));
}
}

Then I configured Spring Security to handle the access control:

SecurityConfig.java
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}

You can see that I succeeded to eliminate duplication. All user-related operations are now in one place, and access control is handled by Spring Security annotations.

I tried a similar approach for orders:

OrderController.java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
// External - public order tracking
@GetMapping("/public/track/{id}")
public ResponseEntity<OrderPublicDto> trackOrder(@PathVariable String id) {
return ResponseEntity.ok(orderService.getPublicOrder(id));
}
// Internal - requires authentication
@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) {
return ResponseEntity.ok(orderService.findById(id));
}
@PostMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<OrderDto> createOrder(@RequestBody CreateOrderRequest request) {
return ResponseEntity.ok(orderService.create(request));
}
// Admin only
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
orderService.delete(id);
return ResponseEntity.noContent().build();
}
}

The reason

I think the key reason the domain-driven approach works better is:

  1. Single Source of Truth: All operations for a domain entity are in one place. When I need to understand or modify user-related logic, I go to UserController.

  2. Spring Security Handles Access Control: Spring provides powerful security annotations like @PreAuthorize, @Secured, and @RolesAllowed. There’s no need to duplicate controllers just to enforce access rules.

  3. Aligned with Domain-Driven Design: DDD emphasizes organizing code around business domains and bounded contexts, not technical concerns like who accesses the API.

  4. Better for Microservices: In a microservices architecture, each service typically handles one bounded context. Access-based separation doesn’t make sense when the service itself is bounded by domain.

  5. Easier Maintenance: Adding a new endpoint or modifying existing logic is simpler because everything related to that domain is co-located.

Different response formats

One concern I had was: What if internal and external APIs need different response formats?

I found that DTOs (Data Transfer Objects) solve this elegantly:

UserController.java
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserResponse(id));
}
@GetMapping("/{id}/internal")
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<UserInternalDto> getInternalUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getInternalResponse(id));
}
}

The controller delegates to the service layer to return the appropriate DTO:

UserService.java
@Service
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserDto getUserResponse(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
return userMapper.toDto(user);
}
public UserInternalDto getInternalResponse(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
return userMapper.toInternalDto(user);
}
}

Package structure

Here’s the complete package structure following domain-driven design:

Project Structure
com.example.application
├── interfaces (Interface Layer - Controllers)
│ ├── user
│ │ ├── UserController.java
│ │ ├── UserRequest.java
│ │ └── UserResponse.java
│ ├── order
│ │ ├── OrderController.java
│ │ ├── OrderRequest.java
│ │ └── OrderResponse.java
│ └── product
│ └── ProductController.java
├── application (Application Layer)
│ ├── user
│ │ └── UserService.java
│ └── order
│ └── OrderService.java
├── domain (Domain Layer)
│ ├── user
│ │ ├── User.java
│ │ └── UserRepository.java
│ └── order
│ ├── Order.java
│ └── OrderRepository.java
└── infrastructure (Infrastructure Layer)
├── config
│ └── SecurityConfig.java
└── persistence

This structure follows the clean architecture principles while keeping domain boundaries clear.

When access-based separation makes sense

After using domain-driven organization for a while, I found a few cases where access-based separation might be appropriate:

  1. Separate API Gateways: If you have completely different API gateways for different consumers, you might want separate controller packages.

  2. Completely Different Data Contracts: When internal and external APIs use fundamentally different data structures, response formats, and protocols.

  3. Legacy System Integration: When integrating with legacy systems that require specific endpoint structures.

  4. Consumer-Specific Rate Limiting: When you need different rate limits per consumer type at the controller level.

Even in these cases, I prefer path-based separation within the same controller:

Alternative approach when needed
@RestController
public class OrderController {
@GetMapping("/internal/orders/{id}")
@PreAuthorize("hasRole('SERVICE')")
public ResponseEntity<OrderInternalDto> getInternalOrder(@PathVariable Long id) {
// Internal representation with full details
return ResponseEntity.ok(orderService.getInternalOrder(id));
}
@GetMapping("/external/orders/{id}")
public ResponseEntity<OrderPublicDto> getExternalOrder(@PathVariable Long id) {
// Public representation with limited details
return ResponseEntity.ok(orderService.getPublicOrder(id));
}
}

Common pitfalls

While implementing domain-driven controller organization, I made a few mistakes:

  1. Creating controllers for every CRUD operation: I initially created separate controllers for read and write operations. This was overkill. A single controller per domain works better.

  2. Mixing concerns: I started putting business logic in controllers. I learned to keep controllers thin - they should only handle HTTP concerns and delegate to services.

  3. Over-segmentation: I created too many small controllers for related entities. Grouping related entities under a domain package works better.

  4. Ignoring Spring Security: I initially tried to manually implement access control logic. Using @PreAuthorize annotations is cleaner and more maintainable.

  5. Exposing domain entities directly: I returned JPA entities from controllers. Using DTOs prevents accidentally exposing internal data structures.

Summary

In this post, I showed how to organize Spring Boot controllers by domain instead of access rules. The key point is using Spring Security annotations for access control instead of creating separate controllers for internal and external APIs.

The domain-driven approach provides:

  • Single source of truth for each business domain
  • Eliminated code duplication
  • Better alignment with DDD principles
  • Easier maintenance and evolution
  • Cleaner separation of concerns

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