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.
| 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.
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.
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.
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()); } }
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.
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
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.
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:
ApplicationModuleListener annotation that formalizes in-process event handling.ModuleTest 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.
@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.
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.
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.
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.
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.
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.
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
Client interfaces, conditionally-wired local/remote implementations, and dual-path event routing.@ConditionalOnProperty is simpler and more transparent than a custom framework for this.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.