Scalable architecture is failing if the team can survive a traffic spike but needs three weeks to ship a safe schema change. My position: a 15-30 person engineering organisation should judge scalability by cost of change under load, not by how many services, queues, or replicas exist, because complexity is also a production bottleneck.
Scalability is working only if product change stays cheap under load
Scalable Software Architecture Patterns for Modern Systems becomes useful when its patterns are treated as measurable bets rather than design vocabulary, because a pattern that improves peak throughput while doubling coordination cost is a local win and an organisational loss.
For a tech lead, the first measurement question is not “can the system scale?” but “can we change the system while it is scaling?” That framing is deliberately narrow because a 20-person team cannot afford an architecture that requires a platform group, a release group, and a database reliability group to keep ordinary features moving.
I would not split a codebase into microservices merely because the domain has several nouns, because each new service adds deployment sequencing, distributed tracing, versioned contracts, incident ownership, and data reconciliation before it proves any capacity benefit.
The architecture is working when these things stay within tolerable bounds at the same time:
- Lead time for change: from merged pull request to production, measured by GitHub Actions, GitLab CI, Azure DevOps, or Buildkite. If it rises as services increase, the architecture is taxing delivery.
- Change failure rate: the percentage of deployments causing rollback, hotfix, or incident, using DORA-style definitions because the metric catches hidden release coupling better than deployment count does.
- p95 and p99 latency: measured at the edge and per internal hop, because averages hide queueing pain and make bad fan-out look acceptable.
- Cost per successful request: derived from cloud spend, compute minutes, database I/O, and message volume, because scaling that triples the bill for a flat user journey is an efficiency failure.
- Recovery time: minutes from alert to restored service, because an architecture nobody can debug is not scalable for a small team.
A measured baseline from one team might say: p95 checkout latency is 420 ms, p99 is 1.8 seconds, lead time is 14 hours, change failure rate is 18%, and cost is 0.21 cents per successful checkout. Those numbers are not universal targets; they are the starting line, because improvement without a baseline usually rewards the loudest architectural preference.
The disagreement I would expect is that throughput should lead the scorecard. Throughput matters, but it should not lead for a small engineering organisation because teams usually hit coordination limits before they hit theoretical compute limits. If 1,000 requests per second requires five engineers to babysit deployments, while 700 requests per second lets one engineer release safely, the second system may be more scalable for that organisation.
Your first scorecard should fit on one page
I would treat Designing Scalable NET Microservices Architecture at Scale as a set of claims to instrument in .NET rather than as an architectural destination, because ASP.NET Core services can look clean in diagrams while failing through thread-pool starvation, chatty HTTP calls, or database lock contention.
A practical scorecard for .NET 8 or .NET 9 should combine service-level indicators, delivery metrics, and resource signals. Use OpenTelemetry .NET SDK 1.9.0 or newer for traces and metrics, W3C Trace Context for correlation across HTTP and messaging, Prometheus 2.53 for scraping, Grafana 11 for dashboards, and either Jaeger 1.57 or Grafana Tempo for trace storage. Those tools are named because they expose failure modes that architecture diagrams usually erase.
For ASP.NET Core, start with RED metrics: request rate, error rate, and duration. Add USE metrics for infrastructure: utilization, saturation, and errors. Then add runtime-specific signals with dotnet-counters: System.Runtime GC heap size, thread pool queue length, allocation rate, and exception count. A service that holds p95 latency under 250 ms while thread pool queue length climbs is not healthy, because the next burst may convert latent saturation into timeouts.
For data access, enable PostgreSQL pg_stat_statements with pg_stat_statements.track = all, because EF Core query shape regressions often appear as repeated queries rather than obvious CPU spikes. For SQL Server, Query Store should be on, because plan regression is a scalability problem when a release changes cardinality or parameter behavior. For Kafka, track consumer lag per partition because a healthy producer can still create stale user-visible state. For RabbitMQ using AMQP 0-9-1, track ready messages, unacked messages, and consumer acknowledgements because queue depth alone does not show whether consumers are stuck or merely slow.
Use numbers that force decisions. A reasonable service-level objective to tune might be “99.5% of successful read requests under 300 ms over 28 days,” because a monthly window gives enough samples without hiding a week of damage. A separately chosen error budget could be 0.5%, because a team of 20 engineers needs permission to ship while still having a hard stop when reliability is being consumed. Kubernetes documentation lists the Horizontal Pod Autoscaler sync period default as 15 seconds via –horizontal-pod-autoscaler-sync-period, which matters because autoscaling cannot rescue a service from a 3-second overload spike. AWS publishes 60 seconds as the default Application Load Balancer idle timeout, which matters because long-running HTTP calls can fail even while the service itself is alive.
The scorecard should fit on one page because nobody reads a dashboard museum during an incident. If you need 30 panels to decide whether the architecture is working, the architecture is probably too opaque for the team size.
Synthetic load is valuable only when it breaks a real assumption
Load testing should falsify an architectural claim, because “we ran k6 and it looked fine” is theater unless the test models a decision you might reverse. If the claim is “we can horizontally scale the order API,” the test must hold the database, queue, cache, and downstream dependency behavior close enough to production to reveal the true bottleneck.
Run a small test continuously and a heavier test before risky releases. k6 v0.49, Grafana Cloud k6, Locust 2.x, and NBomber for .NET are all workable; k6 wins for simple HTTP checks because the script is small and CI-friendly, while NBomber wins for .NET-heavy protocol work because engineers can reuse C# models and libraries.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 50,
duration: '5m',
thresholds: {
http_req_duration: ['p(95)<300'],
http_req_failed: ['rate<0.01']
}
};
export default function () {
const res = http.get(__ENV.URL || 'https://test.k6.io');
check(res, { 'status is 200': r => r.status === 200 });
sleep(1);
}
The 50 virtual users in this script are a value to tune, not a maturity badge, because a background API with 50 concurrent users may be overloaded while a static content endpoint may barely notice it. The 300 ms p95 threshold is also a chosen service expectation, because a user-facing read path and an asynchronous report generation endpoint should not share the same latency promise.
For each load test, record at least four facts in the pull request or release note: the tested commit SHA, the traffic model, the bottleneck found, and the architectural decision taken. Without that record, load tests decay into screenshots, and screenshots do not help the next tech lead understand why the system has three queues and a cache.
Measure failure mode, not only capacity. Inject a slow dependency with Toxiproxy, add 200 ms latency to a downstream call, pause a Kafka consumer, or throttle PostgreSQL IOPS in a staging environment. This is worth doing because distributed systems usually fail through partial slowness before they fail through clean outages. If adding 200 ms to the pricing service turns checkout p99 from 900 ms into 6 seconds, the architecture has a fan-out or timeout problem regardless of its happy-path throughput.
Use explicit timeout, retry, and circuit-breaker settings in .NET. Polly v8 should not be configured with blind three-retry defaults, because synchronized retries can amplify an outage. Prefer bounded retries with jitter, per-dependency timeout budgets, and a circuit breaker that opens before thread pools saturate. Measure retry count as a first-class metric because a service can meet its success rate while quietly burning latency and downstream capacity.
The observability stack should be boring enough to audit
There are two defensible options for a 15-30 person organisation: Prometheus plus Grafana, or a managed observability suite such as Datadog or New Relic. Prometheus plus Grafana wins when you need cost control, data ownership, and simple Kubernetes-native scraping; it costs engineering time because someone must own retention, label cardinality, alert routing, and upgrades. Datadog or New Relic wins when the team needs fast setup, hosted retention, synthetics, profiling, and support; it costs more cash and can create vendor-shaped workflows because query language, billing units, and agent behavior become part of operations.
I would pick managed observability for the first year if the team has no dedicated platform engineer, because the salary cost of building a fragile internal stack often exceeds the invoice. I would pick Prometheus and Grafana when the system already runs on Kubernetes and the team has someone who understands scrape intervals, relabeling, and cardinality, because uncontrolled labels such as user_id or request_path with raw IDs can make any metrics system expensive or unusable.
Specific configuration matters. Prometheus scrape intervals around 15 seconds are a common starting point to adjust, because shorter intervals increase cost and longer intervals can miss burst saturation. OpenTelemetry should set OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, and OTEL_EXPORTER_OTLP_ENDPOINT, because traces without service identity become expensive logs with nicer colors. Kubernetes HPA using autoscaling/v2 should scale on a relevant metric such as requests per second per pod, queue depth, or CPU only when CPU is truly the bottleneck, because CPU-based scaling does not fix database locks or downstream rate limits.
Alerting should be tied to user impact, because alerting on every pod restart trains engineers to ignore alerts. Page on error-budget burn, sustained p95 latency breach, failed critical journeys, or queue lag that threatens freshness. Send diagnostic warnings to Slack or Teams, but keep paging rare enough that the person on call believes the alarm. A tunable starting point is a two-window burn-rate alert, such as 2% budget consumption in 1 hour and 5% in 6 hours, because it catches fast failures without paging on one bad minute.
Do not let traces replace logs, because traces explain request shape while structured logs explain business state and decision branches. Serilog, Microsoft.Extensions.Logging, or OpenTelemetry logs should include correlation IDs, tenant or account scope where appropriate, and stable event names. Avoid logging raw payloads because privacy exposure and storage cost both rise faster than debugging value.
Start by rejecting one architecture bet this week
The first concrete step is to choose one architectural belief and put a number beside it before the next sprint planning session. Write the belief as a sentence: “Splitting billing from the core API will reduce p95 checkout latency by 30% without increasing lead time.” Then instrument the current path, run one controlled test, and decide whether the evidence is strong enough to keep the bet alive.


