A startup founder asked me to review an architecture proposal: eleven microservices, three databases, a message bus and a Kubernetes cluster, for a product with two developers and no users yet. I asked one question: "Who needs to deploy independently of whom?" The answer was nobody. We built a modular monolith in six weeks; two years and a lot of users later, it has been split exactly once, along a boundary that was already there. This post is the reasoning behind that recommendation.
What microservices are actually for
Microservices came out of companies with hundreds of engineers where a single codebase meant a single deploy train, merge conflicts across teams, and one team's bug taking down another team's feature. Splitting into independently deployable services fixed an organisational scaling problem. The technical benefits (independent scaling, polyglot stacks, fault isolation) are real but secondary, and each has a cheaper partial solution inside a monolith.
What you pay for services:
- Network calls replace function calls. Every one can time out, fail partially or return stale data. A monolith function call has none of these failure modes.
- Transactions become sagas. "Create order and decrement stock" is one
BEGIN…COMMITin a monolith and a distributed workflow with compensating actions across services. - Joins become API calls. "Orders with customer names" is a SQL join or an N+1 across a network.
- Ops multiplies. Eleven services means eleven deploy pipelines, eleven sets of logs, eleven things to keep patched, and a tracing system to see a request cross them.
- Local development degrades. Running the product locally becomes a Docker Compose file with eleven containers and a wiki page.
For a small team none of those costs buys anything. The same team deploys everything anyway.
The modular monolith
A modular monolith is one deployable unit with internal boundaries as strict as service boundaries. Modules own their data, expose a small public interface, and never reach into each other's tables or internals. You get the design discipline of services with the operational simplicity of one process.
A module's public interface
// modules/catalog/index.ts -- the ONLY file other modules may import from catalog
export type { Product, ProductId } from './types';
export { getProductsByIds, reservePrices } from './service';
export { CatalogEvents } from './events';
// modules/orders/service.ts
import { getProductsByIds, reservePrices } from '@/modules/catalog'; // ✓ public API
// import { sql } from '@/modules/catalog/repo'; // ✗ forbidden by lint rule
export async function createOrder(input: CreateOrderInput, actor: UserId) {
const products = await getProductsByIds(input.items.map(i => i.productId));
// ...validation, totals...
return withTransaction(async (tx) => {
const order = await ordersRepo.insert(tx, {...});
await eventBus.publish(tx, 'order.created', { orderId: order.id, total: order.totalCents }); // outbox
return order;
});
}
The eventBus.publish(tx, …) uses the transactional outbox from my REST API guide: the event is written in the same transaction and delivered by a worker. Inside a monolith that worker can be the same process; when you extract a service, it becomes a real message queue and nothing in orders changes.
Enforce boundaries with tooling
A boundary that lives in a README is not a boundary. Make violations fail CI:
// eslint.config.js (eslint-plugin-import + boundaries rule)
export default [{
rules: {
'import/no-restricted-paths': ['error', {
zones: [
// Nothing outside a module may import that module's internals
{ target: './src/modules/!(catalog)/**', from: './src/modules/catalog/!(index.ts)' },
{ target: './src/modules/!(orders)/**', from: './src/modules/orders/!(index.ts)' },
{ target: './src/modules/!(billing)/**', from: './src/modules/billing/!(index.ts)' },
// shared/ may not import from modules (dependency direction)
{ target: './src/shared/**', from: './src/modules/**' },
],
}],
},
}];
On the database side, give each module its own schema (catalog.products, orders.orders) and a database role that can only touch its schema. Then a cross-module query fails at runtime, not in code review. In PHP projects I do the same with separate Composer packages per module and deptrac for the dependency rules.
When to extract a service
Three signals justify pulling a module out into its own deployable:
- A different scaling profile. Image processing needs 16 CPU cores for ten minutes an hour; the API needs 2 cores all day. Running them in one process means paying for 16 cores all day.
- A different team with a different cadence. The payments module is now owned by a team that deploys twice a day while the rest deploys weekly, and their changes keep blocking on unrelated failing tests.
- A hard isolation requirement. PCI scope, a third-party SDK that crashes the process, a component that must run in a different region for data residency.
Notice what is not on that list: "it is getting big", "microservices are best practice", "we might need to scale". A well-modularised 200k-line monolith is fine. The founder's product extracted its notification module after 18 months because it grew a Slack, WhatsApp and email fan-out that needed its own worker pool; the extraction took nine working days because the boundary already existed.
The extraction steps
- Confirm the module has no incoming imports except its
index.tsand no cross-schema queries (the lint rule and DB roles already guarantee this). - Replace the in-process event subscription with a real queue consumer (BullMQ, SQS, whatever fits).
- Replace the public function calls with an HTTP or RPC client that implements the same interface. Callers do not change.
- Move the schema to its own database (or keep it in the same cluster with its own role, which is fine for a long time).
- Deploy it separately. Add a circuit breaker on the client side.
Side by side
| Tangled monolith | Modular monolith | Microservices | |
|---|---|---|---|
| Deploy units | 1 | 1 | Many |
| Boundaries | None | Enforced in code and DB | Enforced by the network |
| Transactions | Easy | Easy within a module; events across | Sagas |
| Local dev | Easy | Easy | Hard |
| Independent scaling | No | Partial (worker pools, read replicas) | Yes |
| Independent team deploys | No | No | Yes |
| Ops cost | Low | Low | High |
| Team size sweet spot | 1-3 (briefly) | 1-30 | 30+ |
A note for WordPress and agency work
The same principle applies at smaller scale. A client site with a custom theme, six custom plugins and a headless frontend is already a distributed system with three deployables. Before adding a fourth, ask who needs to deploy independently. Usually the answer is to fold two of the plugins into one well-structured plugin with clear modules, following the class-based patterns in Modern PHP 8 for WordPress, rather than spinning up another service. And the decision of whether the frontend should be a separate Next.js deployment at all is the topic of my headless WordPress guide.
Not "monolith or microservices?" but "where are the boundaries, and are they enforced?" Get that right and the deployment topology becomes a reversible, incremental decision instead of a rewrite. Architecture that keeps its options open is the kind I try to deliver, whether the client is a two-person startup or an agency with thirty sites.