Showing posts with label spring-framework. Show all posts
Showing posts with label spring-framework. Show all posts

Building a Dual-Mode Monolith/Microservices System with Spring Boot - One Codebase, Two Topologies

How to build a single codebase that deploys as either a monolith or independent microservices — and why you might want to.


Monolith-to-microservices rewrites are expensive, and most teams don't actually know which topology they need until they're already committed to one. The usual fix is to guess, build, and rewrite later when the guess turns out wrong.

There's a third option: ship both from day one, from the same codebase, the same tests, and the same CI pipeline.

That's what the dual-architecture pattern does. Three Spring Boot services — Order, Inventory, and Shipping — live in one repo, share one set of interfaces, and deploy identically whether they're running as a single JAR or as independently scaled services. Switching topology is a single property: deployment.mode.

In this post, I'll walk through how it works, how to set it up, and how it compares to Spring Modulith — along with the trade-offs that matter once you're past the prototype stage.


The Problem It Solves

Scenario Monolith Microservices
Early-stage startup ✅ Fast to build, one deployable ❌ Operational overhead kills velocity
Enterprise with dedicated ops ❌ Can't scale teams independently ✅ Teams own their services end-to-end
On-premise customer ✅ Single JAR, simple install ❌ Requiring Kubernetes is a dealbreaker
Cloud-native customer ❌ Wastes resources scaling monolithically ✅ Scales inventory independently of ordering
Vendor selling to both Needs to support both at once, without maintaining two codebases

If you're building software that different customers deploy differently — or you're a growing team that wants to defer the monolith-vs-microservices decision — you need a codebase that supports both topologies without forking. That's what this architecture delivers.


How It Works: The Three Pillars

Pillar 1: A Shared Contract (the Client Interface)

Every cross-service interaction starts with a plain Java interface in a shared common-api module:

// common-api/src/main/java/com/monoservice/common/api/InventoryClient.java
public interface InventoryClient {
    boolean checkStock(String sku, int quantity);
    ReservationResult reserveStock(String sku, int quantity);
    void releaseStock(String sku, int quantity);
    int getAvailableQuantity(String sku);
}

Domain services never know whether the implementation lives in-process or across the network — they just inject the interface:

// order-service/.../OrderDomainService.java
@Service
@RequiredArgsConstructor
public class OrderDomainService {
    private final InventoryClient inventoryClient;  // ← just the interface
    private final OrderEventGateway eventGateway;

    @Transactional
    public OrderResult placeOrder(PlaceOrderRequest request) {
        if (!inventoryClient.checkStock(request.sku(), request.quantity())) {
            throw new InsufficientStockException(request.sku());
        }
        inventoryClient.reserveStock(request.sku(), request.quantity());
        // ... persist order, publish event
    }
}

No if (mode == MONOLITH) branches, no proxy magic — just polymorphism, wired by the container.

Pillar 2: Two Implementations, Conditionally Wired

For each Client interface, you write two implementations, and Spring picks the right one based on a property:

// Monolith mode — direct method call, same JVM, same transaction
@Component
@ConditionalOnProperty(name = "deployment.mode", havingValue = "monolith")
public class InventoryClientLocal implements InventoryClient {

    private final InventoryDomainService domainService;

    @Override
    public boolean checkStock(String sku, int quantity) {
        return domainService.checkStock(sku, quantity);
    }

    @Override
    public ReservationResult reserveStock(String sku, int quantity) {
        return domainService.reserveStock(sku, quantity);
    }
    // ...
}
// Microservices mode — HTTP call via Feign
@FeignClient(name = "inventory-service",
             url = "${services.inventory.url:http://inventory-service:8080}")
@ConditionalOnProperty(name = "deployment.mode", havingValue = "microservices")
public interface InventoryClientRemote extends InventoryClient {

    @Override
    @GetMapping("/api/inventory/check")
    boolean checkStock(@RequestParam("sku") String sku, @RequestParam("qty") int quantity);

    @Override
    @PostMapping("/api/inventory/reserve")
    ReservationResult reserveStock(@RequestParam("sku") String sku,
                                    @RequestParam("qty") int quantity);
    // ...
}

