Skip to content
On this page

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:

  1. Service Application Module: HTTP interface for standalone deployment
  2. REST Client Implementation: Network-based client that mirrors internal client interface
  3. 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

yaml
server.port: 8000  # Unique port for this service
1

Spring Boot Application:

kotlin
@SpringBootApplication(scanBasePackages = ["vt.demo.users", "dev.vibetdd.service.api.common.rest"])
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}
1
2
3
4
5
6

UserController Implementation:

kotlin
@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
}
1
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:

kotlin
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
}
1
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:

kotlin
@ConditionalOnProperty(
    value = ["clients.users.type"],
    havingValue = "INTERNAL",
    matchIfMissing = true  // Was the default
)
class InternalClientConfig { ... }
1
2
3
4
5
6

After - REST Client as Default:

kotlin
// 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 { ... }
1
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:

xml
<!-- 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>
1
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:

kotlin
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
            ),
        )
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Every acceptance test runs against both implementations:

kotlin
@ParameterizedTest
@EnumSource(ClientType::class)
fun `should create user successfully`(clientType: ClientType) {
    val client = testUsersClientFactory.createClient(clientType)
    // Test automatically validates both deployment modes
}
1
2
3
4
5
6

Consumer Integration: The API Layer

The consuming applications require minimal changes - just dependency version updates:

Maven Dependencies:

xml
<!-- 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>
1
2
3
4
5

Local Development Configuration:

yaml
# 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
1
2
3
4
5

And Maven Config:

xml
<!--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>
1
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:

yaml
# application-test.yml
clients:
  users:
    type: INTERNAL  # Always internal, the testing will continue working with the real service logic
1
2
3
4

Deployment Modes

This configuration gives you three deployment options:

1. Full Monolith

yaml
clients:
  users:
    type: INTERNAL
1
2
3

Everything runs in one JVM, zero network calls, instant startup.

2. Hybrid Mode

yaml
clients:
  users:
    type: REST  # Users service separate
  orders:
    type: INTERNAL  # Orders service embedded
1
2
3
4
5

Mix standalone and embedded services based on scaling needs.

3. Full Microservices

yaml
# No configuration needed - REST is now default
1

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.

Built by software engineer for engineers )))