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.

No comments :

Post a Comment

Your Comment and Question will help to make this blog better...