Spring Boot WebFlux MongoDB multi-tenancy implementation

In this post, we are going to implement multi-tenancy using Spring Boot WebFlux and MongoDB.

Multitenancy applications allow multiple customers or tenants to use a single resource without seeing each other’s data. This is highly common in SaaS solutions. There are three main approaches to isolating information in these multitenant systems (Separate database, Separate schema, Partitioned (Discriminator) Data).

Each solution approach has its pros and cons. It is recommended to implement an approach according to your use cases.

Get Started

In this story, we will implement the approach with a separate database. So each tenant’s data is kept in a physically separate database instance.

Prerequisites

  • Spring Boot 2.4
  • Maven 3.6.+
  • JAVA 17
  • Mongo 4.4

We will start by creating a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Reactive Web, Spring Data Reactive MongoDB, and Lombok.

Resolving the Current Tenant ID

The goal is to pass the current tenant ID to the controller using an HTTP header named X-Tenant. Let’s create a WebFilter that will intercept all HTTP requests.

@Component
public class TenantFilter implements WebFilter {

   @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
       return Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst(AppConstant.TENANT_HEADER_KEY))
               .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
               .flatMap(tenantKey -> chain.filter(exchange).contextWrite(ctx -> ctx.put(AppConstant.TENANT_ID, tenantKey)));

    }
}

When the tenant id is intercepted, we save it in a reactor context which will be available to any class that requests it.

Get Tenant Data Sources

The second main purpose is to load the tenant data sources and select the corresponding data source based on the tenantId retrieved from the context. For this story, we will define data sources for all tenants in the YAML file.

tenants:
  datasources:
    - id: tenant1
      host: localhost
      port: 27017
      database: booktenant1
      username: admin
      password: admin
    - id: tenant2
      host: localhost
      port: 27017
      database: booktenant2
      username: admin
      password: admin
    - id: tenant3
      host: localhost
      port: 27017
      database: booktenant3
      username: admin
      password: admin

In the next step, we need to create Mongo Database instances from a MongoClient instance using SimpleReactiveMongoDatabaseFactory class.

@Configuration
public class MultiTenantMongoDBFactory extends SimpleReactiveMongoDatabaseFactory {

    private final MongoDataSources mongoDataSources;

    /**
     * @param mongoClient default mongo client connection
     * @param databaseName default database
     * @param mongoDataSources datasource bean component
     */
    public MultiTenantMongoDBFactory(@Qualifier("createMongoClient") MongoClient mongoClient, String databaseName, MongoDataSources mongoDataSources) {
        super(mongoClient, databaseName);
        this.mongoDataSources = mongoDataSources;
    }

    @Override
    public Mono<MongoDatabase> getMongoDatabase() throws DataAccessException {
        return mongoDataSources.mongoDatabaseCurrentTenantResolver();
    }

}

The Override getMongoDatabase method is the part that loads the current data source. It calls our custom bean MongoDataSources.

@Component
public class MongoDataSources {


    private List<TenantClient> tenantClients;

    private TenantClient defaultTenant = new TenantClient();

    private final DataSourceProperties dataSourceProperties;

    public MongoDataSources(DataSourceProperties dataSourceProperties) {
        this.dataSourceProperties = dataSourceProperties;
    }


    /**
     * Initialize all mongo tenant datasource
     */
    @PostConstruct
    @Lazy
    public void initTenant() {
        tenantClients = new ArrayList<>();
        List<TenantDatasource> tenants = dataSourceProperties.getDatasources();
        tenantClients = tenants.stream().map(t -> new TenantClient(t.getId(), t.getDatabase(),t.getPort(), t.getHost(), t.getUsername(), t.getPassword())).toList();
        tenantClients.stream().findFirst().ifPresent(t -> defaultTenant = t);
    }

    /**
     * Default Database name for spring initialization. It is used to be injected into the constructor of MultiTenantMongoDBFactory.
     *
     * @return String of default database.
     */
    @Bean
    public String databaseName() {
        return defaultTenant.getDatabase();
    }

    /**
     * Default Mongo Connection for spring initialization.
     * It is used to be injected into the constructor of MultiTenantMongoDBFactory.
     */
    @Bean
    public MongoClient createMongoClient() {
        MongoCredential credential = MongoCredential.createCredential(defaultTenant.getUsername(), defaultTenant.getDatabase(), defaultTenant.getPassword().toCharArray());
        return MongoClients.create(MongoClientSettings.builder()
                .applyToClusterSettings(builder ->
                        builder.hosts(Collections.singletonList(new ServerAddress(defaultTenant.getHost(),defaultTenant.getPort()))))
                .credential(credential)
                .build());
    }

    /**
     * This will get called for each DB operations
     *
     * @return MongoDatabase
     */
    public Mono<MongoDatabase> mongoDatabaseCurrentTenantResolver() {

        return Mono
                .deferContextual(Mono::just)
                .filter(ct -> ct.hasKey(AppConstant.TENANT_ID))
                .map(ct -> ct.get(AppConstant.TENANT_ID))
                .map(tenantId -> {
                    TenantClient currentTenant = getCurrentTenant(tenantId.toString());
                    return currentTenant.getClient().getDatabase(currentTenant.getDatabase());
                });
    }


    /**
     * @return TenantClient tenant client.
     */
    private TenantClient getCurrentTenant(String tenantId){
      return tenantClients.stream().filter(c -> c.getId().equals(tenantId))
                .findFirst().orElseThrow(() -> new TenantDataSourceNotFoundException("Tenant ID not found"));
    }
}

CRUD sample

Let’s create an example of CRUD with a Book document.

@Data
@AllArgsConstructor
@NoArgsConstructor
@Document
public class Book {

    @Id
    private String id;

    private String title;

    private int page;

    private String isbn;

    private String description;

    private double price;

    private LocalDate publicationDate;

    private String language;
}

Also we need to create BookRepositoryBookService and BookController.

@Service
@Slf4j
public record BookServiceImpl(BookRepository bookRepository) implements BookService{

    @Override
    public Flux<Book> getAllBook() {
        return  bookRepository.findAll();
    }

    @Override
    public Mono<Book> addBook(Book book) {
        LOGGER.info("addBook : {} " , book );
        return bookRepository.save(book)
                .log();
    }

    @Override
    public Mono<Book> getBookById(String id) {
        return null;
    }

    @Override
    public Mono<Book> updateBook(Book book, String id) {
        return bookRepository.findById(id)
                .flatMap(book1 -> {
                    book1.setTitle(book.getTitle());
                    book1.setIsbn(book.getIsbn());
                    book1.setPublicationDate(book.getPublicationDate());
                    return bookRepository.save(book1);
                });
    }

    @Override
    public Mono<Void> deleteBookById(String id) {
        return bookRepository.deleteById(id);
    }
}

Test the REST APIs:

Run the Spring Boot Application.

curl — location — request POST ‘http://localhost:8080/v1/book’ \
— header ‘X-Tenant: tenant2’ \
— header ‘Content-Type: application/json’ \
— data-raw ‘{
“id”:”1234567890jjh9poj”,
“title”:”Thejjj”,
“totalPage”:480,
“isbn”:”726971586–6″,
“description”: “book description”,
“price”: 500
}’

  • Get data with the wrong tenant ID

Conclusion

In this story, we implemented multi-tenancy using Spring Boot WebFlux and MongoDB.

The complete source code is available on GitHub.

References

👉 Link to Medium blog

** Cover image by 30daysreplay Social Media Marketing on Unsplash

Related Posts