deployment.mode=monolith activates the local adapter; deployment.mode=microservices activates the Feign client. @ConditionalOnProperty handles the rest — no custom condition classes, no proxy factories, no bytecode generation. It's plain Spring you already know.

Pillar 3: Dual-Path Event Routing

Cross-service asynchronous communication follows the same pattern. Each service defines an event gateway that publishes identically in both modes:

@Component
public class OrderEventGateway {
    private final ApplicationEventPublisher eventPublisher;
    private final ObjectProvider<KafkaTemplate<String, Object>> kafka;

    public void publish(Object event) {
        eventPublisher.publishEvent(event);  // always — for in-process listeners

        kafka.ifAvailable(template ->        // only when Kafka is on the classpath
            template.send("order.events." + event.getClass().getSimpleName(), event)
        );
    }
}

On the receiving side, two listeners — only one active per mode — handle the same event, both implementing a shared OrderPlacedHandler interface to keep the contract explicit and testable:

// Monolith: synchronous, within the publishing transaction's lifecycle
@Component
@ConditionalOnProperty(name = "deployment.mode", havingValue = "monolith")
public class InventoryOrderPlacedListener implements OrderPlacedHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(OrderPlacedEvent event) {
        domainService.reserveStock(event.sku(), event.quantity());
    }
}

// Microservices: async, from Kafka
@Component
@ConditionalOnProperty(name = "deployment.mode", havingValue = "microservices")
public class InventoryOrderPlacedKafkaListener implements OrderPlacedHandler {

    @KafkaListener(topics = "order.events.OrderPlacedEvent",
                   groupId = "inventory-service")
    public void handle(OrderPlacedEvent event) {
        domainService.reserveStock(event.sku(), event.quantity());
    }
}

The Project Structure

Putting it together, the repo looks like this:

mono-service/
├── common-api/              # Shared interfaces, DTOs, events, Feign clients
├── order-service/           # Own @SpringBootApplication, port 8081
├── inventory-service/       # Own @SpringBootApplication, port 8082
├── shipping-service/        # Own @SpringBootApplication, port 8083
├── monolith-app/            # Aggregator — depends on all three, port 8080
├── docker/                  # Dockerfile + docker-compose (both modes)
├── k8s/                     # Kubernetes manifests — monolith/ and microservices/
└── e2e/                     # Mode-agnostic test suite (29 assertions)

Each service is a standalone Spring Boot application, with its own main method, its own configuration, and its own database migrations. monolith-app is just a fourth application that bundles all three as Gradle dependencies, scans the unified package namespace, and excludes the Kafka- and Feign-related autoconfiguration it doesn't need.

The Mode Switch in Configuration

Monolith (monolith-app/src/main/resources/application-monolith.yml):

deployment:
  mode: monolith

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
      - org.springframework.cloud.openfeign.FeignAutoConfiguration
  datasource:
    url: jdbc:postgresql://localhost:5432/shop
  liquibase:
    enabled: true          # One DB, three schemas
  flyway:
    enabled: false

Microservices (order-service/src/main/resources/application.yml):

deployment:
  mode: microservices

spring:
  kafka:
    bootstrap-servers: localhost:9092
  flyway:
    enabled: true           # Per-service database

services:
  inventory:
    url: http://inventory-service:8080
  shipping:
    url: http://shipping-service:8080

Database Strategy

This is where the pattern gets interesting — the dual architecture handles database evolution differently in each mode:

Aspect Monolith Microservices
Databases One PostgreSQL (shop) Three PostgreSQL (orders, inventory, shipping)
Schemas Three schemas in one DB One schema per DB
Migrations Liquibase (runs all SQL in order) Flyway (per-service, independent)
SQL files Same files, different tool Same files, different tool
Cross-schema FKs None — application-level integrity None — application-level integrity

The SQL migration files are authored once and consumed by both tools. Liquibase references them via a master changelog; Flyway picks them up natively from each service's classpath. This avoids the single worst failure mode of dual architectures: diverging schema definitions.

