Representative interview topic

C++ interview: how would you design a composable error contract with std::expected?

CodingMedium
Offer.cc Editorial TeamPublished Updated

Question

An order service can fail during validation, inventory reservation, and charging. Design the error-propagation chain with C++23 std::expected and explain when exceptions still belong at the boundary.

Prompt and context

An order service performs input validation, inventory reservation, and charging. Out-of-stock, declined payment, and dependency timeout are expected failures; programming errors, broken invariants, and unrecoverable resource failures need a separate path. The interviewer asks you to design return types, composition, and error recording with C++23 std::expected.

This tests error contracts and composable code. std::expected<T, E> contains either a value T or an error E; it does not retry, log, or roll back side effects for you. Define the error domain first, then show how callers inspect results, and finally state the exception and compensation boundaries.

What the interviewer is evaluating

  • Whether you distinguish business failure, dependency failure, and broken invariants.
  • Whether you correctly use has_value, value, error, unexpected, and [[nodiscard]].
  • Whether you can compose non-throwing failures with and_then, transform, and or_else.
  • Whether you avoid treating std::expected as a global exception replacement or a raw string error.
  • Whether you cover error mapping, idempotency, logging, and compensation.

Questions to clarify first

  • Does the codebase compile as C++23, and does its standard library implement the target APIs?
  • Is the error handled locally or serialized across a service boundary? Cross-service errors need stable codes.
  • Does a function own locks, file handles, or a reservation? State cleanup and compensation before returning failure.
  • Are order steps idempotent? Retrying a charge is different from releasing inventory twice.
  • Which diagnostic fields must be retained? User messages and internal logs should not be mixed.

A 30-second answer framework

“I would model expected business failures as std::expected<T, OrderError> so callers handle them explicitly. I would mark results [[nodiscard]], compose validation, reservation, and charging with and_then, and use or_else for consistent mapping and metrics. Exceptions remain for broken invariants, initialization failure, or a boundary that cannot safely recover. Every side effect needs an idempotency key, compensation plan, and non-sensitive logging.”

Deep-dive answer

Define the error domain

OrderError should contain categories a caller can act on, such as invalidrequest, outofstock, paymentdeclined, and dependency_timeout, plus controlled internal context. Do not put user copy, a stack trace, and a vendor payload into one string. Programming errors and broken invariants should not silently become ordinary business failures.

Choose value and error types

Validation can return std::expected<ValidatedOrder, OrderError>, reservation can return std::expected<Reservation, OrderError>, and charging can return std::expected<Receipt, OrderError>. Keep the error type movable, reasonably small, and explicit about ownership. Mark result-producing functions [[nodiscard]] so a caller cannot silently discard a failure.

Use explicit checks at clear boundaries

When steps are short or have different actions per error, explicit checks are easy to audit:

cpp
[[nodiscard]] auto reserve(const ValidatedOrder& order)
    -> std::expected<Reservation, OrderError>;

auto create_order(Input input) -> std::expected<Receipt, OrderError> {
  auto valid = validate(std::move(input));
  if (!valid) return std::unexpected(valid.error());

  auto held = reserve(*valid);
  if (!held) return std::unexpected(held.error());

  return charge(*valid, *held);
}

Use operator* and value() only after establishing success. Preserve the original category on the failure path; do not disguise out-of-stock as an opaque generic error.

Compose homogeneous chains with monadic operations

When every step returns std::expected, and_then continues only on a value and short-circuits on an error. transform maps a successful value, or_else records or converts an error, and C++23 also provides transform_error for boundary conversion. Keep side-effect order visible; do not hide charging, retries, and compensation inside an unauditable chain.

Set the exception boundary

Timeouts, out-of-stock, and declined payment are expected outcomes, so expected lets the business layer choose retry, user feedback, or manual handling. Broken invariants, initialization failure, or a boundary that cannot safely recover may use exceptions. Keep the boundary consistent: the same error category should not sometimes return and sometimes throw from the same function.

