Should You Separate Controllers for Internal and External APIs in Spring Boot?
Problem
When I started building a Spring Boot microservice with APIs accessed by external users through an API gateway and internal services calling each other directly, I ran into an organizational dilemma: Should I organize controllers by access type or by domain?
My first thought was to separate them clearly: external endpoints for public access, internal endpoints for service-to-service communication. But I was worried about code duplication and maintainability.
Environment
- Spring Boot 3.2
- Java 21
- Spring Security 6
- Spring Cloud Gateway
The Two Approaches
I considered two ways to organize my controllers.
Option A: Separate by Access Type
/controllers /external - UserController.java - OrderController.java /internal - ServiceUserController.java - ServiceOrderController.javaThis seemed appealing at first because it clearly separates public from private endpoints. I could easily see which endpoints were meant for external users versus internal services.
But I noticed problems immediately. When I needed to add a new feature to user management, I had to update two controllers. Business logic started duplicating between UserController and ServiceUserController.
Option B: Separate by Domain
/controllers /user - UserController.java /order - OrderController.javaThis approach groups endpoints by business domain. Both internal and external endpoints live in the same controller.
Why Domain-First Matters
I tried Option A first and quickly ran into issues.
When I added validation logic to create users, I had to duplicate the code in both controllers:
@RestController@RequestMapping("/api/v1/users")public class ExternalUserController {
@PostMapping public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) { validateUserRequest(request); // Validation logic here User user = userService.create(request); return ResponseEntity.ok(toResponse(user)); }}@RestController@RequestMapping("/internal/v1/users")public class InternalUserController {
@PostMapping public ResponseEntity<UserEntity> createUserInternal(@RequestBody CreateUserRequest request) { validateUserRequest(request); // Same validation logic duplicated User user = userService.create(request); return ResponseEntity.ok(user); }}This violated the DRY principle and made maintenance painful. Every change required updating both files.
I switched to Option B and kept related logic together:
@RestController@RequestMapping("/api/v1/users")public class UserController {
// External endpoints (via API gateway) @GetMapping @PreAuthorize("hasRole('USER')") public ResponseEntity<List<UserResponse>> getUsers() { return ResponseEntity.ok(userService.findAll().stream() .map(this::toResponse) .toList()); }
@PostMapping @PreAuthorize("hasRole('USER')") public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) { validateUserRequest(request); User user = userService.create(request); return ResponseEntity.ok(toResponse(user)); }
// Internal endpoints (service-to-service) @GetMapping("/internal/{id}") @PreAuthorize("hasRole('SERVICE')") public ResponseEntity<UserEntity> getUserInternal(@PathVariable String id) { return ResponseEntity.ok(userService.findById(id)); }
@GetMapping("/internal/batch") @PreAuthorize("hasRole('SERVICE')") public ResponseEntity<List<UserEntity>> getUsersBatch(@RequestParam List<String> ids) { return ResponseEntity.ok(userService.findByIds(ids)); }}Now all user-related logic stays in one place. I only duplicate when necessary - like using different response types (DTO vs entity).
URL Path Strategies
The key insight is that URL paths provide the separation, not controller classes.
Hereβs the convention I adopted:
External API: /api/v1/{resource}Internal API: /internal/v1/{resource}Health: /actuator/**This serves several purposes:
- Clear access boundaries for security configuration
- Easy API gateway routing - route
/api/**externally, block/internal/** - Documentation clarity - Swagger/OpenAPI can group by path prefix
Security Configuration
I configured security by path pattern, not by controller class:
@Configuration@EnableWebSecuritypublic class SecurityConfig {
@Bean public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception { http .securityMatcher("/api/v1/**") .authorizeHttpRequests(auth -> auth .anyRequest().hasRole("USER") ) .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); return http.build(); }
@Bean public SecurityFilterChain internalSecurity(HttpSecurity http) throws Exception { http .securityMatcher("/internal/**") .authorizeHttpRequests(auth -> auth .anyRequest().hasRole("SERVICE") ); return http.build(); }}This approach uses separate security filter chains. External endpoints require user authentication via JWT, while internal endpoints require service authentication.
API Gateway Integration
The API gateway configuration became straightforward:
spring: cloud: gateway: routes: - id: external-api uri: lb://user-service predicates: - Path=/api/v1/** filters: - RateLimit=100,1s - JwtAuth # Internal routes are NOT exposed through gatewayI explicitly only expose /api/** paths. The /internal/** paths are accessible only within the service mesh or private network.
When Separate Controllers Make Sense
After using domain-organized controllers for a while, I found scenarios where separate controllers were actually justified.
- Completely different contracts - When internal API returns full entities and external API returns strictly formatted DTOs
- Different authentication mechanisms - OAuth for external, mutual TLS for internal
- Different rate limiting - Strict limits for external, relaxed for internal
- Different versioning strategies - Slow, backward-compatible versioning for external, fast iteration for internal
For example:
@RestController@RequestMapping("/api/v1/users")public class ExternalUserController { @GetMapping("/{id}") public ResponseEntity<UserResponse> getUser(@PathVariable String id) { User user = userService.findById(id); return ResponseEntity.ok(UserResponse.builder() .id(user.getId()) .name(user.getName()) .email(user.getEmail()) // Only exposed fields .build()); }}@RestController@RequestMapping("/internal/v1/users")public class InternalUserController { @GetMapping("/{id}") public ResponseEntity<UserEntity> getUserRaw(@PathVariable String id) { // Returns full entity with internal fields return ResponseEntity.ok(userService.findById(id)); }}In this case, the controllers serve fundamentally different purposes and audiences. The business logic still lives in the service layer, so no duplication occurs.
Common Mistakes I Made
Here are mistakes I encountered and how I fixed them.
Mistake 1: Organizing by Access Type
I initially created separate packages for external and internal controllers. This scattered related logic across the codebase.
/external/UserController.java/internal/UserController.javaThe fix: Organize by domain regardless of access type.
/user/UserController.javaMistake 2: Duplicating Business Logic
I put business logic directly in controllers and duplicated it between external and internal versions.
The fix: Extract to a shared service layer. Controllers should be thin wrappers that handle HTTP concerns only.
Mistake 3: Mixing Internal and External in Same Endpoint
I tried to handle both cases in one endpoint with a header check:
@GetMapping("/{id}")public ResponseEntity<?> getUser(@PathVariable String id, @RequestHeader("X-Internal") boolean internal) { if (internal) return ResponseEntity.ok(entity); return ResponseEntity.ok(dto);}This creates unclear behavior and security risks. Separate the endpoints by path.
Mistake 4: Exposing Internal Endpoints Through Gateway
I almost exposed /internal/** paths through the API gateway by mistake.
The fix: Explicitly only route /api/** paths. Use network policies as an additional layer of security.
Practical Implementation
Hereβs my complete approach for implementing domain-organized controllers with clear access boundaries.
Step 1: Define URL Constants
public class ApiPaths { public static final String EXTERNAL_PREFIX = "/api/v1"; public static final String INTERNAL_PREFIX = "/internal/v1";}Step 2: Configure Separate Security Chains
@Configuration@EnableWebSecuritypublic class SecurityConfig {
@Bean public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception { http .securityMatcher("/api/v1/**") .authorizeHttpRequests(auth -> auth .anyRequest().hasAuthority("SCOPE_user") ) .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) .csrf(AbstractHttpConfigurer::disable); return http.build(); }
@Bean public SecurityFilterChain internalSecurity(HttpSecurity http) throws Exception { http .securityMatcher("/internal/**") .authorizeHttpRequests(auth -> auth .anyRequest().hasAuthority("SCOPE_service") ) .csrf(AbstractHttpConfigurer::disable); return http.build(); }}Step 3: Document with OpenAPI
@OpenAPIDefinition( info = @Info(title = "User Service API", version = "1.0"), group = "external")public class ExternalApiConfig { }@OpenAPIDefinition( info = @Info(title = "User Service Internal API", version = "1.0"), group = "internal")public class InternalApiConfig { }Step 4: Add Metrics
@Configurationpublic class MetricsConfig { @Bean public TimedAspect timedAspect(MeterRegistry registry) { return new TimedAspect(registry); }}@Timed(value = "api.users", extraTags = {"access", "external"})@GetMapping("/api/v1/users")public ResponseEntity<List<UserResponse>> getUsers() { ... }Summary
In this post, I showed how to organize Spring Boot controllers by domain rather than access type. The key points are:
- Organize controllers by business domain, keeping related endpoints together
- Use URL path prefixes (
/api/v1/*vs/internal/*) to separate access boundaries - Configure security by path pattern, not by controller class
- Consider separate controllers only when contracts fundamentally differ
- Extract business logic to service layer to avoid duplication
This approach keeps related code together while maintaining clear boundaries between public and private endpoints. It scales well as services grow and makes the codebase easier to navigate and maintain.
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:
- π¨βπ» Spring Security - Request Authorization
- π¨βπ» Spring Cloud Gateway Documentation
- π¨βπ» Domain-Driven Design by Eric Evans
- π¨βπ» Spring Boot - @RestController Best Practices
Oh, and if you found these resources useful, donβt forget to support me by starring the repo on GitHub!
Comments