No cross-schema foreign keys means no mode-specific data integrity rules. If you wouldn't trust it across the network, don't trust it across schemas.


How Spring Modulith Compares

If you're thinking "this sounds like Spring Modulith," you're right — and the differences are instructive.

Spring Modulith verifies and enforces module boundaries within a modular monolith. It gives you:

  • Module structure verification — ArchUnit-style tests that catch illegal dependencies between modules at build time.
  • Event publication — an ApplicationModuleListener annotation that formalizes in-process event handling.
  • Documentation — auto-generated diagrams of your module topology.
  • Testing supportModuleTest for testing modules in isolation.

What Modulith doesn't do is provide a config-driven switch between local method calls and remote HTTP calls — it assumes you're a monolith. If you later split out a module into its own service, you're rewriting the integration points: replacing direct calls with HTTP, adding circuit breakers, dealing with distributed transactions.

The dual-architecture pattern instead writes the integration point once (the Client interface), implements both paths from the start, and lets a property choose. The cost is upfront: two implementations per interface, conditional beans, dual-tool database migrations. The payoff is that you never rewrite an integration.

Concern Spring Modulith Dual Architecture
Module boundary enforcement ✅ Compile-time verification ⚠️ By convention only
Deploy as monolith ✅ Natural ✅ Natural
Deploy as microservices ❌ Requires rewrite of integrations ✅ Built-in, same artifact
Event system @ApplicationModuleListener Spring Events + Kafka (conditional)
Database strategy Single DB recommended Single DB or separate DBs
Learning curve Low (annotations you already know) Medium (more moving parts)
Framework dependency Heavy (spring-modulith-starter) None (plain Spring Boot + Feign)
Good for Teams committed to monolith, wanting discipline Teams that need both topologies from day one

Neither is "better" — they solve different problems. If you're sure you'll be a monolith forever, Modulith's compile-time verification and lighter footprint win. If you might split, or you sell to customers who need different topologies, the dual architecture prevents a rewrite.


