Beyond the Monolith vs Microservices Debate: A Practical Guide to Deployment-Agnostic Services
The Problem with Choices
The monolith vs microservices debate forces teams into a false choice that constrains both development and deployment options. Many teams want to move toward distributed systems but find themselves trapped by poorly designed monoliths where components are tightly coupled and difficult to extract without comprehensive test coverage. Others adopt microservices prematurely and struggle with operational complexity when their applications could run perfectly well as monoliths.
The solution isn't choosing sides - it's building services that can deploy either way through configuration, not architecture.
Building on Modular Foundations
This post builds on the modular architecture established in Phase 4.6: Breaking the Monolith, where we split repositories into parent POMs, commons libraries, and service modules. That phase created physical boundaries to prevent AI from modifying files it shouldn't touch. Now we add logical boundaries through deployment flexibility.
The same service code can run embedded within a larger application or as an independent server, controlled purely by configuration. This approach eliminates the need to commit to architectural extremes upfront.
The Implementation Strategy
The key insight is separating service logic from its deployment mode. We'll transform the users service from our modular architecture by adding three layers:
- Service Application Module: HTTP interface for standalone deployment
- REST Client Implementation: Network-based client that mirrors internal client interface
- Configuration-Driven Selection: Spring profiles that choose deployment mode
Let's walk through each step with real implementation examples.
Step 1: Service Application Module
The service-app module (previously used only for acceptance testing) now provides the HTTP layer for external access. Each service needs a unique local port for separate local run:
application-local.yml
server.port: 8000 # Unique port for this service
Spring Boot Application:
@SpringBootApplication(scanBasePackages = ["vt.demo.users", "dev.vibetdd.service.api.common.rest"])
class Application
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
2
3
4
5
6
UserController Implementation:
@RestController
@RequestMapping("/v1/users")
class UserControllerV1(
private val userCoreFactory: UserCoreFactory,
) {
@PostMapping
suspend fun create(
@RequestBody params: CreateUserParamsV1
): ModelV1<UserV1> = userCoreFactory
.createUseCase
.execute(params.toCommand())
.toV1()
@PutMapping("{id}/versions/{version}")
suspend fun update(
@PathVariable id: UUID,
@PathVariable version: Long,
@RequestBody params: UpdateUserParamsV1
): ModelV1<UserV1> = userCoreFactory
.updateUseCase
.execute(params.toCommand(id, version))
.toV1()
// ... other methods
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Notice how the controller uses the same UserCoreFactory
that internal clients use - no duplication of business logic.
Step 2: REST Client Implementation
Create a REST client that implements the same interface as the internal client:
UsersRestClient.kt:
class UsersRestClient(
val props: UsersRestClientProps,
val httpClient: HttpClient
) : UsersClientV1 {
override suspend fun create(request: CreateUserRequestV1): ModelV1<UserV1> = clientCall {
httpClient
.post("/v1/users") {
setBody(request.params)
timeout { requestTimeoutMillis = props.getTimeoutRequest(request.timeout) }
}
.body()
}
override suspend fun update(request: UpdateUserRequestV1): ModelV1<UserV1> = clientCall {
httpClient
.put("/v1/users/${request.id}/versions/${request.version}") {
setBody(request.params)
timeout { requestTimeoutMillis = props.getTimeoutRequest(request.timeout) }
}
.body()
}
// ... other methods
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Step 3: Configuration-Driven Client Selection
The crucial change is switching the default from internal to REST client. Notice how matchIfMissing
moved:
Before - Internal Client as Default:
@ConditionalOnProperty(
value = ["clients.users.type"],
havingValue = "INTERNAL",
matchIfMissing = true // Was the default
)
class InternalClientConfig { ... }
2
3
4
5
6
After - REST Client as Default:
// Internal Client Configuration
@ConditionalOnProperty(
value = ["clients.users.type"],
havingValue = "INTERNAL",
matchIfMissing = false // No longer default
)
class InternalClientConfig { ... }
// REST Client Configuration
@ConditionalOnProperty(
value = ["clients.users.type"],
havingValue = "REST",
matchIfMissing = true // Now the default
)
class RestClientConfig { ... }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
This single change transforms the entire deployment model - services now default to distributed mode while preserving monolithic capability.
Build Configuration Strategy
The Maven setup enables different dependencies for different deployment modes:
Client Module Dependencies:
<!-- users-client-spring/pom.xml -->
<dependencies>
<dependency>
<groupId>dev.vibetdd.demo.service</groupId>
<artifactId>users-client-rest</artifactId> <!-- Always included -->
</dependency>
<dependency>
<groupId>dev.vibetdd.demo.service</groupId>
<artifactId>users-client-internal</artifactId>
<scope>provided</scope> <!-- Only for local development, should be provided by consumers (api-admin in our case) -->
</dependency>
</dependencies>
2
3
4
5
6
7
8
9
10
11
12
Seamless Testing
Simply add the new value ClientType.REST
and extend factory, and all existing tests automatically validate both implementations:
TestUsersClientFactory.kt:
enum class ClientType {
INTERNAL,
REST
}
class TestUsersClientFactory(...) {
fun createClient(clientType: ClientType): UsersClientV1 = when (clientType) {
ClientType.INTERNAL -> UsersInternalClient(...)
ClientType.REST -> UsersRestClient( // Configure the new client
props = restClientProps,
httpClient = HttpClientFactory.create(
props = restClientProps,
objectMapper = objectMapper
),
)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Every acceptance test runs against both implementations:
@ParameterizedTest
@EnumSource(ClientType::class)
fun `should create user successfully`(clientType: ClientType) {
val client = testUsersClientFactory.createClient(clientType)
// Test automatically validates both deployment modes
}
2
3
4
5
6
Consumer Integration: The API Layer
The consuming applications require minimal changes - just dependency version updates:
Maven Dependencies:
<!-- Parent pom.xml -->
<properties>
<!-- Update the version (or let maven versions plugin to do it) -->
<client.users.version>1.1.0</client.users.version>
</properties>
2
3
4
5
Local Development Configuration:
# application-local.yml
clients:
users:
type: ${CLIENT_USERS_TYPE:INTERNAL} # Monolith mode for development
rest.url: http://localhost:8000 # Set the service port if you want to switch to REST mode
2
3
4
5
And Maven Config:
<!--api-admin/pom.xml-->
<dependencies>
<!-- Let client config to set the implementation -->
<dependency>
<groupId>vt.demo.service</groupId>
<artifactId>users-client-spring</artifactId>
</dependency>
<!-- Always include internal for testing -->
<dependency>
<groupId>vt.demo.service</groupId>
<artifactId>users-client-internal</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<profile>
<id>local-dev</id>
<dependencies>
<!-- Include internal for local development -->
<dependency>
<groupId>vt.demo.service</groupId>
<artifactId>users-client-internal</artifactId>
</dependency>
</dependencies>
</profile>
</profiles>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
For production, no configuration is needed - the service auto-configures to REST mode and discovers the service URL automatically.
Testing Configuration:
# application-test.yml
clients:
users:
type: INTERNAL # Always internal, the testing will continue working with the real service logic
2
3
4
Deployment Modes
This configuration gives you three deployment options:
1. Full Monolith
clients:
users:
type: INTERNAL
2
3
Everything runs in one JVM, zero network calls, instant startup.
2. Hybrid Mode
clients:
users:
type: REST # Users service separate
orders:
type: INTERNAL # Orders service embedded
2
3
4
5
Mix standalone and embedded services based on scaling needs.
3. Full Microservices
# No configuration needed - REST is now default
All services running independently with automatic service discovery.
Key Benefits
Zero Test Disruption
- Existing acceptance tests automatically validate both implementations
- No mocking required - tests use real services with real databases
- Same test suite gives confidence in both deployment modes
Development Simplicity
- Local development runs as monolith - no Docker or manual services run complexity
- Instant startup times, easy debugging across service boundaries
- Change one property to test against standalone service
Deployment Flexibility
- Extract services one at a time based on actual scaling needs
- Easy rollback by changing configuration
- No pressure to migrate everything at once
Gradual Migration Path
Teams can start with monolithic deployment and gradually extract services as operational expertise and infrastructure mature. The same codebase supports both approaches.
Beyond the Debate
This approach resolves the monolith vs microservices debate by making it irrelevant. Instead of choosing sides, you build services that adapt to your needs:
- When you need simplicity: Deploy as monolith
- When you need scale: Deploy as microservices
- When you need both: Deploy hybrid mode
The architecture enables possibilities rather than constraining them. Whether you need the simplicity of a monolith or the scalability of microservices isn't a permanent decision - it's a runtime configuration choice.
Teams no longer need to commit to architectural extremes upfront. They can start simple and evolve complexity only when business requirements demand it, all while maintaining the same codebase and test suite.
This approach demonstrates that good architecture makes hard things easy and easy things trivial. The hardest part of microservices shouldn't be running them locally for development, and the hardest part of monoliths shouldn't be extracting services when you need to scale.