A practical guide to building a modular monolith without losing the ability to scale back in a Spring Cloud powered stack

Here’s an irony for you: everyone talks about migrating from monolith to microservices. The entire industry narrative flows in one direction. In fact, when I was searching for images for this post, every single diagram showed monolith → microservices. Not a single arrow pointed the other way.
But sometimes the right move is consolidation. Building a modular monolith is a reality people are often hesitant to discuss publicly, thanks to years of microservices hype.
When we were running 15 microservices, it sounded like good architecture on paper. In practice, it was burning through our budget and adding complexity we didn’t need.
I want to share our experience consolidating everything into a modular monolith. While our stack is Java with Spring Cloud on Kubernetes, the principles apply broadly whether you’re running Node.js services, Go microservices, or .NET applications on any container orchestration platform. The key concepts of conditional loading, path preservation, and schema isolation translate across technology stacks.
We slashed our infrastructure costs by 90%, cut our CI/CD build times in half, dramatically simplified troubleshooting, and made integration testing actually manageable. The fact that we can still deploy as microservices if needed is a nice side effect of already having that architecture—not a design goal in itself.
The Hidden Cost of Microservices
Our platform had grown to 15 services: user-service, billing-service, integration-service, and a dozen more. Each one came with its own baggage:
- A Kubernetes deployment with dedicated CPU and memory
- Its own database connection pool
- Just JVM overhead of around 256MB per service
- Separate monitoring, logging, and tracing infrastructure
The numbers were sobering. Running a full environment required tens of CPUs and hundreds of of RAM just for the JVMs, 15+ pods to manage, and complex inter-service networking. We were paying for enterprise-scale infrastructure when the entire platform could run on a single instance with 80% less resources.
Beyond the raw infrastructure costs, we were dealing with other pain points that don’t show up on the cloud bill:
Build times were brutal. Each service had its own build pipeline. A full CI run meant building 15 Docker images, pushing them to the registry, and deploying them sequentially. What should have been a 3-minute deploy turned into 45 minutes of waiting.
Troubleshooting was a maze. When something broke, we’d be jumping between 15 different log streams even with a centralized logging, correlating trace IDs across services, and trying to reproduce issues that only appeared when specific services interacted. A bug that would take 30 minutes to diagnose in a monolith could eat up half a day.
Integration tests were a nightmare to maintain. We needed docker-compose files with all 15 services running, complex test fixtures to ensure data consistency across schemas, and flaky tests that failed because one service started slower than another. Our test suite took 20 minutes to run and broke constantly.
For a growing startup, this was unsustainable. But we didn’t want to throw away years of modular architecture work that provides great flexibility and isolation between business logic. So, we decided to approach it in a way to make it a reversible decision—what Amazon calls a “two-way door”.
---
config:
theme: base
themeVariables:
background: "#ffffff"
tertiaryColor: "#f8fafc"
primaryTextColor: "#0f172a"
primaryColor: "#2563eb"
primaryBorderColor: "#1d4ed8"
secondaryColor: "#14b8a6"
lineColor: "#cbd5f5"
title: From Microservices to Modular Monolith
---
flowchart TB
subgraph SERVICES["Individual Microservices"]
direction LR
US["👤 user-service<br><i>256MB JVM</i>"]
BS["💳 billing-service<br><i>256MB JVM</i>"]
IS["🔗 integration-service<br><i>256MB JVM</i>"]
MORE["📦 +12 more services<br><i>256MB JVM each</i>"]
end
subgraph DBS1["Database Schemas"]
direction LR
DB1[("user_service")]
DB2[("billing_service")]
DB3[("integration_service")]
DB4[("... +12 schemas")]
end
subgraph BEFORE["<b>BEFORE: Microservices Architecture</b>"]
direction TB
INGRESS1[/"🌐 Ingress"/]
GW["⚡ Gateway Service"]
SERVICES
DBS1
end
subgraph MODULES["Modules (Same Code, No Network Hops)"]
direction LR
UM["👤 user<br>module"]
BM["💳 billing<br>module"]
IM["🔗 integration<br>module"]
MOREM["📦 +12 more<br>modules"]
end
subgraph MONOLITH["🏛️ Monolith Service (Single JVM)"]
direction TB
MODULES
ROUTER["🔀 Path Router<br><code>/user-service/*</code> → user module<br><code>/billing-service/*</code> → billing module<br><code>/integration-service/*</code> → integration module"]
end
subgraph DBS2["Same Database Schemas (Preserved)"]
direction LR
DB1B[("user_service")]
DB2B[("billing_service")]
DB3B[("integration_service")]
DB4B[("... +12 schemas")]
end
subgraph AFTER["<b>AFTER: Modular Monolith</b>"]
direction TB
INGRESS2[/"🌐 Ingress"/]
MONOLITH
DBS2
end
INGRESS1 --> GW
GW --> US & BS & IS & MORE
US --> DB1
BS --> DB2
IS --> DB3
MORE --> DB4
INGRESS2 --> MONOLITH
ROUTER --> UM & BM & IM & MOREM
UM --> DB1B
BM --> DB2B
IM --> DB3B
MOREM --> DB4B
BEFORE --> TRANSITION(("🔄<br>SERVICE_MODE=monolith<br><b>Same Codebase</b><br>Same Helm Charts"))
TRANSITION --> AFTER
style US fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style BS fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style IS fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style MORE fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style UM fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style BM fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style IM fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style MOREM fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB1 fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB2 fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB3 fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB4 fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB1B fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB2B fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB3B fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style DB4B fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style INGRESS1 fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style INGRESS2 fill:#f8fafc,stroke:#c7d2fe,color:#0f172a
style GW fill:#f1f5f9,stroke:#2563eb,stroke-width:2px,color:#0f172a
style ROUTER fill:#f1f5f9,stroke:#4f46e5,stroke-width:2px,color:#0f172a
style MONOLITH fill:#ffffff,stroke:#0f172a,stroke-width:2px,color:#0f172a
style BEFORE fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#0f172a
style AFTER fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#0f172a
style TRANSITION fill:#fefce8,stroke:#eab308,stroke-width:3px,color:#0f172aWhy We Chose a Modular Monolith
Before touching any code, we defined strict requirements:
- Keep the modular Maven structure. Each service stays an independent module.
- Preserve all API paths. URLs like /billing-service/plans and /user-service/teams continue working.
- Maintain database isolation. Each service keeps its own PostgreSQL schema.
- Zero changes to business logic. Controllers, services, and repositories stay exactly the same.
Since we already had a microservices codebase, we also wanted to preserve the ability to deploy individual services independently—not as a primary goal, but because throwing away that flexibility seemed wasteful.
This approach aligns with what Martin Fowler describes in his article on monolith-first architecture—though in our case, we were applying the lesson in reverse.
The Modular Monolith Architecture
Pulling Services Together with Maven
We created a new monolith-service module that depends on all business services and replaces gateway-service as the main ingress point:
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>user-service</artifactId>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>billing-service</artifactId>
</dependency>
<!-- All 15 services as dependencies -->
</dependencies>
The trick is pulling in the service classes without executing their @SpringBootApplication classes.
Conditional Bean Loading
Each microservice’s main class got a conditional annotation:
@SpringBootApplication
@ConditionalOnProperty(
name = "service.mode",
havingValue = "microservice",
matchIfMissing = true
)
public class BillingServiceApplication {
// Only loads when running as a standalone microservice
}
The modular monolith application consolidates all Spring configurations:
@SpringBootApplication(scanBasePackages = "com.example")
@EnableFeignClients
@EnableJpaRepositories
@EnableTransactionManagement
public class MonolithServiceApplication {
// One application to rule them all
}
Preserving API Routes in Your Modular Monolith
To keep URLs like /billing-service/plans working, we configured dynamic path prefixes based on controller packages:
@Configuration
public class MonolithContextPathConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer
.addPathPrefix("/billing-service",
c -> c.getPackageName().contains("service.billing.controller"))
.addPathPrefix("/user-service",
c -> c.getPackageName().contains("service.user.controller"));
}
}
No annotations needed on the controllers themselves. The routing happens automatically based on package names.
Multi-Schema Database Migrations
Each service maintains its own PostgreSQL schema. At startup, we run Flyway migrations for each:
@Component
public class MultiSchemaFlywayMigrator {
private static final List<String> SCHEMAS = List.of(
"user_service", "billing_service", "integration_service"
);
public void migrate() {
for (String schema : SCHEMAS) {
Flyway.configure()
.dataSource(dataSource)
.schemas(schema)
.locations("classpath:db/migration/" + schema)
.load()
.migrate();
}
}
}
All JPA entities specify their schema explicitly:
@Entity
@Table(name = "plan", schema = "billing_service")
public class Plan {
// Entity stays in its own schema
}
Common Modular Monolith Problems (And How We Fixed Them)
Bean Name Collisions
When all services load in one context, beans with the same name collide. For example, we had multiple cronConfig beans fighting each other.
The fix was explicit naming with service prefixes:
@Component("billingServiceCronConfig")
@ConfigurationProperties(prefix = "billing-service.cron")
public class CronConfig { }
Entity Name Conflicts
Multiple services had entities named like OrganizationCron or Project. JPA requires unique entity names.
Solution: prefix entity names while keeping table names unchanged:
@Entity(name = "BillingOrganizationCron")
@Table(name = "organization_cron", schema = "billing_service")
public class OrganizationCron { }
The Spring Boot JAR Packaging Gotcha
This one was sneaky. Everything worked locally, but in Kubernetes we got:
No static resource billing-service/plans
Controllers weren’t being registered at all.
The root cause: Spring Boot executable JARs package classes in BOOT-INF/classes/, which aren’t accessible when used as dependencies.
The fix is configuring each service to produce two JARs:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<classifier>exec</classifier>
</configuration>
</plugin>
This produces billing-service.jar (regular JAR for modular monolith dependency) and billing-service-exec.jar (executable JAR for microservice deployment).
Ingress Path Rewriting
Our NGINX ingress was stripping paths, breaking the monolith’s routing.
We made the rewrite conditional:
{{- if ne .Values.serviceMode "monolith" }}
nginx.ingress.kubernetes.io/rewrite-target: /
{{- end }}
The Results: Modular Monolith vs Microservices
| Metric | Microservices | Modular Monolith |
|---|---|---|
| Pods running | 15 | 1 |
| CPU & RAM total | 100 unit | 20 unit |
| Monthly cost | 100 unit | 10 unit |
| Full CI/CD pipeline | ~45 minutes total | ~3 minutes |
| Integration test setup | 15-service docker-compose | Single application context |
| Log correlation for debugging | Distributed tracing required | Single log stream |
| Deployment complexity | High | Low |
The operational benefits went beyond just cost savings. When something breaks now, we look at one log stream instead of correlating traces across 15 services. Our integration tests spin up a single Spring context instead of orchestrating containers. New team members can run the entire platform locally without needing hundreds of RAM and a PhD in docker-compose.
The deployment command is the same either way:
# Deploy as modular monolith
SERVICE_MODE=monolith make deploy
# Deploy as microservices
SERVICE_MODE=microservice make deploy
Same codebase. Same Helm charts. Different configuration.
Lessons Learned Building a Modular Monolith
Microservices have real overhead and it’s not just infrastructure. The resource costs are obvious, but the hidden taxes on build times, debugging complexity, and integration testing add up even faster. Question whether you actually need distributed services.
Developer experience matters. Being able to set breakpoints across what used to be service boundaries, having a single log stream to grep through, and running integration tests without container orchestration has made our team significantly more productive.
Modular monoliths work across tech stacks. While we’ve shared our Java/Spring Cloud/Kubernetes implementation, the core patterns like conditional module loading, path-based routing, and schema isolation apply to any stack. Node.js developers can use dynamic imports and Express routers. Go teams can use build tags and gorilla/mux. The architecture is language-agnostic.
Schema isolation gives you most of the benefits. Separating database schemas provides modularity without the operational complexity of separate deployments. You get clean boundaries where they matter most, at the data layer.
Don’t let industry trends dictate your architecture. Microservices became the default answer regardless of the question. For most teams at our scale, a well-structured monolith is simpler to build, easier to debug, and cheaper to run.
Moving Forward
We’ve been running 100% on the modular monolith in staging and production for a while now. The operational simplicity has been a game-changer for our small team. Since we came from microservices, we still have the option to split out individual services if a specific module ever needs independent scaling—but honestly, we haven’t needed to, and I don’t expect we will anytime soon.