What Makes It Work (and What Doesn't)

What works well

  1. @ConditionalOnProperty is the right abstraction level. You don't need a custom framework — a single property, standard Spring annotations, and a discipline of always pairing local and remote implementations is enough.

  2. Controllers implementing the Client interfaces catches drift at compile time. If InventoryController implements InventoryClient, the compiler guarantees the REST endpoint signatures match the Feign client's expectations. No more "the URL changed but the client wasn't updated" bugs.

  3. The same E2E tests run in both modes. The test suite (run-tests.sh) is parameterized only by base URLs and asserts the same 29 behaviors against monolith and microservices deployments. If the tests pass in both modes, you haven't introduced a topology-dependent bug.

  4. The aggregator app is a separate module, not a profile. monolith-app is its own Gradle module that depends on all three services — it doesn't reconfigure them internally. This means each service can still be built, tested, and deployed independently; the monolith is a consumer of services, not a special mode of each one.

What's tricky

  1. The Feign clients live in common-api, which pulls spring-cloud-starter-openfeign into every module — including ones that never make an HTTP call in monolith mode. You end up excluding it manually in the aggregator (as shown above). A cleaner alternative is to move the Feign interfaces into the consumer module (e.g., InventoryClientRemote inside order-service), keeping common-api free of network dependencies entirely.

  2. Event semantics differ silently between modes. In monolith mode, events are synchronous and transactional (@TransactionalEventListener(AFTER_COMMIT)). In microservices mode, events are async and at-least-once (Kafka). Code that works in one mode can fail subtly in the other — duplicate processing, missing transactional rollback, reordering. The only real defense is idempotency keys on every event, plus running the full test suite in both modes on every change.

  3. There's no compile-time module boundary check. Unlike Modulith, nothing stops order-service from importing inventory-service's entity classes directly and bypassing the InventoryClient abstraction entirely. You need either discipline or an ArchUnit test that bans cross-module imports.


# Clone and build
./gradlew build -x test

# Run as monolith — all three services in one process
SPRING_PROFILES_ACTIVE=monolith ./gradlew :monolith-app:bootRun

# Or run as microservices — three independent processes
./gradlew :order-service:bootRun &
./gradlew :inventory-service:bootRun &
./gradlew :shipping-service:bootRun &

# Same E2E tests, both modes
MODE=monolith BASE_URL=http://localhost:8080 ./e2e/run-tests.sh
MODE=microservices \
  ORDER_URL=http://localhost:8081 \
  INVENTORY_URL=http://localhost:8082 \
  SHIPPING_URL=http://localhost:8083 \
  ./e2e/run-tests.sh

Key Takeaways

  • One codebase, two deployment topologies is a real, practical architecture — not an academic exercise.
  • Three pillars make it work: shared Client interfaces, conditionally-wired local/remote implementations, and dual-path event routing.
  • Spring Modulith and this pattern solve different problems. Modulith enforces monolith discipline; the dual architecture enables topology switching.
  • @ConditionalOnProperty is simpler and more transparent than a custom framework for this.
  • The biggest risk is mode-specific behavioral divergence — mitigate it with idempotency, identical E2E tests in both modes, and compile-time contract verification wherever you can get it.

For teams selling the same product into both monolith and microservices customers, this pattern eliminates the most expensive decision in backend architecture: the one you have to make before you know the answer.

Spring Boot - update response of every API using ResponseBodyAdvice

Not sure why we need it but I've seen several codebase where developers were wrapping the response body in some structure like below - where the actual content is put under an element eg: body and the root object has several other values.

{
"body": { //the actual body
"greeting": "Hello World!"
},

//additional elements
"timestamp": 1741653577468,
"traceId": "67cf8649f3c848a71d8171473d3b4955",
"spanId": "1d8171473d3b4955",
"durationMs": 0,
"status": 200
}

The corresponding controller method would look something like below where they would explicitly create the ResponseObject and map other parameters in every single endpoint definition:

@GetMapping("/object-bad-way/hi")
public ResponseObject<Hi> oldWayHi() {
log.info("Got request - object - old way - don't do this, use ResponseBodyAdvice");
var resp = new ResponseObject<Hi>();
var span = tracer.currentSpan();
if (span != null) {
resp.setTraceId(span.context().traceId());
resp.setSpanId(span.context().spanId());
}

resp.setTimestamp(System.currentTimeMillis());
try {
resp.setBody(myService.getHi()); //service call
resp.setStatus(HttpStatus.OK.value());
} catch (Exception e) {
resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
}

return resp;
}

The ResponseObject:

@Getter
@Setter
class ResponseObject<T> {
T body;
long timestamp;

String traceId;
String spanId;

long durationMs;
int status;
}


But wait, there's a better way to do this if you really want this. The idea is to use Spring's @ControllerAdvice that implements ResponseBodyAdvice to map the additional params so that you don't need to create the new ResponseObject instance on all endpoints.

After the change, your controller method would look simple as below: wouldn't that be nice?

@GetMapping("/better-way/hi")
public Hi objectHi() {
log.info("Got request - object");
return service.getHi();
}

 

ResponseBodyAdvice

From the docs:

ResponseBodyAdvice allows customizing the response after the execution of an @ResponseBody or a ResponseEntity controller method but before the body is written with an HttpMessageConverter.
Implementations may be registered directly with RequestMappingHandlerAdapter and ExceptionHandlerExceptionResolver or more likely annotated with @ControllerAdvice in which case they will be auto-detected by both.

 

A simple (empty) usage would look like this. On the supports method, we can decide if the response body needs to be modified. The beforeBodyWrite is where we can modify the request body or create new.

 

@ControllerAdvice
class ObjectResponseAdvice implements ResponseBodyAdvice<Object> {

//Whether this component supports the given controller method return type and the selected HttpMessageConverter type.
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}

//Invoked after an HttpMessageConverter is selected and just before its write method is invoked.
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
//modify body
return body;
}
}

 

A simple implementation of beforeBodyWrite that would work with both String response type and any other response type. Here we are handling the String response specially by checking if the original body(response body) is String. Otherwise, we are creating a ObjectNode and mapping timestamp and body elements under it.

 

