28 Sep 2019

Schema-based multi-tenancy with Spring Data, Hibernate and Flyway

Multi-tenancy is an architectural pattern that allows multiple tenants to use a single instance of software, the purpose of which is to provide each tenant with a dedicated share of the instance but to isolate the information belonging to each tenant.
In this tutorial, we are going to look at how to implement schema-based multi-tenancy in Spring Boot application.

🚀 Boost Your Team with TeamPulse

TeamPulse provides engineering teams with data-driven insights to boost productivity, enhance collaboration, and improve team health. Stay on top of your team's performance and make informed decisions with ease.

Try It Free

1. Project

We will start by creating a simple RESTful web service, protected by Spring Security, that will use Spring Data JPA to persist data in the embedded H2 database.

1.1. Maven Dependencies

First, we add the necessary dependencies to pom.xml:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

1.2. Entities

Next, we create entity classes that represent our domain objects:

@Entity
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    private String password;

    // getters, setters and overriden methods from UserDetails
}
@Entity
public class Note {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String text;
    
    // getters and setters
}

1.3. Repositories

Then we define CRUD repositories for the entities:

@Repository
public interface UserRepository extends CrudRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
@Repository
public interface NoteRepository extends CrudRepository<Note, Long> {
}

1.4. Services

Now we can implement some logic in the service layer:

@Service
public class UserService implements UserDetailsService {

    private UserRepository repository;
    private PasswordEncoder encoder;

    public UserService(UserRepository repository) {
        this.repository = repository;
        this.encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    public User createUser(User user) {
        String encodedPassword = encoder.encode(user.getPassword());
        user.setPassword(encodedPassword);
        return repository.save(user);
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return repository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User with the specified username is not found"));
    }
}
@Service
public class NoteService {

    private NoteRepository repository;

    public NoteService(NoteRepository repository) {
        this.repository = repository;
    }

    public Note createNote(Note note) {
        return repository.save(note);
    }

    public Note findNote(Long id) {
        return repository.findById(id).orElseThrow();
    }

    public Iterable<Note> findAllNotes() {
        return repository.findAll();
    }
}

1.5. Controllers

Finally, we can expose our web service with REST API:

@RestController
@RequestMapping("/users")
public class UserController {

