Why Microservices Are Overkill for Most Startups
Let me be blunt: if your startup has fewer than 30 engineers and you're running microservices in production, you're burning money and engineering hours on problems you don't have yet. I've watched founding teams spend months building Kubernetes clusters, configuring service meshes, and debugging distributed traces when they should have been shipping features to their first 100 customers.
The microservices pitch is seductive. Independent deployments. Polyglot freedom. Infinite scalability. But every one of those benefits carries a cost that startup advocates conveniently leave out of their architecture diagrams.
Operational Complexity Is the Real Killer
Running 10 microservices means 10 CI/CD pipelines, 10 deployment configurations, 10 sets of health checks, and 10 services that can fail independently. You need a container orchestration platform (Kubernetes, ECS, or Nomad), a service mesh for inter-service communication (Istio, Linkerd), service discovery (Consul, CoreDNS), centralized logging (ELK stack or Grafana Loki), distributed tracing (Jaeger, Tempo), and per-service metrics dashboards. That's easily $3,000 to $15,000 per month in tooling costs alone, before you factor in the engineering hours to maintain all of it.
For a 5-person team trying to find product-market fit, that operational overhead is devastating. Every hour spent debugging a timeout between your order service and inventory service is an hour not spent talking to customers or iterating on your core product.
Distributed System Pitfalls
Network calls fail. Services go down. Messages get lost. These aren't edge cases in distributed systems; they're Tuesday. You need retry logic with exponential backoff, circuit breakers to prevent cascade failures, idempotency keys to handle duplicate messages, saga patterns for distributed transactions, and compensation logic when partial operations fail. A senior engineer might handle this elegantly. A startup team learning distributed systems while building a product? That's a recipe for data inconsistency bugs that surface at 2 AM on a Saturday.
Debugging Nightmares
A user reports that their checkout is failing intermittently. In a monolith, you search one set of logs, find the stack trace, and fix the bug. In microservices, the request might flow through the API gateway, the auth service, the cart service, the inventory service, the payment service, and the notification service. The failure might be a timeout in one service, a stale cache in another, or a message queue that dropped an event. Without mature observability (which, again, costs money and time to set up), you're guessing.
We've seen teams spend entire sprint cycles tracking down bugs that would have been a 30-minute fix in a monolith. As we covered in our monolith vs microservices breakdown, the architecture you choose should match your team's size and operational maturity, not your ambition.
What a Modular Monolith Actually Is
A modular monolith is a single deployable application with strict internal module boundaries. You get one repository, one build pipeline, one deployment artifact, and one runtime process. But inside that process, your code is organized into discrete, well-encapsulated modules that interact through explicit public APIs rather than reaching into each other's internals.
Think of it like an apartment building. Every unit has its own walls, its own plumbing connections, and its own front door. Residents don't walk through each other's kitchens to get to the elevator. But the entire building shares one foundation, one electrical grid, and one address. If you eventually need to move a tenant into their own house, you can, because the walls and interfaces were already clearly defined.
The Key Difference from a Traditional Monolith
A traditional monolith is a big ball of mud. Your user controller imports the billing repository directly. Your notification service reads from the orders table. Dependencies are implicit, boundaries are nonexistent, and extracting any single piece of functionality requires untangling a web of cross-cutting concerns. That is what gives monoliths a bad reputation.
A modular monolith, by contrast, enforces discipline. Each module exposes a defined interface. Other modules cannot bypass that interface to access internal implementation details. Database schemas are owned by specific modules, and cross-module data access flows through explicit APIs. This is the critical distinction: you get the simplicity of a single deployment with the organizational clarity of separated domains.
What You Gain
- Development speed. One repo, one language, one build. Adding a feature that spans modules is still one pull request and one deployment. No network serialization, no API versioning between services, no coordinated rollouts.
- Straightforward debugging. One process, one set of logs, one debugger. Stack traces are complete. You can set breakpoints across module boundaries without distributed tracing infrastructure.
- ACID transactions where they matter. Since everything runs against one database (even if schemas are separated), you can use database transactions for operations that truly need atomicity. No saga patterns, no compensation logic, no eventual consistency headaches for your core business operations.
- A clear path to extraction. Because module boundaries are already enforced in code, extracting a module into its own service later is a relatively mechanical process. You already know its public API, its data ownership, and its interaction patterns with other modules.
Module Design Principles That Actually Work
The difference between a modular monolith that stays clean over two years and one that collapses into a spaghetti monolith in six months comes down to discipline in module design. Here are the principles we enforce on every project.
Domain-Driven Boundaries
Each module should map to a bounded context from your domain. Not a technical layer, not a database table, not a UI component. A business domain. Your "Billing" module handles subscriptions, invoices, payment methods, and usage metering. Your "Identity" module handles authentication, authorization, user profiles, and team management. Your "Catalog" module handles products, pricing, categories, and search indexing.
The litmus test: can a product manager understand what this module does from its name alone? If you have a module called "Utils" or "Shared" or "Common," you've already lost. Those modules become dumping grounds that couple everything together.
Public API per Module
Every module exports exactly one public interface. In TypeScript, this might be an index.ts file that re-exports the module's service classes and DTOs. In Java, it's a package-level interface. In .NET, it's a project-level contracts assembly. Everything else is internal. Other modules can only interact with what's explicitly exported.
This sounds simple, but it requires active enforcement. Without linting rules or architectural tests, developers will inevitably import an internal class "just this once" because it's faster. Six months later, you have 200 cross-module internal imports and your boundaries are fiction. Tools like ArchUnit (Java), NetArchTest (.NET), or eslint-plugin-boundaries (TypeScript) can enforce these constraints automatically in CI.
No Cross-Module Database Access
This is the rule that teams resist most and the one that matters most. Each module owns its tables. No other module writes to them. No other module reads from them with direct SQL. If the Orders module needs to know a customer's email, it calls the Identity module's public API, not a JOIN against the users table.
Yes, this means some data duplication. Yes, this means some operations are slightly less efficient than a direct JOIN. The tradeoff is worth it because it means you can change a module's internal schema without breaking other modules, and you can extract a module into its own service without rewriting every query that touched its tables.
Event-Based Inter-Module Communication
When something happens in one module that other modules care about, publish a domain event. The Orders module publishes "OrderPlaced." The Billing module subscribes and creates an invoice. The Notifications module subscribes and sends a confirmation email. The Inventory module subscribes and decrements stock.
In a modular monolith, this can be an in-process event bus. No RabbitMQ, no Kafka, no SQS. Just a simple publish/subscribe mechanism that dispatches events synchronously or asynchronously within the same process. NestJS has a built-in CQRS module for this. Spring has ApplicationEventPublisher. .NET has MediatR. The point is loose coupling between modules without the infrastructure overhead of a distributed message broker.
Implementation Patterns in Practice
Theory is nice. Let's talk about what this looks like in an actual codebase. I'll walk through the patterns we use across our NestJS, .NET, and Spring projects.
Folder Structure
Your top-level source directory should be organized by module, not by technical layer. Forget the controllers/services/repositories pattern that tutorials love. Instead, structure like this:
- src/modules/identity/ contains controllers, services, repositories, entities, DTOs, events, and an index.ts that exports only the public API
- src/modules/billing/ follows the same internal structure with its own controllers, services, and data access
- src/modules/catalog/ owns its own vertical slice of the application
- src/modules/orders/ handles the complete order lifecycle within its own boundary
- src/shared/ contains only truly cross-cutting concerns: logging, configuration, base classes, shared value objects
Each module directory contains its own layered architecture internally. The Identity module has its own controller, its own service, its own repository, and its own entity definitions. It's a self-contained vertical slice of the application.
Dependency Injection and Module Registration
Modern frameworks make module registration clean. In NestJS, each module is a class decorated with @Module() that declares its providers, controllers, imports, and exports. Only exported providers are accessible to other modules. This gives you compile-time enforcement of boundaries.
In .NET, each module registers its services in a dedicated extension method (AddBillingModule, AddIdentityModule) called from Program.cs. Spring Modulith takes this further by scanning module packages and verifying that no internal types leak across boundaries at test time.
Internal vs External APIs
Distinguish between APIs meant for other modules and APIs meant for external HTTP clients. Your Billing module might expose a BillingService with methods like createSubscription() and getInvoices() for other modules to call in-process. Separately, it exposes a BillingController with REST endpoints for the frontend. The in-process API can use rich domain objects. The HTTP API uses DTOs and handles serialization. Keeping these separate means your inter-module contracts stay clean even as your HTTP API evolves for frontend needs.
Framework Recommendations
If you're starting a new project in 2026, these frameworks have first-class support for modular monolith patterns:
- NestJS (TypeScript): Its module system with imports/exports was practically designed for this. Combine with @nestjs/cqrs for event-based communication and eslint-plugin-boundaries for static analysis of module dependencies.
- .NET Aspire: Microsoft's new opinionated stack for building cloud-ready applications. Aspire's service defaults project and module-level dependency injection make modular monoliths natural. When you're ready to extract, Aspire's orchestration layer lets you promote a module to an independent service with minimal rewiring.
- Spring Modulith (Java/Kotlin): Built specifically for this pattern. It provides architectural verification tests, module-scoped event publication, and documentation generation from module boundaries. The team behind it (Oliver Drotbohm and colleagues at VMware) literally wrote the book on modular design in Spring.
Database Strategy: Schema-per-Module in a Single Database
Your database strategy is where the modular monolith either shines or falls apart. Get this right and module extraction later is a straightforward migration. Get this wrong and you have a traditional monolith with fancy folder names.
One Database, Separate Schemas
Run a single PostgreSQL (or MySQL) instance, but create a dedicated schema for each module. The identity module owns the identity schema with its users, roles, and sessions tables. The billing module owns the billing schema with subscriptions, invoices, and payment_methods tables. The orders module owns the orders schema with its own tables.
This gives you logical separation without the operational overhead of running multiple database instances. Backups are simple. Migrations run against one database. Connection pooling is straightforward. But each module only has migration scripts for its own schema, and your ORM configuration scopes each module's entities to its own schema.
Foreign Key Discipline
Here's where it gets nuanced. In a pure microservices world, you can't have foreign keys across service databases. In a modular monolith, you technically can, but you shouldn't. Instead of creating a foreign key from orders.customer_id to identity.users.id, store the customer_id as a plain UUID in the orders schema. If the Orders module needs customer details, it calls the Identity module's API.
The exception: read-only reference data that rarely changes (countries, currencies, product categories). It's reasonable to share a reference schema with foreign keys pointing to it. But the core domain tables should maintain strict ownership.
Why this discipline matters: when you eventually extract the Orders module into its own service with its own database, there are no foreign keys to untangle. The data relationship was already based on IDs and API calls, not database-level constraints across boundaries.
The Eventual Migration Path
When a module needs to become its own service, the database migration follows a clear path. First, you create a new database instance and replicate the module's schema there. Then you set up dual-writes: the application writes to both the old schema and the new database. You verify data consistency. Then you cut reads over to the new database. Finally, you remove the old schema. This process is predictable and low-risk precisely because the module already owned its data exclusively. As we detail in our migration playbook, having clean data ownership boundaries is the single biggest factor in a successful service extraction.
Testing Strategy for Modular Monoliths
Testing a modular monolith requires a layered strategy that validates both individual module correctness and the contracts between modules. Here's the approach we use.
Module Isolation Tests
Each module should have its own test suite that runs independently. Mock or stub all dependencies on other modules and test the module's internal logic and its public API surface. These tests should be fast (under 30 seconds for the full module suite) and should run on every commit. They validate that the module's business logic is correct in isolation.
The key insight: test through the module's public API, not its internal implementation. If your Identity module exports a UserService with a createUser() method, write tests that call createUser() and verify the result. Don't test the internal UserRepository directly. This ensures your tests survive internal refactors without rewriting.
Integration Contract Tests
These tests validate the contracts between modules. When the Orders module calls IdentityModule.getUserById(), does it return the expected shape? When the Billing module publishes a "PaymentFailed" event, does the Orders module handle it correctly?
Contract tests sit between unit tests and full integration tests. They boot two modules (the consumer and the provider), but mock everything else. Tools like Spring Modulith provide built-in support for verifying module interaction contracts. In NestJS, you can use the testing module to create isolated multi-module test contexts.
Run these on every pull request. They catch breaking changes at module boundaries before they reach production.
Architectural Fitness Tests
These tests enforce the structural rules of your modular monolith. They verify that no module imports another module's internal types, that database entities only reference tables within their own schema, and that inter-module communication flows through the defined public APIs.
ArchUnit (Java) and NetArchTest (.NET) let you write these as executable tests. In TypeScript, eslint-plugin-boundaries combined with custom ESLint rules achieves similar enforcement. These tests are cheap to run and catch boundary violations before they become entrenched.
End-to-End Tests
Keep these minimal. Five to ten critical user journeys (signup, checkout, subscription management) tested against the full application. These validate that modules work together correctly in production-like conditions. Don't try to cover every edge case here. That's what module isolation tests are for.
This layered approach gives you fast feedback on most changes (module tests in seconds), reliable boundary enforcement (contract and architectural tests in under a minute), and confidence in critical paths (E2E tests in a few minutes). Compare that to a microservices testing strategy where you need contract tests between every pair of communicating services, integration environments that boot a dozen containers, and end-to-end tests that are slow, flaky, and expensive to maintain.
When to Extract a Module into a Service
A modular monolith isn't the end state for every module. Some will eventually need to become independent services. The key is knowing when extraction is justified and when you're extracting for the sake of architectural purity rather than real business need.
Clear Scaling Needs
If your search module needs 8 CPU cores and 32GB of RAM to run Elasticsearch indexing, while the rest of your application runs comfortably on 2 cores and 4GB, you're wasting resources by scaling the entire monolith to meet one module's demands. Extract the module, run it on appropriately sized infrastructure, and scale it independently. This is one of the few genuinely compelling reasons to extract early.
Different Deployment Cadence
Your core billing module deploys once a week after careful review. Your analytics module deploys three times a day as the data team iterates on dashboards and metrics. When modules have fundamentally different deployment rhythms and the slower one is gating the faster one, extraction lets each team deploy at their own pace. But validate this is actually a bottleneck first. If deploying the monolith takes 3 minutes and you're deploying daily, the "different cadence" argument doesn't hold.
Team Ownership Boundaries
When a specific team of 4 to 6 engineers fully owns a module and rarely needs to coordinate with other teams on changes, extraction gives them true autonomy. They own the service, its deployment pipeline, its on-call rotation, and its SLA. This works when team boundaries are stable. If teams are reshuffled every quarter, per-team services become organizational debt.
What to Watch Out For
Don't extract a module that has heavy synchronous dependencies on three other modules. You'll end up with a service that makes five in-process calls per request, turning nanosecond function calls into millisecond network hops. Profile the interaction patterns first. If a module is tightly coupled to others in its call patterns, it's a poor extraction candidate.
Don't extract because "it feels like it should be a service." Every extraction adds operational overhead: a new CI/CD pipeline, new monitoring, new on-call responsibilities, and API versioning between the service and the monolith. The benefit should clearly outweigh that cost.
Real Examples Worth Studying
Shopify ran a massive modular monolith on Ruby on Rails for years, processing billions in transactions before selectively extracting services. Their approach was pragmatic: they built the componentization tooling (Packwerk) to enforce boundaries within the monolith, then only extracted when a specific component had scaling needs that the monolith couldn't efficiently meet. They didn't extract "because microservices are better." They extracted because Storefront rendering needed to scale independently from Admin operations.
Basecamp and 37signals have been vocally anti-microservices for their scale. They run their products (Basecamp, HEY) as monolithic Rails applications with clean internal structure. Their argument: with fewer than 100 engineers, the coordination cost of microservices outweighs any benefit. They ship faster, debug faster, and spend less on infrastructure than competitors with three times their engineering headcount.
These aren't small companies avoiding complexity because they don't know better. They're experienced teams who chose the modular monolith deliberately because it optimizes for their actual constraints: shipping speed, team productivity, and operational simplicity.
Build the Architecture Your Team Actually Needs
The modular monolith is not a compromise. It's a deliberate architectural choice that optimizes for what most startups actually need: fast iteration, simple operations, and a clear path to scaling when the time comes. You get the domain boundaries and organizational clarity that microservices promise, without the distributed system tax that drains your team's capacity.
Start with a single deployable. Enforce strict module boundaries from day one. Use domain events for inter-module communication. Keep your database schemas separated by module. Test at the module boundary level. And only extract into services when you have a concrete, measurable reason to do so.
If your team is building a new product in 2026, this architecture gives you the best foundation. You'll ship faster than teams wrestling with microservices. You'll debug issues in minutes instead of hours. And when growth demands it, you'll have clean seams for extracting services, because you built the boundaries right from the start. As we explored in our multi-tenant SaaS architecture guide, the same module-per-domain pattern extends naturally to tenant isolation, data partitioning, and per-tenant customization.
We've helped dozens of startups design and implement modular monolith architectures across NestJS, .NET, and Spring. Whether you're starting a greenfield project or restructuring an existing monolith, we can help you set up the right module boundaries, database strategy, and testing patterns from the beginning.
Book a free strategy call and let's design an architecture that matches your team's real constraints, not the hype cycle.
Need help building this?
Our team has launched 50+ products for startups and ambitious brands. Let's talk about your project.