supports() method: 
    return true;


beforeBodyWrit() method:
  ObjectNode root = objectMapper.createObjectNode();
root.put("timestamp", System.currentTimeMillis());

if (body instanceof String) {
root.set("body", objectMapper.convertValue(body, JsonNode.class));
return objectMapper.writeValueAsString(root);
} else {
root.put("body", String.valueOf(body));
return root;
}

 

Handle more stuff and map more stuff:

Here we are mapping the traceId/spanId from Micrometer Tracing , mapping 'status' from ServletServerHttpResponse. Also for backward compatibility with existing RestController methods that already return ResponseObject (with all elements we want on the response), we are skipping the additional mapping and simply returning body.

if (response instanceof ServletServerHttpResponse resp
//if body is already ResponseObject - do not convert
&& !(body instanceof ResponseObject)) {

ObjectNode root = objectMapper.createObjectNode();
var span = tracer.currentSpan();
if (span != null) {
root.put("traceId", span.context().traceId());
root.put("spanId", span.context().spanId());
}
root.put("status", resp.getServletResponse().getStatus());
if (BeanUtils.isSimpleValueType(body.getClass())) {
root.put("body", String.valueOf(body));
} else {
root.set("body", objectMapper.convertValue(body, JsonNode.class));
}
root.put("timestamp", System.currentTimeMillis());

if (body instanceof String) {
// String are handled special case with StringHttpMessageConverter
// we need to return String from this method
return objectMapper.writeValueAsString(root);
} else {
return root;
}
}

 

 

Full code in action: Note that it supports, string type, int type, map, and any object response.

 

Test it out with following endpoints:

###
GET http://localhost:8080/string

###
GET http://localhost:8080/map

###
GET http://localhost:8080/object/hi

###
GET http://localhost:8080/object/hello

###
GET http://localhost:8080/object-bad-way/hi

###
GET http://localhost:8080/object-bad-way/hello

 

 Full Code:

package responsebody;

import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.micrometer.tracing.Tracer;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.MethodParameter;
import org.springframework.http.*;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

import java.net.*;
import java.util.Map;

@SpringBootApplication
public class DemoApplication {

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

@RestController
@Slf4j
@RequiredArgsConstructor
class GreetingController {

final Tracer tracer;

@GetMapping("/string")
public String string() {
log.info("Got request - string");
return "Hello World!";
}

@GetMapping("/int")
public int intVal() {
log.info("Got request - int");
return 1;
}

@GetMapping("/url")
public URL url() throws MalformedURLException {
log.info("Got request - url");
return URI.create("https://localhost:8080/").toURL();
}

@GetMapping("/map")
public Map<String, String> map() {
log.info("Got request - map");
return Map.of("greeting", "Hello World!");
}

@GetMapping("/object/hello")
public Hello objectHello() {
log.info("Got request - object");
return new Hello("Hello World!");
}

@GetMapping("/better-way/hi")
public Hi objectHi() {
log.info("Got request - object");
return new Hi("Hi World!");
}

@GetMapping("/object-bad-way/hello")
public ResponseObject<Hello> oldWay() {
log.info("Got request - object - old way - don't do this, use ResponseBodyAdvice");
var resp = new ResponseObject<Hello>();
var span = tracer.currentSpan();
if (span != null) {
resp.setTraceId(span.context().traceId());
resp.setSpanId(span.context().spanId());
}

resp.setTimestamp(System.currentTimeMillis());
try {
resp.setBody(new Hello("Hello World!")); //service call
resp.setStatus(HttpStatus.OK.value());
} catch (Exception e) {
resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
}

return resp;
}

@GetMapping("/object-bad-way/hi")
public ResponseObject<Hi> oldWayHi() {
log.info("Got request - object - old way - don't do this, use ResponseBodyAdvice");
var resp = new ResponseObject<Hi>();
var span = tracer.currentSpan();
if (span != null) {
resp.setTraceId(span.context().traceId());
resp.setSpanId(span.context().spanId());
}

resp.setTimestamp(System.currentTimeMillis());
try {
resp.setBody(new Hi("Hello World!")); //service call
resp.setStatus(HttpStatus.OK.value());
} catch (Exception e) {
resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
}

return resp;
}


}


@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
class ObjectResponseAdvice implements ResponseBodyAdvice<Object> {

final Tracer tracer;
final ObjectMapper objectMapper;

@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}

@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {


if (response instanceof ServletServerHttpResponse resp
//if body is already ResponseObject - do not convert
&& !(body instanceof ResponseObject)) {

ObjectNode root = objectMapper.createObjectNode();
var span = tracer.currentSpan();
if (span != null) {
root.put("traceId", span.context().traceId());
root.put("spanId", span.context().spanId());
}
root.put("status", resp.getServletResponse().getStatus());
if (BeanUtils.isSimpleValueType(body.getClass())) {
root.put("body", String.valueOf(body));
} else {
root.set("body", objectMapper.convertValue(body, JsonNode.class));
}
root.put("timestamp", System.currentTimeMillis());

if (body instanceof String) {
// String are handled special case with StringHttpMessageConverter
// we need to return String from this method
return objectMapper.writeValueAsString(root);
} else {
return root;
}
}

return body;
}
}


