System Design Interview
Design a Clinical NEWS Score Calculator API
An endpoint that takes vital measurements, validates them, scores them against clinical ranges, and returns a risk score — safely and extensibly
The Interview Question
"Design an API endpoint that accepts physical measurements from a nursing home patient assessment — body temperature, heart rate, and respiratory rate — and returns a single NEWS (National Early Warning Score) value. The endpoint must validate inputs, reject values outside physiologically plausible ranges, and return clear error messages. The clinical scoring logic must be testable in isolation from the HTTP layer."
This question tests API contract design, input validation strategy, separation of concerns, error handling, and how you think about extensibility in a safety-critical domain. Getting the calculation wrong in a clinical context is not just a bug — it's a patient safety risk.
Step 1: Understanding the Scoring Rules
The NEWS algorithm scores each vital sign independently against defined ranges, then sums the three scores.
Range notation: start is exclusive, end is inclusive. So 35..36 means value > 35 AND value <= 36.
TEMP (°C) Score HR (bpm) Score RR (breaths/min) Score
────────────────────── ───────────────── ──────────────────────────
> 31 and <= 35 3 > 25 <= 40 3 > 3 and <= 8 3
> 35 and <= 36 1 > 40 <= 50 1 > 8 and <= 11 1
> 36 and <= 38 0 > 50 <= 90 0 > 11 and <= 20 0
> 38 and <= 39 1 > 90 <= 110 1 > 20 and <= 24 2
> 39 and <= 42 2 > 110 <= 130 2 > 24 and <= 60 3
> 130 <= 220 3
Outside any defined range → INVALID INPUTExample:
TEMP = 37 → falls in (36..38] → score 0
HR = 60 → falls in (50..90] → score 0
RR = 5 → falls in (3..8] → score 3
Total NEWS score = 0 + 0 + 3 = 3Step 2: API Contract Design
The endpoint accepts a list of measurements rather than named top-level fields. This is a deliberate extensibility decision — adding a new vital sign type (SpO2, blood pressure) doesn't change the request shape.
POST /api/v1/news/calculate
Content-Type: application/json
{
"measurements": [
{ "type": "TEMP", "value": 37 },
{ "type": "HR", "value": 60 },
{ "type": "RR", "value": 5 }
]
}Success response — 200 OK:
{
"score": 3,
"breakdown": [
{ "type": "TEMP", "value": 37, "score": 0 },
{ "type": "HR", "value": 60, "score": 0 },
{ "type": "RR", "value": 5, "score": 3 }
],
"riskLevel": "low"
}The breakdown field is important for clinical transparency — nurses need to see which vital is driving the score, not just the total.
Risk level interpretation:
Score 0–4 → "low" → routine monitoring
Score 5–6 → "medium" → increased monitoring, consider escalation
Score 7+ → "high" → urgent clinical review requiredValidation error response — 422 Unprocessable Entity:
{
"errors": [
{
"type": "TEMP",
"value": 44,
"message": "TEMP value 44 is outside the valid range (31–42)"
}
]
}Why 422 and not 400? HTTP 400 (Bad Request) means the request was malformed — wrong JSON syntax, missing required field. HTTP 422 (Unprocessable Entity) means the request was structurally valid but the values fail business/domain rules. A temperature of 44°C is syntactically valid JSON; it's semantically invalid in the clinical domain. The distinction helps API consumers distinguish parsing errors from domain errors.
Step 3: Architecture — Separating Concerns
The most important design decision is separating the clinical scoring logic from the HTTP transport layer.
┌─────────────────────────────────────────────────────┐
│ HTTP Layer (Controller / Minimal API endpoint) │
│ - Parse JSON body │
│ - Call validation service │
│ - Call scoring service │
│ - Map result to response DTO │
│ - Return HTTP response with correct status code │
└──────────────────────┬──────────────────────────────┘
│ calls
┌──────────────────────▼──────────────────────────────┐
│ NewsScoreService (pure domain logic) │
│ - No HTTP dependencies │
│ - Takes domain objects, returns domain objects │
│ - Fully unit-testable without spinning up a server │
└──────────────────────┬──────────────────────────────┘
│ uses
┌──────────────────────▼──────────────────────────────┐
│ ScoringRules (static data / configuration) │
│ - Defines ranges and scores per measurement type │
│ - Can be loaded from config if rules change │
└─────────────────────────────────────────────────────┘Why does separation matter here specifically?
In clinical software, the scoring algorithm is the medically certified component. Regulators and clinical leads need to review, test, and sign off on that logic independently of how it's exposed. If the scoring logic is tangled inside the controller, you can't unit test it without HTTP scaffolding, and you can't reuse it in a batch recalculation job or a FHIR-based integration endpoint.
Step 4: Implementation — C# / .NET
// Domain model — no HTTP references
public record Measurement(MeasurementType Type, int Value);
public enum MeasurementType { TEMP, HR, RR }
public record MeasurementScore(MeasurementType Type, int Value, int Score);
public record NewsResult(int Score, IReadOnlyList<MeasurementScore> Breakdown)
{
public string RiskLevel => Score switch
{
>= 7 => "high",
>= 5 => "medium",
_ => "low"
};
}// Scoring rules — data-driven, easy to audit and extend
public static class ScoringRules
{
// Each entry: (exclusiveStart, inclusiveEnd, score)
public static readonly IReadOnlyList<(int From, int To, int Score)> Temp =
[
(31, 35, 3), (35, 36, 1), (36, 38, 0), (38, 39, 1), (39, 42, 2)
];
public static readonly IReadOnlyList<(int From, int To, int Score)> Hr =
[
(25, 40, 3), (40, 50, 1), (50, 90, 0), (90, 110, 1),
(110, 130, 2), (130, 220, 3)
];
public static readonly IReadOnlyList<(int From, int To, int Score)> Rr =
[
(3, 8, 3), (8, 11, 1), (11, 20, 0), (20, 24, 2), (24, 60, 3)
];
public static IReadOnlyList<(int From, int To, int Score)> For(MeasurementType type)
=> type switch
{
MeasurementType.TEMP => Temp,
MeasurementType.HR => Hr,
MeasurementType.RR => Rr,
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
}// Pure scoring service — no I/O, fully deterministic
public class NewsScoreService
{
public Result<NewsResult, List<ValidationError>> Calculate(
IReadOnlyList<Measurement> measurements)
{
var errors = Validate(measurements);
if (errors.Count > 0)
return Result.Failure(errors);
var breakdown = measurements
.Select(m => new MeasurementScore(m.Type, m.Value, ScoreFor(m)))
.ToList();
return Result.Success(new NewsResult(
breakdown.Sum(b => b.Score),
breakdown));
}
private static int ScoreFor(Measurement m)
{
var rules = ScoringRules.For(m.Type);
// start exclusive, end inclusive: value > from AND value <= to
var match = rules.FirstOrDefault(r => m.Value > r.From && m.Value <= r.To);
return match.Score; // default(int) = 0 only reached if validation passed
}
private static List<ValidationError> Validate(IReadOnlyList<Measurement> measurements)
{
var errors = new List<ValidationError>();
var required = new[] { MeasurementType.TEMP, MeasurementType.HR, MeasurementType.RR };
foreach (var type in required)
{
var count = measurements.Count(m => m.Type == type);
if (count == 0)
errors.Add(new ValidationError(type, null, $"{type} measurement is required"));
else if (count > 1)
errors.Add(new ValidationError(type, null, $"Duplicate {type} measurement"));
}
foreach (var m in measurements)
{
var rules = ScoringRules.For(m.Type);
bool inRange = rules.Any(r => m.Value > r.From && m.Value <= r.To);
if (!inRange)
{
var (min, max) = (rules.Min(r => r.From), rules.Max(r => r.To));
errors.Add(new ValidationError(m.Type, m.Value,
$"{m.Type} value {m.Value} is outside the valid range ({min}–{max})"));
}
}
return errors;
}
}// Minimal API endpoint — thin HTTP adapter
app.MapPost("/api/v1/news/calculate", (
NewsCalculateRequest request,
NewsScoreService scorer) =>
{
var measurements = request.Measurements
.Select(m => new Measurement(m.Type, m.Value))
.ToList();
return scorer.Calculate(measurements) switch
{
{ IsSuccess: true, Value: var result } =>
Results.Ok(new NewsCalculateResponse(
result.Score,
result.RiskLevel,
result.Breakdown.Select(b =>
new MeasurementScoreDto(b.Type.ToString(), b.Value, b.Score)).ToList())),
{ Error: var errors } =>
Results.UnprocessableEntity(new { errors })
};
});Step 5: Validation Strategy — Two Layers
Input validation has two distinct levels, and it matters to keep them separate:
Layer 1: Structural validation (request parsing)
- measurements array must not be null or empty
- Each item must have a "type" field (string) and "value" field (integer)
- Type must be one of: TEMP, HR, RR
- Handled by: JSON deserialisation + FluentValidation or DataAnnotations
- HTTP response: 400 Bad RequestLayer 2: Domain validation (clinical plausibility)
- All three measurement types must be present (exactly once each)
- Each value must fall within the defined scoring ranges
- Values outside ranges are clinically implausible (mistyped)
- Handled by: NewsScoreService.Validate()
- HTTP response: 422 Unprocessable EntityExample error cases:
{ "type": "TEMP", "value": "thirty-seven" } → 400 (not an integer)
{ "type": "TEMP", "value": 37 } (HR missing) → 422 (required measurement absent)
{ "type": "TEMP", "value": 44 } → 422 (44 > 42, outside TEMP range)
{ "type": "TEMP", "value": 31 } → 422 (31 is the exclusive start of the first range)That last case is subtle: 31 is NOT in range because all start values are exclusive. 31 > 31 is false. The valid minimum TEMP is > 31, i.e., 31.1 — but since value is an integer, the minimum valid TEMP is 32.
Step 6: Testing Strategy
The separation of domain logic from the HTTP layer makes this comprehensively testable:
// Unit tests — no HTTP, no dependencies, fast
[Theory]
[InlineData(37, 60, 5, 3)] // TEMP:0 + HR:0 + RR:3 = 3
[InlineData(34, 35, 15, 4)] // TEMP:3 + HR:1 + RR:0 = 4
[InlineData(40, 140, 25, 8)] // TEMP:2 + HR:3 + RR:3 = 8 → "high"
[InlineData(37, 70, 15, 0)] // All in normal range = 0
public void Calculate_ReturnsCorrectScore(int temp, int hr, int rr, int expected)
{
var service = new NewsScoreService();
var result = service.Calculate([
new(MeasurementType.TEMP, temp),
new(MeasurementType.HR, hr),
new(MeasurementType.RR, rr)
]);
Assert.True(result.IsSuccess);
Assert.Equal(expected, result.Value.Score);
}
// Boundary tests — the inclusive/exclusive rule is where bugs hide
[Theory]
[InlineData(MeasurementType.TEMP, 31, false)] // exclusive start of first range
[InlineData(MeasurementType.TEMP, 32, true)] // first valid integer in first range
[InlineData(MeasurementType.TEMP, 35, true)] // inclusive end of first range
[InlineData(MeasurementType.TEMP, 42, true)] // inclusive end of last range
[InlineData(MeasurementType.TEMP, 43, false)] // outside all ranges
public void Validate_BoundaryValues(MeasurementType type, int value, bool shouldBeValid)
{ ... }Step 7: Extensibility — What Changes When You Add SpO2?
A common follow-up question: "How would you add oxygen saturation (SpO2) as a fourth vital sign?"
With the current design:
- Add
SpO2to theMeasurementTypeenum - Add ranges to
ScoringRules.For()switch case - Add
SpO2to the required types inValidate() - Zero changes to the HTTP endpoint, validation plumbing, or response model
The data-driven rules structure means the algorithm is a configuration concern, not a code change concern. In a regulated context, a scoring rule update can go through a clinical review process and be deployed as a configuration change — with the calculation logic unchanged and unchanged tests still passing.
Step 8: Clinical Audit Trail
In a real deployment, every score calculation must be logged — not just for debugging, but for clinical governance:
{
"calculationId": "calc_01HX9...",
"patientRef": "patient_ward2_room5",
"calculatedBy": "user_nurse_hansen",
"calculatedAt": "2026-04-20T09:14:22Z",
"inputs": [
{ "type": "TEMP", "value": 37 },
{ "type": "HR", "value": 60 },
{ "type": "RR", "value": 5 }
],
"score": 3,
"riskLevel": "low",
"rulesVersion": "NEWS2-2024-01"
}rulesVersion is critical: if scoring rules are updated (clinical guidelines change), you can still retrospectively see what rule set was in effect when a historical score was calculated.
What the Interviewer Is Actually Testing
- Do you separate domain logic from HTTP transport so the scoring algorithm is unit-testable independently?
- Do you understand the inclusive/exclusive range boundary and write tests for the boundary values specifically?
- Do you return HTTP 422 vs 400 correctly — structural errors vs domain validation errors?
- Do you include a breakdown by vital sign in the response, not just the total (clinically essential)?
- Do you design the scoring rules as data-driven configuration, not hard-coded if/else chains?
- Do you think about extensibility (adding new vital signs without restructuring the API)?
- Do you mention a clinical audit trail and why
rulesVersionmatters in a regulated context? - Do you validate both structural issues (missing field) and domain issues (value out of range) with appropriate error messages?
Related Case Studies
Go Deeper
Case studies teach the "what". Our courses teach the "how" — the patterns behind these decisions, built up from first principles.
Explore Courses