DEV Community

Maksim Matlakhov
Maksim Matlakhov

Posted on • Originally published at blog.vibetdd.dev

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

server.port: 8000  # Unique port for this service
Enter fullscreen mode Exit fullscreen mode

Spring Boot Application:

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

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
            ),
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Deployment Modes

This configuration gives you three deployment options:

1. Full Monolith

clients:
  users:
    type: INTERNAL
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Mix standalone and embedded services based on scaling needs.

3. Full Microservices

# No configuration needed - REST is now default
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)