@Getter
@NoArgsConstructor
@AllArgsConstructor
class Hello {
String greeting;
}

@Getter
@NoArgsConstructor
@AllArgsConstructor
class Hi {
String hi;
}

@Getter
@Setter
class ResponseObject<T> {
T body;
long timestamp;

String traceId;
String spanId;

long durationMs;
int status;
}

 

 


Run a task in a different interval during weekend using Spring Scheduler and Custom Trigger

Run a task in a different interval during weekend.

Instead of defining fixed interval to run a task every 1 minute (as shown in example below) using @Scheduled, we can customize Spring Task Scheduler to schedule job with a custom trigger to run the tasks in different schedule depending on business logic. 

@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
void job1() {
System.out.println("Running job1 - this won't run on weekend");
}

 

For example, we can do the following to run the task every 15 minute instead of 1 minute during weekends. Here we are registering a task job1 with TaskScheduler with a CustomTrigger.

The CustomTrigger implements Spring's Trigger interface and overrides nextExecution() method to do business logic to find the interval on which the task should run. For the example purpose we are running tasks less often (every 15 minute) during weekend.


DayOfWeek day = LocalDate.now(ZoneId.of(ZoneId.SHORT_IDS.get("CST"))).getDayOfWeek();
Duration nextSchedulePeriod = WORKDAY_INTERVAL;

/*
* Run the task every 15 minute often during weekend
*/

if (day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY) {
nextSchedulePeriod = WEEKEND_INTERVAL;
}
return new PeriodicTrigger(nextSchedulePeriod).nextExecution(ctx);



import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.TriggerContext;
import org.springframework.scheduling.support.PeriodicTrigger;

import java.time.DayOfWeek;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

@Configuration
class CustomTaskScheduler implements InitializingBean {

static final Duration WORKDAY_INTERVAL = Duration.ofMinutes(1);
static final Duration WEEKEND_INTERVAL = Duration.ofMinutes(15);

@Autowired
TaskScheduler taskScheduler;


@Override
public void afterPropertiesSet() {
taskScheduler.schedule(this::job1, new CustomTrigger());
// schedule more tasks
}

void job1() {
System.out.println("Running job1 - this won't run on weekend");
}


static class CustomTrigger implements Trigger {
/**
* Determine the next execution time according to the given trigger context.
*/
@Override
public Instant nextExecution(TriggerContext ctx) {
DayOfWeek day = LocalDate.now(ZoneId.of(ZoneId.SHORT_IDS.get("CST"))).getDayOfWeek();
Duration nextSchedulePeriod = WORKDAY_INTERVAL;

/*
* Run the task every 15 minute often during weekend
*/

if (day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY) {
nextSchedulePeriod = WEEKEND_INTERVAL;
}
return new PeriodicTrigger(nextSchedulePeriod).nextExecution(ctx);
}


}

}

