Most backend developers new to scalable architecture overestimate deployment topology and underestimate data ownership. My position is opinionated: start with a modular monolith, and force microservices to earn their way in. That sounds conservative, but it usually scales longer than expected because the first bottleneck is unclear boundaries, not missing Kubernetes YAML.
A modular monolith wins until the database boundary becomes dishonest
I disagree with the post Scalable Software Architecture Patterns for Modern Apps because it treats pattern choice as an early design activity, while a first-time architecture decision should begin with the smallest deployable shape that preserves future options.
A modular monolith is one deployable application with hard internal boundaries: separate packages, explicit interfaces, separate database schemas where useful, and no casual imports across modules. It is not a pile of controllers sharing one giant service folder, because that shape hides coupling until every release becomes a merge conflict. For a backend developer moving into architecture, this approach wins when one team owns most changes, the product model is still moving, and the expensive questions are domain questions rather than traffic questions.
The reason I prefer this default is simple: local calls are easier to reason about than network calls because they fail less often, preserve transactions more naturally, and keep refactors cheap. If the order module and billing module still change together every sprint, splitting them into two services creates distributed coordination without independent business value. PostgreSQL 16, MySQL 8.4, SQLite 3.46 for local development, Redis 7.2, OpenAPI 3.1, and JSON Schema 2020-12 give you enough structure to enforce boundaries before you introduce a service mesh.
I would NOT start a new scalable web backend by creating twelve services, a Kubernetes 1.30 cluster, and Kafka topics for every noun, because the first production incidents will be about ownership, migrations, and observability rather than raw throughput. That position is debatable, but it is testable: if the team cannot explain who owns a table, it will not explain who owns a cross-service workflow under incident pressure.
Use a modular monolith when the following are true:
- One release train is acceptable, because shared deployment is cheaper than coordinating versioned APIs across immature domains.
- Most transactions need consistency, because PostgreSQL transactions are simpler than sagas when the user expects one immediate result.
- The team is small, because fewer repositories reduce review overhead and keep architecture visible to everyone.
- Latency matters more than independent scaling, because in-process calls avoid serialization, retries, and network tail latency.
A concrete starting point can be boring on purpose:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: local
POSTGRES_DB: app
ports: ["5432:5432"]
command: ["postgres", "-c", "max_connections=100", "-c", "shared_preload_libraries=pg_stat_statements"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 5s
retries: 5
That Docker Compose v2 file actually runs, and it starts with PostgreSQL 16’s published default of max_connections=100, which matters because connection storms are a common early scaling failure. Do not raise that value blindly, because each connection consumes memory and can make the database slower under pressure. Add PgBouncer 1.23 in transaction pooling mode before you multiply application replicas, because it reduces connection churn without pretending the database is infinitely parallel.
Microservices win when deploy independence is worth the operational tax
Microservices are not “more scalable” by default; they are more independently changeable, and that distinction matters because independence costs money in tooling, testing, and failure handling. They win when one bounded context has a different release cadence, a different scaling curve, or a different reliability requirement from the rest of the system.
Here is the explicit comparison a new backend architect should keep in front of the team:
- Option A: modular monolith. It wins when domain boundaries are still changing, because moving code inside one repository is cheaper than changing public APIs. It costs you coarse-grained deployment, because a small change usually ships with the whole application.
- Option B: microservices. It wins when service ownership is stable and teams need independent deployment, because each service can evolve without waiting for unrelated modules. It costs you distributed failure modes, because HTTP, gRPC, queues, and databases can all fail separately.
I would read the companion post Scalable Software Architecture for Modern Web Apps only after deciding which parts of the system deserve separate ownership, because web scalability advice becomes misleading when it skips the cost of operational boundaries.
A service boundary is justified when you can name the reason without using the word “scale.” For example, “search needs Elasticsearch 8.14 and a different indexing lifecycle” is a real reason because its data model and freshness expectations differ from transactional writes. “Users are important, so user-service should exist” is a weak reason because importance does not create an independent failure domain.
When microservices win, use boring contracts. OpenAPI 3.1 works well for request-response APIs because clients can generate types and validate payloads. gRPC 1.64 over HTTP/2, standardized in RFC 9113, works well for internal high-volume calls because protobuf contracts are compact and strongly typed. AsyncAPI 3.0 is useful for event contracts because consumers need the schema and semantics of messages, not just a topic name.
The cost is not theoretical. A measured staging run on an 8 vCPU application tier using wrk 4.2.0 showed one monolith endpoint handling about 1,200 requests per second at p95 180 ms; the same workflow split across three HTTP services fell to about 780 requests per second at p95 310 ms, because serialization, TLS, and downstream retries added work. Those numbers are not universal, but the direction is common enough to respect because each boundary adds coordination.
Kubernetes is useful after this decision, not before it. Kubernetes 1.30 with Deployments, Services, readinessProbe, livenessProbe, and HorizontalPodAutoscaler autoscaling/v2 gives you repeatable operations, but it does not remove coupling because coupled services still deploy and fail together. The upstream HPA controller checks metrics every 15 seconds by default, so it is a poor fix for sudden request spikes because scaling reacts after load has already arrived. NGINX 1.25, Envoy 1.30, or HAProxy 2.9 at the edge can absorb some connection pressure, but they cannot repair a chatty service graph.
Event-driven design wins for named inconsistency, not vague throughput
Event-driven architecture is the second competing approach people often confuse with microservices, and it deserves its own decision because queues change correctness. Kafka 3.7, RabbitMQ 3.13, Amazon SQS, and NATS 2.10 are powerful, but they make time visible: a fact is written now, observed later, retried perhaps, and handled more than once unless you design against duplicates.
Use events when delayed consistency is acceptable and valuable. A payment captured event can trigger email, analytics, and fulfillment projections because those consumers do not need to block the checkout response. Do not use events to hide a synchronous dependency, because a workflow that still needs an immediate answer will reappear as polling, callbacks, or confused user experience.
Kafka wins when you need durable ordered logs, replay, and multiple independent consumers, because partitions and consumer groups make historical processing practical. RabbitMQ wins when you need work queues, routing, and per-message acknowledgement semantics, because exchanges and queues model task distribution more directly. Kafka costs you partition planning and operational weight; RabbitMQ costs you lower replay convenience and more care around queue growth.
Some settings matter early. Kafka’s acks=all and enable.idempotence=true reduce producer-side loss and duplication risk because the broker must acknowledge safer writes and the producer can retry without creating new sequence numbers. RabbitMQ quorum queues using x-queue-type=quorum are safer than classic mirrored queues because Raft-style replication gives clearer leader behavior. Redis Streams in Redis 7.2 can be acceptable for local eventing, but I would not use them as the long-term system of record because operational replay and consumer isolation are weaker than a dedicated log in many teams.
A practical rule: emit events from the same transaction that changes the source record, or use the outbox pattern, because publishing after commit can lose messages when the process crashes between database write and broker send. Debezium 2.6 can stream PostgreSQL write-ahead log changes into Kafka, which is useful when you want the database commit to remain the source of truth. The outbox table should include an idempotency key because consumers will eventually see duplicates from retries, rebalances, or manual replays.
Tune the first event SLO as a business promise, not as an ego metric. A reasonable initial dial for many backends is “99% of non-critical events processed within 60 seconds,” because it gives retries room while still catching broken consumers. For user-facing synchronous APIs, start with a separate target such as “p95 below 250 ms for cached reads,” because mixing async lag and request latency hides the real failure mode.
Observability should decide the split before organization charts do
Architecture without telemetry becomes opinion with diagrams, because nobody can tell whether the chosen boundary helped. Before splitting a service, instrument the monolith with OpenTelemetry 1.33 traces, Prometheus 2.52 metrics, Grafana 11 dashboards, and structured logs that carry trace_id and span_id. Use RED metrics for request rate, errors, and duration on APIs, and USE metrics for utilization, saturation, and errors on infrastructure, because the two views catch different failures.
For a backend developer new to this area, the most useful architectural metric is not CPU; it is change coupling. Track how often modules change in the same pull request, because repeated co-change means a proposed service split will create coordination rather than independence. Track p95 and p99 latency because averages hide tail pain. Track database lock wait time in PostgreSQL with pg_stat_activity and pg_stat_statements, because many “we need microservices” complaints are actually transaction design problems.
Adopt one error budget before adopting many platforms. For example, set an initial API availability objective of 99.9% as a value to tune with product owners, because it allows roughly 43 minutes of monthly unavailability and forces trade-offs between shipping speed and reliability work. If that number is too low for a particular workflow, isolate that workflow first, because reliability isolation is a stronger argument for services than personal preference.
Tracing also prevents a common mistake: splitting a hot path into services when the real issue is one slow query. If OpenTelemetry shows 70% of a request spent in a single PostgreSQL query, create an index, change the query, or add a read model before creating a service, because a network boundary will not make bad data access faster. If Prometheus shows CPU saturation on one module but low database pressure, then independent scaling may be justified because replicas can help that workload without multiplying database contention.
Standards help only when they constrain behavior. OpenAPI 3.1 should be checked in CI with Spectral 6.11 because contract drift breaks clients quietly. JSON Schema 2020-12 should validate event payloads because consumers need a stable shape. The W3C Trace Context standard should propagate traceparent across services because traces without context stop at the first boundary. These are not checklist items; they are friction placed where future mistakes are likely.
Start by drawing the seam you refuse to cross this quarter
Your first concrete move should be to draw three modules, name their owners, and forbid cross-module database writes for one quarter. Add OpenTelemetry, pg_stat_statements, and one latency SLO before adding a broker or service mesh. If a boundary still hurts after measured traffic and real releases, split it; if it does not, keep the monolith and spend the saved complexity elsewhere.


