Monolith vs Microservices — A Pragmatic Guide for Startups
by Arif Ikhsanudin, Backend Developer
The distributed systems tax you pay upfront
Your team has five engineers and a product that has been live for four months. You are debating whether to split the billing service into its own microservice. The argument for: independent deployability, clear ownership, the ability to scale it separately. The argument against: you do not know yet what billing actually needs to do. This is the most common and most costly mistake I see startups make.
What a monolith actually costs you
Nothing — until you have a genuine reason to pay it. A well-structured monolith with clear module boundaries is not a technical debt. It is a pragmatic choice that defers coordination costs until you understand your domain well enough to draw the right service boundaries.
The Rails engine pattern, for example, lets you build near-microservice isolation within a monolith. Each domain — billing, notifications, user management — lives in its own engine with its own models, controllers, and migrations. The communication happens via Ruby method calls instead of HTTP, which means no serialization overhead, no network latency, no distributed transaction complexity.
# Modular monolith — Billing engine
# Located at engines/billing/
module Billing
class Engine < Rails::Engine
isolate_namespace Billing
end
end
# Clean boundary — other engines call Billing::SubscriptionService
# not BillingController or billing_subscriptions table directly
module Billing
class SubscriptionService
def activate(user_id:, plan_id:)
subscription = Billing::Subscription.create!(
user_id: user_id,
plan_id: plan_id,
status: :active,
started_at: Time.current
)
Billing::InvoiceJob.perform_later(subscription.id)
subscription
end
end
end
This is extractable into a microservice later. The interface is already defined. The data boundary is already clean. You pay none of the distributed systems cost during the period when you are still learning what the domain looks like.
What microservices actually cost you
Distributed systems introduce a class of failure modes that do not exist in monoliths: partial failures, network partitions, message ordering, idempotency requirements, distributed tracing overhead. These are solvable problems, but each one requires tooling, operational expertise, and developer time to solve.
A call stack in a monolith that takes 5ms becomes a chain of HTTP requests with 20-50ms overhead per hop in a microservices architecture. You fix this with gRPC (Protocol Buffers, multiplexed connections, ~2-5ms overhead) or by questioning whether the service boundary is right. Either way, it is a decision you now have to make.
Deployment complexity multiplies. A monolith has one deployment pipeline. Fifteen microservices have fifteen. Each needs its own CI configuration, Docker image, Kubernetes deployment manifest, health checks, secrets management, and rollback procedure. On a small team, this overhead crowds out product work.
# What one microservice adds to your infrastructure
# (multiply by number of services)
apiVersion: apps/v1
kind: Deployment
metadata:
name: billing-service
spec:
replicas: 2
template:
spec:
containers:
- name: billing
image: billing-service:{{ .Values.tag }}
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: billing-secrets
key: database-url
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
Multiply that manifest — plus the service definition, the ingress rules, the HPA configuration, the PodDisruptionBudget — by fifteen services and you have a full-time infrastructure engineering job that is not building product.
The actual decision criteria
Start with a monolith when:
- You have fewer than ten engineers
- The product is less than two years old and the domain model is still being discovered
- You do not have dedicated DevOps/platform engineering capacity
- Different parts of the system do not have wildly different scaling requirements
Consider extracting a service when:
- A bounded context has a genuinely different scaling profile (e.g., a video transcoding pipeline next to a CRUD API)
- Team ownership is creating merge conflicts and deployment coupling — your billing team cannot ship without coordinating with the auth team
- A specific component has security or compliance requirements (PCI DSS, SOC 2) that benefit from a hard perimeter
- You have proven the domain model is stable enough that the service interface will not need to change monthly
The rule that has served me well: Do not split a service until you have drawn the domain boundary in code first. If you cannot define the interface between two services as a clean set of method signatures — inputs, outputs, error cases — you do not understand the boundary well enough to pay the distributed systems cost.
The path from monolith to services
Extract the data layer first. Before splitting into services, ensure each domain manages its own database tables. Cross-domain joins in SQL are a sign the boundary is wrong or not yet clean. Once the data is isolated by domain, the service extraction is mechanical — the interface is already there, you are just adding an HTTP or gRPC transport in front of it.
The startups that do this well spend 12-18 months building a well-modularized monolith, then extract services one at a time based on actual operational pain, not anticipated scaling needs. The ones that start with microservices often spend 12-18 months debugging distributed failures in a system they do not yet understand.