    private UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<User> register(@RequestBody User user) {
        User created = userService.createUser(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}
@RestController
@RequestMapping("/notes")
public class NoteController {

    private NoteService noteService;

    public NoteController(NoteService noteService) {
        this.noteService = noteService;
    }

    @PostMapping
    public ResponseEntity<Note> createNote(@RequestBody Note note) {
        Note created = noteService.createNote(note);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Note> getNote(@PathVariable Long id) {
        Note note = noteService.findNote(id);
        return ResponseEntity.ok(note);
    }

    @GetMapping
    public ResponseEntity<Iterable<Note>> getAllNotes() {
        Iterable<Note> notes = noteService.findAllNotes();
        return ResponseEntity.ok(notes);
    }
}

1.6. Security

The web service is ready and we can run it, but all of our API endpoints are publicly available. To protect some of them, we need to configure http security in a configuration class that extends WebSecurityConfigurerAdapter. We will make the user registration endpoint public, and also open access to the H2 console, which can be enabled using the spring.h2.console.enabled = true parameter. All other endpoints will require authentication, but in order not to complicate the example with some token-based authentication, we just enable http basic authentication for them:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private UserDetailsService userService;

    public SecurityConfig(UserDetailsService userService) {
        this.userService = userService;
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/h2-console/**").permitAll()
                .antMatchers(HttpMethod.POST, "/users").permitAll()
                .anyRequest().authenticated()
                .and()
                .httpBasic()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .csrf().disable()
                .headers().frameOptions().disable();
    }
}

Note that we use the UserService to provide the authentication manager with user details. For this purpose, we made it to implement UserDetailsService, and we made our User class to implement UserDetails.

2. Multi-tenancy

Typically, a tenant would be a group of users, such as an organization, but in our simplified implementation, each user will be a separate tenant.

Also you probably already noticed that, although we can create notes on behalf of different users, all of them are saved in one table, and all users have access to all notes. The obvious solution would be to record the owner of the notes in the database and retrieve the notes for each user using this data. This is the most common multi-tenancy option, but since we want more isolation between tenant data, this option does not suit us. We would get the greatest isolation when each tenant uses a separate database, but this complicates and increases the cost of our infrastructure. But there is a third option that is cheaper and still provides partial isolation: the schema-based multi-tenancy.

2.1. Hibernate configuration

We are going to use the multi-tenancy features of Hibernate, which is the default JPA provider in Spring Data. All we need to do is provide an implementation of the CurrentTenantIdentifierResolver and MultiTenantConnectionProvider interfaces, and add them to the JPA properties along with the multi-tenancy strategy.

CurrentTenantIdentifierResolver - resolves the tenant identifier to use. In our implementation, it gets the authentication data from the security context and uses the username as the identifier of the tenant; if authentication is anonymous or missing, it falls back to the default tenant identifier.

@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {

    static final String DEFAULT_TENANT = "default";

    @Override
    public String resolveCurrentTenantIdentifier() {
        return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .filter(Predicate.not(authentication -> authentication instanceof AnonymousAuthenticationToken))
                .map(Principal::getName)
                .orElse(DEFAULT_TENANT);
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

MultiTenantConnectionProvider – provides connections based on tenant identifier. Our implementation reuses the JDBC connection pool to serve all tenants, but before using the Connection, it alters it with the SET SCHEMA command to reference the schema named by the tenant identifier.

@Component
public class TenantConnectionProvider implements MultiTenantConnectionProvider {

    private DataSource datasource;

    public TenantConnectionProvider(DataSource dataSource) {
        this.datasource = dataSource;
    }

    @Override
    public Connection getAnyConnection() throws SQLException {
        return datasource.getConnection();
    }

    @Override
    public void releaseAnyConnection(Connection connection) throws SQLException {
        connection.close();
    }

    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        final Connection connection = getAnyConnection();
        connection.createStatement()
                .execute(String.format("SET SCHEMA \"%s\";", tenantIdentifier));
        return connection;
    }

    @Override
    public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
        connection.createStatement()
                .execute(String.format("SET SCHEMA \"%s\";", TenantIdentifierResolver.DEFAULT_TENANT));
        releaseAnyConnection(connection);
    }

    @Override
    public boolean supportsAggressiveRelease() {
        return false;
    }

    @Override
    public boolean isUnwrappableAs(Class unwrapType) {
        return false;
    }

    @Override
    public <T> T unwrap(Class<T> unwrapType) {
        return null;
    }
}

It remains only to set all the necessary parameters in the configuration:

@Configuration
public class HibernateConfig {

    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        return new HibernateJpaVendorAdapter();
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource, JpaProperties jpaProperties,
            MultiTenantConnectionProvider multiTenantConnectionProvider, CurrentTenantIdentifierResolver tenantIdentifierResolver) {

        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan(MultiTenancyApplication.class.getPackage().getName());
        em.setJpaVendorAdapter(jpaVendorAdapter());

        Map<String, Object> jpaPropertiesMap = new HashMap<>(jpaProperties.getProperties());
        jpaPropertiesMap.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
        jpaPropertiesMap.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
        jpaPropertiesMap.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantIdentifierResolver);
        em.setJpaPropertyMap(jpaPropertiesMap);

        return em;
    }
}

2.2. Flyway configuration

The tables in the shared schema for unauthenticated users and in the tenant schemas will be different, so we need a way to perform different migrations in these schemas, and we will use Flyway for this. We configure it to start migrations for the default schema from the db/migration/default directory, and then iterate over all tenants and start migrations for each of them from the db/migration/tenants directory.

@Configuration
public class FlywayConfig {

    @Bean
    public Flyway flyway(DataSource dataSource) {
        Flyway flyway = Flyway.configure()
                .locations("db/migration/default")
                .dataSource(dataSource)
                .schemas(TenantIdentifierResolver.DEFAULT_TENANT)
                .load();
        flyway.migrate();
        return flyway;
    }

    @Bean
    CommandLineRunner commandLineRunner(UserRepository repository, DataSource dataSource) {
        return args -> {
            repository.findAll().forEach(user -> {
                String tenant = user.getUsername();
                Flyway flyway = Flyway.configure()
                        .locations("db/migration/tenants")
                        .dataSource(dataSource)
                        .schemas(tenant)
                        .load();
                flyway.migrate();
            });
        };
    }
}

Next, we will create the necessary migrations in the appropriate directories:

-- db/migration/default/V1__init_schema.sql
CREATE TABLE user
(
    id                 BIGINT AUTO_INCREMENT,
    username           VARCHAR(255) UNIQUE,
    password           VARCHAR(255)
);
-- db/migration/tenants/V1__init_schema.sql
CREATE TABLE note
(
    id                 BIGINT AUTO_INCREMENT,
    text               TEXT
);

We also create a service for programmatically creating a scheme for new tenants and performing all relevant migrations:

@Component
public class TenantService {

    private DataSource dataSource;

    public TenantService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void initDatabase(String schema) {
        Flyway flyway = Flyway.configure()
                .locations("db/migration/tenants")
                .dataSource(dataSource)
                .schemas(schema)
                .load();
        flyway.migrate();
    }
}

Finally, we update our user service and make it initiate the creation of a schema for new users:

@Service
public class UserService implements UserDetailsService {

    // ...
    
    @Transactional
    public User createUser(User user) {
        String encodedPassword = encoder.encode(user.getPassword());
        user.setPassword(encodedPassword);
        User saved = repository.save(user);
        tenantService.initDatabase(user.getUsername());
        return saved;
    }
}

3. Test

Now we can run our application and test it. We start by creating multiple users:

 
> curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"john\",\"password\":\"password\"}" http://localhost:8080/users
{"id":1,"username":"john"}
> curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"jane\",\"password\":\"qwerty123\"}" http://localhost:8080/users
{"id":2,"username":"jane"}

We create a note using John’s credentials:

 
> curl -u john:password -X POST -H "Content-Type: application/json" -d "{\"text\":\"Hello from John!\"}" http://localhost:8080/notes
{"id":1,"text":"Hello from John!"}

Then we also create a note using Jane’s credentials:

 
> curl -u jane:qwerty123 -X POST -H "Content-Type: application/json" -d "{\"text\":\"Hello from Jane!\"}" http://localhost:8080/notes
{"id":1,"text":"Hello from Jane!"}

We can notice that the generated note ID is the same because notes were stored in separate tables in schemas specific to the tenant.

Finally, we will request all notes using the credentials of one of the users and make sure that we only get notes belonging to this user:

 
> curl -u jane:qwerty123 http://localhost:8080/notes
[{"id":1,"text":"Hello from Jane!"}]

Also, to make sure that everything works as we intended, we can go to http://localhost:8080/h2-console and use the H2 console, access to which we opened earlier.

4. Conclusion

Schema-based multi-tenancy provides the best balance between performance, tenant isolation, development complexity, and infrastructure cost. However, when choosing a multi-tenancy strategy, you should always consider the level of security that your tenants will require and indicate the level of data isolation that you provide in the Service-Level Agreement: either full or partial. Besides, sharing resources in a schema-based approach can make SLA compliance difficult, as one tenant performing resource-intensive tasks can cause latency spikes for everyone else.

Full source code can be found on GitHub.

đź’ˇ Smarter Team Decisions Made Easy

With TeamPulse, monitor team health, gain real-time productivity insights, and receive actionable recommendations—all seamlessly integrated with your existing tools.

Learn More