Business & Strategy - Software Design & Development

Platform rebuild or feature slice Which should a backend dev pick

Most backend developers meet “scalable architecture” too early and reach for service boundaries before they have traffic boundaries. My position: for a first modern web app, a modular monolith should be the default, and service-first architecture should be earned by evidence. That sounds conservative, but it prevents the worst beginner mistake: distributing uncertainty across databases, queues, deployments, and humans.

A modular monolith wins until the system has separate reasons to fail

A scalable backend is not automatically a set of microservices, because scale pressure usually starts inside one database, one slow endpoint, or one overloaded background job. A modular monolith with clear packages, internal interfaces, and one deployment gives a new backend developer fewer moving parts, so mistakes remain debuggable instead of becoming network mysteries.

Scalable Software Architecture Patterns for Modern Apps is right to treat patterns as options, but I would rank the modular monolith above service meshes for a first serious web backend because code boundaries are cheaper to correct than data boundaries. In practice, that means separate modules for billing, accounts, notifications, and search, but one runtime until the pressure to split is visible in metrics.

I would start with PostgreSQL 16, because row-level locks, partial indexes, EXPLAIN ANALYZE, and transactional integrity solve more first-scale problems than a fleet of services. I would add Redis 7 only for cache or ephemeral coordination, because using it as a primary data store too early creates recovery problems the first time memory pressure or eviction policy surprises you. If Redis is used, maxmemory-policy allkeys-lru should be an intentional choice, not a copied default, because cache eviction changes user-visible behavior.

The disagreement point is simple: I would not start a new web backend with Kubernetes, Kafka, and six services just to “be ready,” because readiness purchased with operational complexity usually delays the product feedback that tells you what actually needs scaling. Kubernetes 1.30 is excellent when you need scheduling, rollout control, and isolation, but it is a poor beginner default because every failure now includes pods, probes, ingress, DNS, service accounts, and container resource limits.

One vendor-published scale ceiling makes the point: Kubernetes documents cluster support up to 5,000 nodes and 150,000 pods, which is impressive but irrelevant when your immediate problem is a 900 ms checkout endpoint. A developer moving into this area should care more about one p95 latency target, one saturation signal, and one rollback path than about theoretical cluster size.

Service-first architecture wins when team ownership is already real

The explicit comparison is between Modular Monolith and Service-First Microservices. Modular Monolith wins when one team can still reason about the system, releases are frequent enough, and database transactions are part of the core workflow; its cost is deploy coupling, because a harmless notification change may still ship with account code. Service-First Microservices wins when separate teams own separate outcomes, failure isolation matters, and independent release cadence pays for itself; its cost is distributed debugging, because every user request can cross HTTP, gRPC, queues, retries, timeouts, and independent data stores.

I would pair Scalable Software Architecture for Modern Web Apps with a stricter rule: do not distribute a feature until one team can own its data and error budget, because shared ownership across a network boundary usually becomes nobody’s ownership during incidents.

Microservices are justified when boundaries are stable enough to name in business language and enforce in code, because service extraction before domain clarity creates thin wrappers over the same confused model. For example, an authentication service can make sense early because OAuth 2.1, OpenID Connect, token rotation, and session revocation form a coherent security boundary. A “user preferences service” is often premature, because it usually becomes a remote key-value table that every feature calls synchronously.

The first hidden cost is latency. HTTP/2 and gRPC reduce connection overhead, but they do not make remote calls free because serialization, TLS, retries, and tail latency still accumulate. The second hidden cost is observability. Prometheus 2.53, Grafana 11, and OpenTelemetry 1.32 are powerful, but a new developer must now define traces, spans, labels, metrics cardinality, and log correlation before they can confidently answer why one request failed.

Service-first architecture wins when failure isolation is more valuable than local simplicity. If a video transcoding worker, search indexer, or webhook dispatcher can be unavailable without taking the core API down, a separate service with a queue is reasonable because the blast radius is smaller. RabbitMQ 3.13 is often the better first queue for task dispatch because routing, acknowledgements, and dead-letter exchanges are straightforward. Kafka 3.7 is better when ordered event streams and replay matter, because partitioned logs preserve a history that task queues intentionally discard.

The decision should be made with thresholds, not architecture taste

A first-time scalability decision should be attached to thresholds because otherwise the loudest preference in the room becomes the architecture. I like three gates: latency, throughput, and operational independence. If one module violates a user-facing service-level objective, consumes a disproportionate share of resources, and needs a different release schedule, extraction becomes rational rather than fashionable.

For latency, choose a service-level indicator such as p95 request duration, because averages hide the slow requests that users notice. A reasonable starting target to tune is p95 under 300 ms for read-heavy API endpoints, while write-heavy workflows may deserve a different target because durable transactions and downstream consistency take time. For errors, a useful initial threshold is under 1% failed HTTP requests during a controlled load test, because a higher rate usually means the architecture is masking capacity or dependency failures.