Handle side effects, idempotency, and compensation

expected transports a result; it does not undo a completed side effect. If reservation succeeds and charging fails, release the reservation or enter a compensating state. Charge retries need an idempotency key. Error objects may carry order ID, step, and retry advice without secrets; logging can associate a trace without storing payment credentials.

Make the contract testable

Test each error branch, short-circuit, mapping, and compensation order. Test that unexpected exceptions cross the boundary and are handled consistently. Pin compiler, standard-library feature macros, and build flags in CI. Across services, map OrderError to stable protocol codes instead of exposing C++ type names as API contracts.

Model high-quality answer

“I would define OrderError with stable categories and controlled internal diagnostics. Validation, reservation, and charging return [[nodiscard]] std::expected values. Out-of-stock, declined payment, and timeout are business failures handled explicitly by the caller; they are not all exceptions.

For a short chain I would use if (!result) and propagate std::unexpected(result.error()), keeping each side-effect boundary auditable. Pure homogeneous transformations can use and_then and transform, while or_else records and maps errors. I only call value() after success is established.

If reservation succeeds and charging fails, expected does not roll it back, so I record an idempotent state and run release or compensation. Exceptions are reserved for broken invariants and initialization failures that the current boundary cannot safely recover from. CI pins the C++23 toolchain and tests every error, short-circuit, and compensation path before mapping errors to stable service codes.”

Common mistakes

  • Treating std::expected as a retry engine: the return type has no retry semantics → decide in the business layer using error categories and idempotency.
  • Using std::optional and losing the reason: callers cannot distinguish stock failure from timeout → use a stable error type.
  • Ignoring [[nodiscard]]: callers can discard a failure → annotate result functions and promote warnings in CI.
  • Calling value() without checking: a failure can throw bad_expected_access → branch explicitly or check first.
  • Converting every exception to an error code: broken invariants may be swallowed → keep a clear exception boundary.
  • Putting secrets in an error object: logs or serialization can leak credentials → separate user copy, stable code, and internal diagnostics.
  • Ignoring completed side effects: inventory remains reserved after a failed charge → design idempotent state and compensation.
  • Sending C++ types across services: upgrades break the protocol → map to versioned stable codes and fields.

Follow-up questions and answers

Follow-up 1: How do you choose between std::expected and exceptions?

Use expected for expected business outcomes the caller can handle. Use exceptions for broken invariants, initialization failure, or a boundary that cannot safely recover. Consistency matters more than a universal rule: callers should not guess the control flow for one error category.

Follow-up 2: Why not std::optional<T>?

optional represents presence or absence but carries no reason. The order flow needs different actions for inventory, payment, and dependency failures, so it needs expected<T, E>.

Follow-up 3: Can charging run inside and_then?

It can, if side-effect boundaries remain visible and each step is retryable or compensatable. For a long chain with different policies, explicit if may be easier to audit; do not hide state changes for a functional-looking style.

Follow-up 4: How do you unify errors from several dependencies?

Define a stable domain error at the service boundary. Map vendor failures to a finite set of categories while retaining an internal cause. Use transform_error or or_else for mapping, record vendor codes and traces internally, and keep vendor wording out of user messages.

Follow-up 5: Is returning an error slower than throwing?

Measure the real paths. expected makes common failures explicit but may copy an error object or add branches; exceptions concentrate cost on the throw path. Choose from latency targets, compiler behavior, and error-frequency benchmarks, not an absolute claim.

Follow-up 6: How do you stop callers from forgetting to check?

Annotate result types and important functions with [[nodiscard]], turn compiler warnings into CI failures, and review every value() call. For cross-language APIs, add protocol-state and contract tests to cover what the compiler cannot enforce.

Public sources

Related questions

Related interview tool

Use Screenshot for a coding prompt

Capture the problem, then work through the constraints, solution, code, edge cases, and complexity in order.

View the tool