Executive Summary
In 2025, many engineering teams are consolidating sprawling microservice estates back into modular monoliths. The drivers are not ideological—they’re economic and operational. Cloud bills inflated by chatty networks, elevated mean time to recovery due to dependency chains, and the cognitive load of 100+ repos and pipelines have turned once aspirational microservices into an anchor for many mid-sized products. Meanwhile, modern language ecosystems, build tools, observability, and deployment platforms make it far more practical to build a single deployable artifact with crisp modular boundaries, robust parallel testing, and well-instrumented internal interfaces.
This article is a field guide for teams evaluating a pivot. It is opinionated: if you are a single product with fewer than a handful of independently staffed teams, a modular monolith will likely make you faster, cheaper, and safer. If you run a multi-tenant, globally distributed platform with strict isolation, extreme polyglot needs, and dozens of teams that truly deploy independently, microservices still win. We’ll teach you how to decide, and, if consolidation is right, how to migrate without breaking customers: domain boundaries, repository and layout choices, build/test pipelines, observability, data strategy, and a phased cutover.
Why Consolidation Is Rising in 2025
•Cost pressure and FinOps maturity: Inter-service RPCs are not free. Network egress, load balancers, NAT gateways, per-service autoscaling, cache duplication, and always-on sidecars add up. Teams report significant savings when collapsing unnecessary network hops. In 2023, an Amazon Prime Video engineering blog described consolidating a microservice-heavy monitoring subsystem into a more monolithic architecture, reducing costs dramatically while improving scaling.
•Operational drag: Fan-out calls degrade p99 latency and complicate incident response. A single service degradation can fan in/out into dozens of alerts. Page fatigue rises, MTTR grows.
•Tooling maturity: Monorepo tooling (Bazel, Pants, Nx, Turborepo), module boundary linters (ArchUnit, NDepend, ESLint import rules), and observability (OpenTelemetry) make it feasible to maintain strong encapsulation inside one deployable.
•Research and practice convergence: The “Accelerate” and DORA metrics literature emphasizes fast feedback, small batch sizes, and trunk-based development. Many teams achieve those with a well-structured monolith faster than with scattered services.
•Cautionary industry stories: High-profile case studies—Segment’s “Goodbye Microservices” (2018), the Prime Video 2023 write-up, and numerous engineering blogs—illustrate that microservices carry a substantial overhead that is not always warranted.
Definitions: Microservices vs. Modular Monolith
•Microservices: Multiple independently deployable services communicating over the network. Benefits include isolation of failures and scaling, autonomous team deployment, and polyglot freedom. Costs include distributed transactions, network reliability, operational overhead, and complex observability.
•Modular monolith (aka “modulith”): A single deployable unit with strict internal modular boundaries. Each module owns a well-defined domain, exposes stable interfaces (or events), and hides internals. You gain in-process calls, single deployment, and simpler data consistency while keeping much of the conceptual separation of microservices.
Decision Framework: When a Modular Monolith Wins
Choose modular monolith if most of these are true:
•Team size and org structure: Fewer than 6–8 teams working on one cohesive product; low need for independent deploy cadence.•Latency-sensitive call chains: Known fan-out patterns (e.g., request → auth → profile → pricing → inventory → recommendations) cause tail latency issues.•Shared data and transactions: Strong need for multi-entity atomicity—payments, orders, inventory updates—where distributed two-phase commit or sagas are overkill or fragile.•Cost constraints: Significant spend on data transfer, per-service overhead, multi-cluster operations, or messaging infra.•Tooling readiness: Willingness to invest in module boundary enforcement, well-structured repo, and robust CI.
Stick with microservices if several of these hold:
•Independent teams and roadmaps: Dozens of teams with separate SLOs, on-call, and release cycles.•Isolation requirements: Hard tenant isolation, regulatory segregation, or blast-radius containment mandates.•Polyglot and heterogeneity: Distinct runtime profiles (e.g., GPU inference, stream processing, low-latency trading) that scale and deploy independently.•Global distribution: Edge-local compute, multi-region active-active with data sovereignty that benefits from service isolation.
Principles of a Good Modular Monolith
•Bounded context alignment: Modules map to domain bounded contexts (e.g., Identity, Catalog, Pricing, Orders, Fulfillment). Cross-module interaction happens via explicit interfaces or domain events.
•Compile-time and CI enforcement: Guardrails prevent cross-module imports and database access violations.
•Explicit contracts: Even in-process, keep API-like interfaces and consumer-driven contracts. This allows a potential future split without rewrites.
•Autonomous data schemas: Modules own tables or schema namespaces; cross-module reads go through module APIs or published views.
•Independent testing and release toggles: Module-level test suites and feature flags allow safe changes without full-regression overhead every time.
Drawing Domain Boundaries That Last
Start with domain discovery:
•Event Storming: Gather product and engineering stakeholders, map domain events (OrderPlaced, PaymentAuthorized), commands, and aggregates. Boundaries emerge where event clusters stabilize.•Team Topologies alignment: Minimize cognitive load by aligning modules to stream-aligned teams. Avoid splitting a critical value stream across too many modules.•Context maps: Identify upstream/downstream relationships and anti-corruption layers for necessary translations.
Boundary heuristics:
•High cohesion within module, low coupling between: Entities and logic that change together belong together.•Interaction styles: Synchronous call patterns indicate potentially same module; asynchronous workflows (e.g., fulfillment) can be separate modules communicating via events.•Data ownership: Each module owns its write model. Other modules may derive read models via subscriptions, not ad hoc joins.
Repository Strategy and Layout
Monorepo vs. multi-repo:
•Monorepo often wins for a modular monolith. Advantages: atomic changes, consistent tooling, shared libraries with visibility controls, and single source of truth for module boundaries.•Multi-repo can work if enforced with package registries and strict versioning, but adds coordination overhead.
Language-specific layout patterns
Java (Spring, JPMS, or Spring Modulith):
•Use Java Platform Module System (JPMS) for strong encapsulation, or Spring Modulith for structural enforcement and domain events.•Example layout:
`
/monolith
/modules
/identity
src/main/java/com/acme/identity/...
/catalog
src/main/java/com/acme/catalog/...
/pricing
src/main/java/com/acme/pricing/...
/orders
src/main/java/com/acme/orders/...
/app
src/main/java/com/acme/app/Application.java
settings.gradle
build.gradle
`
•Enforce boundaries with ArchUnit rules and JPMS exports/opens:
`
// ArchUnit example
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
noClasses().that().resideInAPackage("..orders..").should()
.accessClassesThat().resideInAnyPackage("..pricing..", "..catalog..")
.because("Orders must use interfaces, not internals");
`
TypeScript/Node (with Nx or Turborepo and TS project refs):
`
apps/
api/
libs/
identity/
catalog/
pricing/
orders/
tsconfig.base.json
.eslintrc.js
`
•Enforce import boundaries with ESLint:
`
// .eslintrc.js
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@acme/orders/internal',
message: 'Use @acme/orders/public interface',
},
],
patterns: [
{
group: ['@acme/
/internal/*'],
message: 'Do not import internal modules across boundaries',
},
],
},
],
}
`
Go (single repo, internal packages):
`
/monolith
/cmd/app
/internal
/identity
/catalog
/pricing
/orders
/pkg
`
•Enforce boundaries by placing non-exported internals inside /internal so they’re not importable outside the module subtree. Add staticcheck and custom linters to prevent cross-imports.
.NET (solution with projects per module):
`
Acme.sln
src/
Identity/Identity.csproj
Catalog/Catalog.csproj
Pricing/Pricing.csproj
Orders/Orders.csproj
App/App.csproj
`
•Use InternalsVisibleTo sparingly; prefer public interfaces packages. Use Roslyn analyzers or NDepend to enforce dependency rules.
Build and Test Pipelines That Scale
Core goals:
•Fast, incremental builds with caching and parallelism.•Test impact analysis to run only affected module tests on each change.•Artifact versioning and reproducibility.
Tools and tactics:
•Build systems: Bazel, Pants, Buck2 for polyglot monorepos; Gradle/Maven for Java; Nx/Turborepo for JS; Go’s native tooling plus mage/Taskfile.•Test sharding: Split module tests across runners. Cache test results with remote execution where available.•Branch strategy: Trunk-based development with short-lived branches. Use feature flags for risk mitigation.•Code owners: Map modules to teams through CODEOWNERS for review flows.
Example GitHub Actions pipeline (monorepo, module-aware):
`
name: ci
on: [push, pull_request]
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Determine affected modules
run: |
./tools/affected_modules.sh > affected.txt
- name: Build affected
run: |
xargs -a affected.txt ./gradlew --build-cache :$MODULE:build
- name: Test affected with sharding
run: |
xargs -a affected.txt -I{} ./gradlew :{}:test --max-workers=4
- name: Upload test reports
uses: actions/upload-artifact@v4
with:
name: test-reports
path: '
/build/test-results/'
`
For Nx (TS), use nx affected --target=test/build to achieve similar results. For Bazel, bazel query and bazel test //... with remote caching.
Contract tests inside a monolith:
•Keep consumer-driven contracts (e.g., Pact) between modules even if they call in-process. This makes the seam explicit and reduces rework if you split later.
Observability for a Modular Monolith
Design for service-like visibility within a single process:
•Tracing: Use OpenTelemetry. Assign span attributes for module.name, operation, and domain IDs (e.g., order_id). Treat module interfaces like service boundaries: create spans at module entry/exit.•Metrics: Namespace metrics by module (pricing_*), and prefer RED/USE patterns per module: rate of requests, errors, duration; utilization, saturation, errors for resources.•Logging: Structured logs with fields module, correlation_id, user_id (if appropriate), and feature_flag states.•Error budgets and SLOs: Define SLOs at module endpoints (e.g., Orders.placeOrder latency < 200ms p95) and track error budgets. Even though you deploy once, you can attribute budget burn to modules.
OpenTelemetry example (Java/Spring):
`
Span span = tracer.spanBuilder("orders.place")
.setAttribute("module", "orders")
.setAttribute("order.id", orderId)
.startSpan();
try (Scope s = span.makeCurrent()) {
pricing.quote(cart);
inventory.reserve(items);
payment.authorize(...);
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}
`
Data Strategy: Owning Schemas, Not Spaghetti
A modular monolith can avoid the two extremes of a single, global, mutable schema accessed by everyone and a distributed mess of micro-databases. The middle path:
•Ownership per module: Each module owns its write tables and enforces invariants. In a relational database, use schema namespaces per module (orders., pricing., identity.*). Other modules cannot write directly.
•Read access patterns:
- Preferred: Use module APIs for reads to encapsulate logic.
- For performance: Expose read-only database views or materialized views that other modules can query. Version views to avoid breaking consumers.
•Migrations: Each module manages migrations for its schema with tools like Flyway/Liquibase (Java), EF Migrations (.NET), Alembic (Python), Prisma (Node). Coordinate in CI so that all migrations run in a stable order.
•Transactions: Keep aggregates small. If you need multi-aggregate consistency across modules, reconsider your boundaries or use an outbox/eventual consistency with clear UX fallback.
•IDs and referential integrity: Prefer global, sortable IDs like ULIDs for cross-module joins. Maintain foreign keys where appropriate; do not rely solely on application-level checks.
•Eventing: Publish domain events on changes (e.g., OrderPlaced) through an in-process event bus. If you might externalize later, persist events via an outbox table and stream with Debezium/Kafka or Redpanda when needed.
PostgreSQL example schema namespacing:
`
CREATE SCHEMA orders;
CREATE SCHEMA pricing;
CREATE TABLE pricing.price_rule (
id uuid PRIMARY KEY,
sku text NOT NULL,
currency text NOT NULL,
amount_cents integer NOT NULL,
effective_at timestamptz NOT NULL
);
CREATE TABLE orders.order (
id uuid PRIMARY KEY,
user_id uuid NOT NULL,
subtotal_cents integer NOT NULL,
currency text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Read-only view for cross-module reporting
CREATE VIEW orders.v_order_summary AS
SELECT o.id, o.user_id, o.subtotal_cents, o.currency, o.created_at
FROM orders."order" o;
GRANT SELECT ON orders.v_order_summary TO reporting_role;
`
Safely Collapsing Services: A Phased Migration Plan
Preconditions and baseline
•Executive alignment: Agree on the why. Target outcomes: 30–60% reduction in infra spend for the app tier, 20–40% reduction in p95 latency for critical endpoints, improved MTTR.•Inventory: Build a service catalog (Backstage is a good choice) listing each service’s owners, APIs, SLAs, dependencies, data stores, and cost.•Dependency graph: Generate call graphs from traces and API gateway logs. Identify hot paths and cycles.•SLOs and error budgets: Establish a baseline to compare post-migration performance and reliability.
Phase 0: Design the modular target
•Define modules via event storming and context mapping. Produce a context map with upstream/downstream and shared kernel segments.•Choose the host runtime: Keep the dominant language and runtime for the monolith to simplify hiring and ops.•Skeleton repo: Create the new monorepo (or central repo) with module directories, build files, and scaffolding tests. Integrate linting and boundary checks from day one.•Sidecar patterns in-process: Decide on an internal adapter layer that emulates current service APIs to allow a reversible path.
Phase 1: Create a compatibility shell (“reverse strangler”)
•Inbound: Keep the API gateway contracts stable. Route calls for Service X to the monolith’s X module via new endpoints that match old service signatures.•Outbound: Where the monolith module must call an external service not yet collapsed, use the old client. This preserves external dependencies while letting you migrate one domain at a time.•Events: Forward events from the old bus into in-process handlers and vice versa. If Kafka is in play, maintain a bridge until all critical consumers move inside.
Phase 2: Move code and logic module by module
•Start with a low-risk, high-churn service to prove the tooling and deployment mechanics.•Embed the service code inside the corresponding module. Replace network calls with direct calls at the module adapter boundary; keep the external endpoint operational by proxying to the internal module.•Preserve contracts and tests: Port unit tests; turn integration tests into module-level tests. Keep consumer-driven contract tests running against the in-process adapter.•Shadow mode: For critical services, run the internal module in shadow—process the request internally and compare outputs with the external service without serving to users. Verify parity.
Phase 3: Consolidate data safely
•Strategy A (single DB, schema namespaces): Migrate the service-owned database tables into the shared DB under a module schema. Use logical replication or ETL to sync during cutover. Switch writes, then reads. Decommission the external DB.•Strategy B (read models and outbox): If full consolidation is risky, first move writes to the monolith while leaving read models external, fed by the outbox and CDC. Gradually move read consumers.•Tooling: Use Flyway/Liquibase to version migrations. For large tables, use online schema change tools (gh-ost, pt-online-schema-change) or managed solutions. For stream-based sync, Debezium + Kafka or cloud-native CDC.•Rollback plan: Keep old service write path behind a feature flag for fast rollback in early stages.
Phase 4: Optimize and harden
•Performance: Profile hot paths. Reduce marshaling; in-process calls are cheap—capitalize by collapsing unnecessary DTO layers while maintaining module APIs.•Observability: Ensure module spans and metrics provide parity with old per-service dashboards. Update SLOs to module level.•Resilience: Replace network circuit breakers with module-level bulkheads and timeouts. Constrain concurrency for expensive modules.•Release cadence: Move to predictable release trains (daily/weekly). Use canary by routing small traffic percentages to a new version of the monolith if you still deploy on a service mesh or gateway.
Phase 5: Decommission
•Remove unused service deployments, Helm charts, and Terraform modules. Shrink cluster size and remove load balancers.•Update on-call rotations to module-aligned responsibilities and alerts.•Archive repos after extracting libraries worth keeping.
Risk Management and Common Pitfalls
•Boundary erosion: Without enforcement, developers will cross import boundaries for convenience. Use linters, architectural tests, and code reviews to maintain discipline.
•Big-bang rewrites: Avoid. Prefer progressive migration with shadow traffic and strangler patterns.
•Data loss during consolidation: Use dual writes (temporary) and CDC-based verification with row-level checksums. Maintain cutover runbooks and checkpoints.
•Performance regressions: Over-abstracting module interfaces can add layers of indirection. Keep hot paths simple and measured.
•Over-centralized teams: Replacing microservices with a monolith doesn’t mean centralizing all decision-making. Keep teams stream-aligned and module-owned.
The Tooling Playbook
Domain and planning
•Event storming tools: Miro, FigJam, or pen and paper.•Context mapping: Context Mapper (DSL), Structurizr for diagrams-as-code.
Repo, builds, and dependency guards
•Monorepo: GitHub/GitLab, sparse-checkout or partial clones for speed; Git LFS for large assets if needed.•Build: Bazel, Pants, Buck2 for polyglot; Gradle/Maven for JVM; Nx/Turborepo for TS; Go native tooling.•Caching: Remote Build Execution (Bazel), Gradle build cache, Nx cloud cache.•Dependency enforcement: ArchUnit (Java), NDepend or ArchUnitNET (.NET), ESLint no-restricted-imports and enforce-module-boundaries (Nx), Python import-linter or deptry, Deptrac (PHP).•Code ownership: CODEOWNERS mapping modules to teams; Probot or GitHub rulesets for enforcement.
Testing
•Unit and module tests: JUnit/TestNG, xUnit/NUnit, Jest/Vitest, Go test.•Contract testing: Pact or simple JSON schema-based validation.•Test selection: Affected graph (Nx), Gradle build scans, Bazel queries.•Mutation testing for critical modules: PIT (JVM), Stryker (JS/.NET), mutmut (Python).
Observability
•Tracing: OpenTelemetry SDK + collectors. Backends: Jaeger, Tempo, Honeycomb, Datadog.•Metrics: Prometheus/OpenMetrics; dashboards in Grafana or vendor APM.•Logging: Structured logs via Logback/Serilog/Winston; correlate with trace IDs.
Data and migration
•Migrations: Flyway, Liquibase, EF Migrations, Alembic, Prisma.•CDC and streaming: Debezium, Kafka/Redpanda, cloud change streams.•Online schema changes: gh-ost, pt-online-schema-change; or managed equivalents.•Backups and verification: pg_dump logical, pg_verifybackup, row-count parity checks.
Runtime and deployment
•Packaging: Single container image with multi-process or single process; use distroless images.•Orchestration: Kubernetes still fine for a monolith; use HPA for CPU/RPS scaling; set requests/limits carefully to avoid noisy neighbors.•Feature flags: LaunchDarkly, Unleash, OpenFeature SDKs.•Access control: OPA/Gatekeeper/Kyverno for policy; Vault/SM for secrets.
Security and compliance
•Module-level authorization: Keep authN at the edge; authZ enforced in modules. Centralize roles/permissions in Identity module.•Auditing: Append-only audit tables per module; include request_id, actor_id, action, before/after snapshots when appropriate.
Concrete Examples and Patterns
Java with Spring Modulith
•Spring Modulith provides conventions and testing support for modular structure and domain events. Define application modules via package structure; use @ApplicationModule, @ApplicationModuleListener.
`
@ApplicationModule(displayName = "Orders")
package com.acme.orders;
import org.springframework.modulith.ApplicationModule;
`
•Events:
`
public record OrderPlaced(UUID orderId) {}
@Component
class OrderService {
private final ApplicationEventPublisher events;
...
public void place(Order o) {
// business logic
events.publishEvent(new OrderPlaced(o.id()));
}
}
@Component
class InventoryHandler {
@ApplicationModuleListener
void on(OrderPlaced evt) {
// reserve stock
}
}
`
TypeScript with Nx and module boundaries
`
// nx.json
{
"affected": { "defaultBase": "main" },
"projects": { "identity": {"tags": ["scope:identity"]}, ... },
"namedInputs": {"default": ["{projectRoot}/
/*"]}
}
// eslint enforce boundaries
"@nrwl/nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{ "sourceTag": "scope:orders", "onlyDependOnLibsWithTags": ["scope:orders","scope:shared"] }
]
}
]
`
Go import boundaries
`
// internal/importcheck/check.go
// A simple CI script to fail if forbidden imports appear.
`
.NET with Roslyn analyzers
•Create custom analyzers to prohibit namespace references across modules that aren’t in public contracts. Combine with NDepend dependency rules.
Performance and Scaling in a Modular Monolith
•Vertical and horizontal scaling: A monolith scales horizontally just fine. Run multiple replicas behind a load balancer. Profile and remove hotspots; use async processing for background tasks.
•Concurrency models: Choose appropriate concurrency primitives for your runtime (virtual threads in Java 21+, async/await in .NET/Node, goroutines in Go). Avoid global locks; confine state to module-level services.
•Caching: Layered caches per module; shared cache where necessary but keep keys namespaced by module. Avoid cross-module cache invalidation coupling.
•Resilience: Bulkhead via thread pools or semaphores at module boundaries for expensive operations.
Governance Without Bureaucracy
•Lightweight ADRs: Record architecture decision records for module boundaries, shared libraries, and cross-cutting concerns.
•Change control: No CAB ceremonies; use fast code reviews with clear owners and pre-merge checks.
•Security reviews: Module-level threat modeling; shared patterns for input validation, output encoding, and secrets handling.
Cost Modeling: Estimating Savings
•Network: Measure inter-service traffic GB/month and cost per GB. Multiply by the fraction you can collapse (often 50–90% for chatty paths).
•Compute: Fewer sidecars and idle autoscaled services. Calculate per-service overhead CPU/memory versus a consolidated deployment.
•Storage: Consolidated caches, fewer duplicated read models.
•People and process: Fewer repos, pipelines, and deployments to maintain; less on-call load due to reduced blast radius from dependency chains.
Backward Compatibility and API Stability
•Keep external API contracts stable during transition. The gateway routes to old services or new module endpoints based on feature flags or path-based routing.
•Semantic versioning for public APIs. Internally, use ADRs to document interface changes; gate merges on consumer contract tests passing.
When You Should Not Consolidate
•You provide a platform for external teams who build against distinct services with separate lifecycles and strict tenancy. Hard isolation is a requirement.
•You rely on heterogeneous compute shapes (e.g., CPU-heavy, GPU-heavy, I/O-heavy) that are best scheduled independently with different autoscaling policies.
•Multiple regulatory regimes require strict segregation of data and processing flows in a way that a single deployable complicates.
Frequently Asked Questions
Q: Will a modular monolith slow down teams due to a single deployment?
A: With trunk-based development, feature flags, and fast CI, you can deploy multiple times per day. Teams can integrate continuously and release behind flags when not ready. The single deployment reduces coordination overhead for cross-module changes.
Q: How do we test module interactions without costly end-to-end tests?
A: Use contract tests at module seams and a few critical end-to-end smoke tests. Combine with synthetic transaction monitoring in production. Module-level integration tests are cheaper and more reliable than full-stack tests.
Q: Could we end up back in a big ball of mud?
A: Only if you neglect boundary enforcement. Invest in architectural tests, CI rules, and code reviews. Keep technical debt visible via dashboards (dependency violations, cycle detection) and make it part of the definition of done.
Q: Can we still split later if growth demands it?
A: Yes—if you keep explicit interfaces, domain events, and module-owned schemas. Many teams treat the monolith as a staging area for domains that eventually require independent scaling or isolation.
A Checklist to Start Next Week
•Decision clarity: Write one page stating why you’re consolidating and what success looks like.
•Inventory: Generate a dependency/call graph and cost breakdown per service.
•Domain map: Identify 5–8 initial modules.
•Repo skeleton: Set up the monorepo with build/test/lint and boundary checks.
•Observability plan: Standardize traces, logs, and metrics with module attributes.
•Data plan: Choose schema namespacing and migration tooling.
•Pilot: Pick one low-risk service to move first. Define shadow tests and rollback.
Closing Thoughts
Microservices are a powerful tool when the organizational and technical context demands strong isolation and independent evolution. But for many product teams, especially in 2025’s cost- and reliability-conscious climate, they impose unnecessary friction. A modular monolith lets you preserve clear domain boundaries and developer autonomy while shaving off network, operational, and cognitive overhead. The key is discipline: enforce interfaces, own your schemas, instrument like services, and automate relentlessly. With the right tooling and a phased plan, consolidation can make your teams faster, your systems simpler, and your spend saner—without closing the door on future decomposition if and when it truly pays off.