Run a small test before you argue about a big redesign. This k6 v0.49 script is intentionally modest: 50 virtual users for 2 minutes is a value to adjust, not a universal benchmark, because the right load shape depends on your traffic model.

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 50,
  duration: '2m',
  thresholds: { http_req_duration: ['p(95)<300'], http_req_failed: ['rate<0.01'] },
};

export default function () {
  const res = http.get('http://localhost:8080/api/orders');
  check(res, { 'status is 200': r => r.status === 200 });
  sleep(1);
}

For throughput, requests per second is useful only with context, because 800 cached reads per second and 800 payment-confirming writes stress completely different parts of a system. For saturation, track CPU, memory, database connections, queue depth, and lock wait time, because a service split that ignores the bottleneck simply moves the same constraint behind a network call.

PostgreSQL’s default max_connections is commonly 100 in packaged installations, which is a practical warning rather than a capacity plan because each connection consumes memory and competes for scheduling. A backend developer should learn connection pooling with PgBouncer before service extraction, because ten services each opening 30 connections can harm the database faster than one monolith with disciplined pooling.

For observability, use RED metrics: rate, errors, and duration, because they map directly to user-facing behavior. Add USE metrics for infrastructure: utilization, saturation, and errors, because CPU graphs alone do not explain queue buildup or database waits. Prometheus scrape_interval: 15s is a common configured value for application metrics, but you should tune it because high-cardinality labels such as user_id or request_path can turn monitoring into its own scaling problem.

Data ownership is the boundary that decides the architecture

Code can be split with refactoring tools, but data cannot be split casually because transactions, history, reporting, and idempotency all depend on where truth lives. A modular monolith should still enforce data ownership internally, because a future extraction is much easier when only the orders module writes order tables and other modules consume explicit interfaces.

The worst compromise is a microservice fleet sharing one database schema, because you pay the cost of distributed deployment while keeping the coupling of a monolith. If five services write the same customer table, an incident requires coordinating code, schema, migrations, and rollback across all five, so the service boundary is cosmetic. If one service owns the table and others request changes through an API or event, the boundary is real because invariants live in one place.

I would use database schemas, migration ownership, and module-level tests before separate databases, because those controls find bad boundaries while rollback is still simple. Tools such as Flyway 10 or Liquibase 4 can enforce migration order, but they do not replace ownership because a migration file can still encode another module’s assumption. Contract tests with Pact 4 are useful after a service boundary exists, because they catch incompatible API changes before deployment, but they are unnecessary ceremony inside one process.

When a service is extracted, start with asynchronous integration unless the user is waiting for the answer, because queues absorb spikes better than synchronous chains. The outbox pattern is a good bridge: write the business change and an event record in the same PostgreSQL transaction, then publish to RabbitMQ or Kafka from a relay. This avoids the classic dual-write failure because the database commit becomes the source of truth for both state and message intent.

Choose Kafka when replay, partition ordering, and long-lived event history are required, because its log model supports consumers joining later without asking producers to resend. Choose RabbitMQ when work dispatch, retries, and routing are the center of the problem, because acknowledgements and dead-letter exchanges map cleanly to job processing. A starting partition count such as 12 is a planning value, not a magic number, because increasing partitions later can change ordering guarantees and consumer behavior.

Synchronous APIs still have their place. Use REST over HTTP/1.1 or HTTP/2 when broad compatibility matters, because browsers, proxies, NGINX 1.26, and common tracing tools understand it well. Use gRPC with Envoy 1.31 when internal contracts are stable and low-latency typed communication matters, because Protocol Buffers reduce ambiguity and Envoy gives retries, deadlines, and load balancing. Set explicit timeouts, because an unbounded remote call converts a dependency slowdown into thread exhaustion.

The infrastructure choice should follow the failure mode

Docker Compose v2.27 is enough for many early environments because it gives reproducible local dependencies without adding a cluster control plane. Kubernetes becomes worth it when you need horizontal pod autoscaling, rolling deployments, node isolation, and standardized runtime policy, because manual scripts become fragile once multiple services and environments exist.

Do not confuse “can run on Kubernetes” with “should be split into microservices,” because a well-structured monolith can run in a container with health checks, resource limits, and blue-green deployment. A single deployable behind NGINX or an ingress controller can scale horizontally when it is stateless, because session state can live in signed cookies, Redis, or the database depending on consistency needs.

The costs differ sharply. With a modular monolith, the main cost is coordination inside the codebase, so you need package boundaries, review rules, and tests that prevent cross-module leakage. With service-first microservices, the main cost is coordination outside the codebase, so you need CI/CD, tracing, service discovery, API versioning, secrets management, and incident playbooks before the architecture feels safe.

My practical trigger for extraction is narrow: split a module only after you can name the owner, the data store, the SLO, the deployment reason, and the failure behavior in one paragraph. If you cannot, keep it inside the monolith, because an unclear service boundary will turn every future change into a negotiation across a network.

Start tomorrow by drawing the current request path for one slow endpoint, then measure p95 latency, database time, external calls, and queue delay before proposing a split. Keep the module in-process if the bottleneck is a query, cache miss, or lock. Extract only when ownership, data, and failure isolation are already obvious enough that the new service removes coordination instead of creating it.