How to make integration tests faster without @DirtiesContext

If your only excuse to use @DirtiesContext is to re-initialize database between tests, then this blog post is for you.

Spring's @DirtiesContext is very useful to make your integration tests faster by indicating the underlying Spring ApplicationContext is modified and forcing it to reload the context (not the whole application) between tests.

This is particularly useful when we want to reset the state of database between tests. This allows us to group many tests together in a same class so that we can easily share some test data, assertions etc. So your test code would look this this:

@SpringBootTest
@DirtiesContext(classMode = AFTER_EACH_TEST_METHOD)
class SomeTest {

@Autowired SomeService serv;
@Autowired DataCreator dc;

@Test
void someTest1() {
//prepare data
dc.initData();
//run test
serv.modifySomeData();
//verify
}

@Test
void someTest2() {
//prepare data
dc.initData(2);
//run test
serv.modifyAnotherData();
serv.doSomethingElse();
//verify
}
}

Problem with @DirtiesContext

This has one major flaw though. By resetting the ApplicationContext, it would also drop all the databases and need to re-create them after every test. So, if the only reason to use @DirtiesContext is to reset the database, then there's a better way of doing this.

Is deleting records from all table not a good solution?

Correct. Deleting records is still a slow operation. When you have a good amount to test data, the @DirtiesContext can sometimes becomes faster than deleting the records one by one. Also, there's referential integrity between tables that enforces you to follow a series of deletes to first delete records from child tables and go up. 

Table Truncate to the rescue

Truncate (truncate table X) is faster way to delete records from database than deleting records. If we already know the list of table and join tables then we can loop through them and execute the TRUNCATE TABLE command. One advantage of this approach is that we can skip truncating some lookup tables that will always have constant records.

How to Truncate H2 Tables:

Note that before we execute the TRUNCATE TABLE we should disable the referential integrity.
em.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String t : tableNames) {
em.createNativeQuery("TRUNCATE TABLE " + t ).executeUpdate();
}
em.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();

Integrating everything together (Sample App @ GitHub):

TestDataManager to grab list of tables, truncate and populate the test data:

