Modular Monolith: How We Migrated from Microservices and Cut Costs by 90%

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:#0f172a

Why 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

MetricMicroservicesModular Monolith
Pods running151
CPU & RAM total100 unit20 unit
Monthly cost100 unit10 unit
Full CI/CD pipeline~45 minutes total~3 minutes
Integration test setup15-service docker-composeSingle application context
Log correlation for debuggingDistributed tracing requiredSingle log stream
Deployment complexityHighLow

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.


Further Reading