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: falseMicroservices (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:8080This 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:
- Module structure verification — ArchUnit-style tests that catch illegal dependencies between modules at build time.
- Event publication — an
ApplicationModuleListenerannotation that formalizes in-process event handling. - Documentation — auto-generated diagrams of your module topology.
- Testing support —
ModuleTestfor 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.
-
@ConditionalOnPropertyis 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
InventoryControllerimplementsInventoryClient, 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-appis 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 pullsspring-cloud-starter-openfeigninto 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.,InventoryClientRemoteinsideorder-service), keepingcommon-apifree 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-servicefrom importinginventory-service's entity classes directly and bypassing theInventoryClientabstraction 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- One codebase, two deployment topologies is a real, practical architecture — not an academic exercise.
- Three pillars make it work: shared
Clientinterfaces, 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.
@ConditionalOnPropertyis 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...