import gt.app.DataCreator;
import gt.app.config.MetadataExtractorIntegrator;
import lombok.RequiredArgsConstructor;
import org.hibernate.boot.Metadata;
import org.hibernate.mapping.Collection;
import org.hibernate.mapping.PersistentClass;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class TestDataManager implements InitializingBean {
final EntityManager em;
final DataCreator dataCreator;

private final List<String> tableNames = new ArrayList<>(); //shared

@Override
public void afterPropertiesSet() {
Metadata metadata = MetadataExtractorIntegrator.INSTANCE.getMetadata();

for (Collection persistentClass : metadata.getCollectionBindings()) {
tableNames.add(persistentClass.getCollectionTable().getExportIdentifier());
}

for (PersistentClass persistentClass : metadata.getEntityBindings()) {
tableNames.add(persistentClass.getTable().getExportIdentifier());
}
}

@Transactional
public void truncateTablesAndRecreate() {
truncateTables();
dataCreator.initData();
}

void truncateTables() {
em.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
em.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
em.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
}

Call the truncateTablesAndRecreate() @BeforeEach test

@SpringBootTest
class WebAppIT {

@Autowired
TestDataManager tdm;

@BeforeEach
void resetDB(){
tdm.truncateTablesAndRecreate();
}

 

This is awesome, how about other databases?

To get a List of tables: You can rely on Hibernate's Metadata to grab list of table/join tables

Truncate command: Each database has different command to set the referential integrity and truncate. This above example is for H2.

You can do the following for MySQL


//for MySQL:
em.createNativeQuery("SET @@foreign_key_checks = 0").executeUpdate();
for (String tableName : tableNames) {
em.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
em.createNativeQuery("SET @@foreign_key_checks = 1").executeUpdate();

For other populate databases, you can take reference from Hibernate's Test code itself. They are available at: Hibernate GitHub. The *Cleaner classes have code snippet that describes how to perform truncate on each database.

How fast the tests ran after this update?

Very fast! In a big application (Spring Boot, H2) with 62 tables and 315 tests that were using @DirtiesContext, we reduced our test execution time from 18 minutes to 4minutes.

Full working example is available at this sample app.

JPA/Hibernate get find all Table and Column metadata

How to use Hibernate Metadata to find All columns and tables

Getting the Hibernate's Metadata object into the Spring application is tricky. Luckily, Hibernate Provides an Integrator API (org.hibernate.integrator.spi.Integrator) that we can use to customize/interact with Hibernate. Its the same API that Caching, Bean Validation etc library uses to integrate with Hibernate. 

Also, Spring Boot provides HibernatePropertiesCustomizer to link the 'hibernate.integrator_provider' property to Integrator implementation.

Here's how we can configure the Hibernate Integrator to read metadata.

Step 1) create a extractor implementation

This class is a singleton class and does absolutely nothing other than exposing the Metadata and Database. Since this is singleton this class and the database, metadata objects can be statically accessed using MetadataExtractorIntegrator.INSTANCE

import lombok.Data;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.model.relational.Database;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.integrator.spi.Integrator;
import org.hibernate.service.spi.SessionFactoryServiceRegistry;

@Data
public class MetadataExtractorIntegrator implements Integrator {

public static final MetadataExtractorIntegrator INSTANCE =
new MetadataExtractorIntegrator();
private Database database;
private Metadata metadata;

@Override
public void integrate(Metadata metadata, SessionFactoryImplementor sf,
SessionFactoryServiceRegistry sr) {
this.database = metadata.getDatabase();
this.metadata = metadata;
}

@Override
public void disintegrate(SessionFactoryImplementor sf,
SessionFactoryServiceRegistry sr) {
}
}

Step 2) Register the Spring Hibernate Customizer


import org.hibernate.jpa.boot.spi.IntegratorProvider;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Configuration;

import java.util.List;
import java.util.Map;

@Configuration
public class HibernateConfig implements HibernatePropertiesCustomizer {
@Override
public void customize(Map<String, Object> hibernateProps) {
hibernateProps.put("hibernate.integrator_provider",
(IntegratorProvider) () -> List.of(MetadataExtractorIntegrator.INSTANCE));
}
}

Step 3) Use MetadataExtractorIntegrator.metadata to extract the metadata

This is a simple Spring Component that uses Metadata.getCollectionBindings and Metadata.getEntityBindings to extract the tables, columns, PK and type

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.boot.Metadata;
import org.hibernate.mapping.*;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

import javax.persistence.EntityManager;
import java.util.Iterator;

@Component
@RequiredArgsConstructor
@Slf4j
public class DBMetadataReader implements InitializingBean {
final EntityManager em;

@Override
public void afterPropertiesSet() {

Metadata metadata = MetadataExtractorIntegrator.INSTANCE.getMetadata();

//Collection tables
for (Collection c : metadata.getCollectionBindings()) {
log.info("Collection table: {}", c.getCollectionTable().getQualifiedTableName());
for (Iterator<Column> it = c.getCollectionTable().getColumnIterator();
it.hasNext(); ) {
Column property = it.next();
log.info(" {} {} ", property.getName(), property.getSqlType());
}
}

//all entities
for (PersistentClass pc : metadata.getEntityBindings()) {
Table table = pc.getTable();

log.info("Entity: {} - {}", pc.getClassName(), table.getName());

KeyValue identifier = pc.getIdentifier();

//PK
for (Iterator<Selectable> it = identifier.getColumnIterator();
it.hasNext(); ) {
Column column = (Column) it.next();
log.info(" PK: {} {}", column.getName(), column.getSqlType());
}

//property/columns
for (Iterator it = pc.getPropertyIterator();
it.hasNext(); ) {
Property property = (Property) it.next();

for (Iterator columnIterator = property.getColumnIterator();
columnIterator.hasNext(); ) {
Column column = (Column) columnIterator.next();
log.info(" {} {}", column.getName(), column.getSqlType());
}
}
}
}
}

Example Project:

Checkout my sample github project and the source for working example