For ninkik I wanted to provide a subdomain for each of our customers. In addition to that, I wanted to use some other subdomains for static and dynamic content. At that moment I did explicitly not wanted to deal with microservices or multiple applications.
When receiving a request, Spring should do the following:
- Is the subdomain one of our known subdomains and mapped to a @Controller annotated class? If yes, use anntoated given controller.
- Belongs the subdomain to one of our tenants and is mapped to a @Controller annotated class? If yes, use the given controller.
- In any other case, use any @Controller annotated class which can handle the requested path. The subdomain does not matter.
If you don’t care about the details, you can directly purchase a complete demo project on Gumroad:
Blueprint: Subdomain handling with Spring Boot and Spring Web MVCResearching the topic
Spring Boot / Spring MVC is not able to deal with subdomains so I had to dig into how to make it work.
I have not been the first one with this kind of requirement. But not many resources are out there and up-to-date. If you are searching for this topic, you should definitely look at
- Custom registrations with Spring Boot 2.x #21571 and the demo repository
- SPRING MVC & CUSTOM ROUTING CONDITIONS
- Providing Multitenancy with Spring Boot which is a good article about multi-tenancy but not about handling subdomains in multi-tenancy applications
- Custom RequestMappingHandlerMapping for API version control
Basically, you have a custom annotation like @SubdomainController and some kind of implementation of org.springframework.web.servlet.mvc.condition.RequestCondition.
Both are tied together with a custom implementation of org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.
All of the links above don’t describe how you can have mulitple RequestConditions to deal with both known subdomains and dynamically resolved subdomains for tenants.
Creating annotations for subdomain and tenant controllers
In my case I needed two annotations: @SubdomainController and @TenantController.
Both annotations are straight forward. In my case I just wanted them to be applied only on the class level but not on methods. A class annotated with @SubdomainController should handle multiple subdomains.
@Controller
@RequestMapping
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface SubdomainController {
String[] value();
}
For example, you should be able to access either http://www.${your-domain} or http://test.${your-domain} with the following class definition:
@SubdomainController(value = { "www", "test" })
public class PublicSubdomainController {
@RequestMapping(value = { "", "/" })
public ResponseEntity<?> index() {
return ResponseEntity.ok("Hello from www.${your-domain} or test.${your-domain}");
}
}
A @TenantController should be applicable to any class and handle requests to a tenant subdomain.
@Controller
@RequestMapping
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantController {
}
I wanted to use something like this
@TenantController
public class TenantController {
@RequestMapping("/")
public ResponseEntity<?> index() {
return ResponseEntity.ok(String.format("Hello from tenant %s", TenantContext.getTenant().getName()));
}
}
RequestCondition checks if a request mapping is considered for an HTTP request
After reading the links above, I understand that both annotations needed a belonging RequestCondition implementation.
A RequestCondition is called for each HttpServletRequest and checks if a given conditions matches. Not more, not less.
public class SubdomainRequestCondition implements RequestCondition<SubdomainRequestCondition> {
@Override
public SubdomainRequestCondition getMatchingCondition(HttpServletRequest request) {
SubdomainRequestCondition r = null;
// you would check for the assigned subdomains
if (request.getServerName().startsWith("mydomain")) {
return this;
}
return null;
}
@Override
public int compareTo(SubdomainRequestCondition other, HttpServletRequest request) {
return 0;
}
@Override
public SubdomainRequestCondition combine(SubdomainRequestCondition other) {
// not relevant at the moment
return null;
}
}
It took me a while to understand the following important facts about a RequestCondition:
- It can be attached to any bean during the initialization the Spring Boot application.
- It does not know anything about the controller nor the annotation it has been currently attached to.
- During an HTTP request, Spring checks the assigned RequestCondition -if any- for each registered @RequestMapping. If RequestCondition.getMatchingCondition() returns its own instance (not null), the annotated controller is considered to be used for the current request.
- There can only be one RequestCondition assigned to a controller instance. If you need to check multiple RequestConditions you can use org.springframework.web.servlet.mvc.condition.CompositeRequestCondition.
Checking for a tenant subdomain with TenantRequestCondition
For classes annotated with @TenantController we use a custom RequestCondition: TenantRequestCondition performs a lookup against a TenantRepository. It contains all tenants with their belonging subdomains:
public class TenantRequestCondition implements RequestCondition<TenantRequestCondition> {
// ...
@Override
public TenantRequestCondition getMatchingCondition(HttpServletRequest request) {
String subdomain = SubdomainUtil.extractSubdomain(request);
TenantRequestCondition r = null;
Optional<Tenant> tenant = tenantRepository.findBySubdomain(subdomain);
// ... set tenant context etc.
if (tenant.isPresent()) {
return this;
}
return null;
}
}
If the tenant’s subdomain is known, the controller is used. ThreadLocal is used for storing the current tenant in the request.
Assigning RequestConditions to Spring MVC controllers
During application start-up an instance of the class RequestMappingHandlerMapping is used for resolving the request mappings. For each registered handler (e.g. a controller instance class or method) having a @RequestMapping annotated, getCustomTypeCondition() or getCustomMethodCondition() is called.
To register our SubdomainRequestCondition and TenantRequestCondition above, we have to provide our own RequestMappingHandlerMapping instance:
public SubdomainRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
@Autowired
TenantRepository tenantRepository;
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
// if the given class is annotated with SubdomainController, we prefer it
SubdomainController subdomainController = null;
if ((subdomainController = AnnotationUtils.findAnnotation(handlerType, SubdomainController.class)) != null) {
// we need to extract the mapped subdomains as we don't have access to this
// information during runtime
return new SubdomainRequestCondition(new HashSet<>(Arrays.asList(subdomainController.value())));
}
// ... do something with the TenantController ...
// no SubdomainController or TenantController annotation? Then don't use any
// conditions and fallback to the default handling without any condiitions.
return null;
}
}
The getCustomTypeCondition() method is only called if a @RequestMapping is applied on class level. It checks if the class has a @SubdomainController annotation. Due to my @SubdomainController definition above, this is always the case.
If the @SubdomainController annotation is present, we assign the class our new RequestCondition. We have to pass the assigned subdomains during start-up. As already mentioned before, the RequestCondition does not know anything about the controller in the current HTTP request context.
Registering the RequestMappingHandlerMapping
The last step was to register our SubdomainRequestMappingHandlerMapping inside Spring MVC. You can only have one instance of RequestMappingHandlerMapping in your application. The configuration interface WebMvcRegistrations allows us to overwrite the default RequestMappingHandlerMapping instance and replace it with our own:
@Component
public class CustomWebMvcRegistrations implements WebMvcRegistrations {
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new SubdomainRequestMappingHandlerMapping();
}
}
What I have achieved
The code above solves my requirements to have a single Spring Boot web application dealing with multiple subdomains:
- I can have subdomains like microsite1.mydomain.com and microsite2.mydomain.com which can be handled by the same Spring Boot web applications.
- Additionally I can have subdomains like tenant1.mydomain.com and tenant2.mydomain.com. These subdomains can be looked up in a database and be dynamically generated.
- I can still use @Controller annotations to fallback to any unknown subdomain.
If you need the whole demo project, you can purchase it on Gumroad:
Blueprint: Subdomain handling with Spring Boot and Spring Web MVC