Software Design Reference Assessment · Phase V · System Architect

Assessment · Phase V

Phase 5 assessment · System Architect

State, invariants, contracts, style, release. Six scenarios and three artifacts that between them design a system from the domain outward, on the returns case the whole course has been building. Lessons 0051–0060.

Scenarios: 20 minWritten tasks: 85 min Self-scoring: 25 min

This is the last assessment before the capstone, and its three tasks are three of the capstone’s artifacts. Keep what you write — the state machine, the contract, and the style decision go straight into the capstone packet, improved rather than rewritten.

Part 1 · Six scenarios

The model has ReturnManager, ReturnProcessor, ReturnHelper and ReturnData. Support and operations call the thing a claim, and distinguish refund claims from exchange claims.

The first thing to fix in that model is:

Two candidate boundaries for the returns system. (a) By layer: API, business logic, data access. (b) By what changes together: eligibility policy, refund settlement, logistics.

The second split is the better boundary because:

A transition table with from, command, to and rule, but no actor column, cannot answer:

One aggregate holds a return’s lines, its eligibility decision, its refund settlement and its carrier label. Peak write rate across all returns is 40 a second.

The strongest reason to move the carrier label out of the aggregate is:

A producer adds a value to an enum in a published response. That is safe only if:

One team of six. Drivers: a guardrail requires strict consistency between the eligibility decision and the refund reservation; the pilot is four weeks away; any deployment must be reversible within ten minutes.

Those drivers point at:

Part 2 · Three written tasks

Twenty-five to thirty minutes each. Blank versions of all three artifacts are in the artifact pack.

Task 1 · The state machine behind the column

What to write

Refund settlement, described by finance: “A settlement is reserved when the return is accepted. It becomes authorized when the warehouse confirms the item arrived and matches. It is paid when the payment provider confirms. If the item never arrives within 21 days the reservation lapses. If the item arrives damaged, an agent may reduce the amount once, with a reason, before authorizing. A paid settlement can be recovered only by raising a debt, which is a separate process nobody here owns.”

In about twenty-five minutes write the transition table with five columns — from, command, to, actor, rule — then, for every transition, its side effects, whether recovery is by reversal, by compensation, or deliberately none, and finally: current state, audit trail, or full event history? One sentence of evidence for that choice.

Self-review — answer these before you open the reveal

Now score what you actually wrote, not what you meant. Each dimension links to its level descriptions.

Domain fidelity and invariant protection

Data ownership, contract evolution, and failure semantics

Operational ownership and initiative closeout

Not scored yet.

Reveal: a strong answer, a weak one, and the difference

Strong

Transition table.

FromCommandToActorRule
—reservereservedthe returns service, on acceptance one settlement per accepted return; amount is the refundable total at acceptance
reservedreducereserveda support agent at most once; a reason is required; the new amount must be lower and above zero
reservedauthorizeauthorizedwarehouse intake the received item matches a line on the return
reservedlapselapseda scheduled job, 21 days after reservation nothing has arrived; the clock is the reservation date, not the return request date
authorizedsettlepaidthe payment provider’s confirmation idempotent on the settlement id; a repeat confirmation is not a second payment

Side effects and recovery. reserve: no external effect; reversible by deleting the reservation. reduce: no external effect, but it is customer-visible once communicated; reversible in the data and awkward in the conversation, so the rule allows it once. authorize: none outside our boundary; reversible. lapse: sends a customer notification, which cannot be unsent — recovery is a forward transition, not a reversal. settle: moves money. There is no reversal at all; the description says so, and the honest table records “recovery: none within this system; raising a debt is a separate process with a different owner”. That row is the most important one in the table, because it marks the moment after which mistakes stop being ours to fix.

Two things the description does not answer, and I am not inventing: what happens when an item arrives after lapsing (day 23), and whether reduce is available after authorizing. Both go to finance as questions rather than into the table as guesses. The day-23 case is not hypothetical — carriers lose things and then find them.

History: audit trail, not event sourcing. The question people will actually ask is “why was this settlement lower than the order total, and who reduced it?” — support asks it, and finance asks it at month end. That needs who, when, from, to, and the reason string, which is a transitions table beside the current state. Full event sourcing would also answer it, at the price of replay, read models, event-schema evolution and tooling, none of which any current question needs. If regulatory reconstruction of the whole settlement lifecycle is ever required, that is the evidence that would change this — and it would be a new decision record, not a quiet migration.

Weak — and plausible

The settlement has five states: reserved, authorized, paid, lapsed and reduced. Transitions are reserved to authorized, authorized to paid, reserved to lapsed, and reserved to reduced. The rules are that you cannot pay before authorizing and cannot authorize after lapsing. I would store the state in a status column with a check constraint listing the allowed values, and keep an updated_at timestamp so we know when it last changed. Event sourcing would be the ideal solution here since this is financial data and we need a complete audit trail.

“Reduced” is not a state. Reducing changes the amount and leaves the settlement reserved, which is why the strong table shows it as a self-transition. Turning a command into a state is the most common way a state machine acquires states nobody can leave, and here it silently loses the “at most once” rule — the only rule in the whole description with a number in it.

No actors anywhere. The interesting facts in this domain are that a support agent reduces, a warehouse scan authorises, and a clock lapses. Without the actor column, all three become “the application”, and the authorization model that follows will be an afterthought.

A check constraint pins the values, not the transitions. It permits paid to reserved as cheerfully as it permits anything else. Guards live in the object; the constraint is a seatbelt, not a design.

“Event sourcing because it is financial” is a choice made by category. The strong answer names the two questions people actually ask, shows that a transition log answers both, and states the evidence that would justify the heavier option later. Reaching for the heavy machinery is not caution; it is a cost accepted without anyone naming it.

Task 2 · Both contracts for one seam

What to write

The seam is eligibility → return journey: something asks whether a return is allowed and gets a decision back. It is called in-process today, and the mobile app will call it across the network next quarter. In about thirty minutes write:

  1. The internal contract: the protocol in the domain’s language, what it promises about meaning and failure, and one test that pins a promise a type cannot express.
  2. The external contract: resource or method naming, identity, idempotency, error shape, and the compatibility policy — what may be added freely, what counts as breaking.
  3. Test the policy against three changes: an optional field is added, a new reason enum value appears, and the meaning of an existing field changes. For each: is it breaking, and which consumer finds out first?

Self-review — answer these before you open the reveal

Now score what you actually wrote, not what you meant. Each dimension links to its level descriptions.

Data ownership, contract evolution, and failure semantics

Quality and depth of module boundaries

Testability, observability, security, and reversibility

Technical communication and decision clarity

Not scored yet.

Reveal: a strong answer, a weak one, and the difference

Strong

Internal contract.

from typing import Protocol


class EligibilityPolicy(Protocol):
    """Decides whether a return may proceed.

    Promises:
      - exactly one decision per (return, evidence) pair; calling twice with
        the same evidence returns an equal decision
      - a refusal is a value, not an exception: Decision.refused carries a
        machine-readable reason and a customer-safe explanation
      - raises only when it cannot decide (evidence unavailable), never
        because the answer was no
    """

    def assess(self, request: ReturnRequest, evidence: OrderEvidence) -> Decision: ...

The promise a type cannot express is determinism, so that is what the test pins:

def test_assess_is_deterministic_for_the_same_evidence():
    policy = StandardEligibilityPolicy(window_days=30)
    request = a_return(placed_days_ago=31)
    evidence = order_evidence(total=Money("40.00", "EUR"))

    first = policy.assess(request, evidence)
    second = policy.assess(request, evidence)

    assert first == second
    assert first.refused
    assert first.reason is RefusalReason.OUTSIDE_WINDOW

External contract. POST /returns/{id}:assess — a custom method, because assessing is a domain verb and not the creation of a resource. Identity: the caller sends an Idempotency-Key minted when the customer presses the button and reused across retries; the same key returns the first decision, byte for byte. Errors carry a stable code, a human message, and a retryable boolean. Crucially, a refusal is 200 with a refused decision, not 4xx: the request was valid and the system answered. Reserving 4xx for malformed input and 5xx for “could not decide” keeps the mobile client’s error handling and the business’s refusal rate in different places.

Compatibility policy, as consumers may quote it. We may add optional response fields at any time. We may add values to reason with 30 days’ notice; consumers must treat unrecognized reasons as a generic refusal and must not switch exhaustively. We will never change the meaning of an existing field, remove a field, or make an optional request field required, without a new version and a 90-day overlap. Breaking is defined by that list, not by judgement.

The three changes.

ChangeBreaking?Who finds out first
An optional estimated_refund field is added No, under the policy Nobody, if consumers are tolerant readers. A consumer validating against a closed schema breaks immediately — which is why the policy states the tolerant-reader requirement rather than assuming it
A new reason value, HAZARDOUS_ITEM Not under the policy, and only because the policy said so in advance The mobile app, in whichever release ships after the value first appears. This is why the 30-day notice exists and why we run the contract test against the app’s pinned fixtures
refundable_total starts excluding shipping Yes, and the worst kind: the schema does not change Nobody, technically. Support finds out three weeks later from customers disputing amounts. Nothing in a type system or a schema registry catches this, so the only defense is the policy’s explicit prohibition plus a semantic contract test that asserts the field includes shipping

The third row is the point of the exercise. Two of these changes are caught by machinery; the expensive one is caught only by a written rule that somebody remembers to apply.

Weak — and plausible

The internal contract is a Python Protocol with an assess method taking a return and returning a decision. The external contract is a REST endpoint, POST /api/v1/returns/{id}/eligibility, returning JSON with a status field and a reason field, with 400 for invalid requests and 500 for server errors, documented in OpenAPI. Since it is versioned as v1, any breaking change can go into v2.

The three changes: adding an optional field is not breaking because it is additive; a new enum value is not breaking because it is additive; changing a field’s meaning is breaking, so it needs v2.

“Additive is not breaking” is the belief that causes the outage. A new enum value breaks every consumer with an exhaustive match, and there is always one. The rule is only safe when the contract has told consumers to be tolerant and something verifies that they are — which is a sentence in a policy plus a test, not a property of JSON.

The version number is doing the work a policy should do. “Anything breaking goes in v2” postpones the definition of breaking, which is the only part consumers need. It also quietly commits to running two versions, which nobody has costed.

Refusals and errors are conflated. A returned status plus 400 for invalid input leaves “we refused you” and “you sent nonsense” in the same place. The consequence is downstream: the refusal rate, a product number, ends up in the client’s error handling and the API’s error dashboards.

The changed-meaning case is answered with a version bump and no detection. Nothing proposed would notice that refundable_total now means something else, because the schema is identical. That is the one worth designing against, and the strong answer does it with a written prohibition and a semantic test rather than with tooling that cannot help.

Task 3 · The style, the rejection, and the release path

What to write

You have the returns system’s drivers, ranked:

  1. Eligibility decision returns within 800 ms at p95 under peak load of 40 requests a second.
  2. The refund reservation and the eligibility decision are never inconsistent, at any commit.
  3. A carrier outage must not stop returns being accepted; labels catch up within 15 minutes of recovery for 99% of accepted returns.
  4. A release is reversible within 10 minutes without restoring data.
  5. One team of six owns the whole thing, and the pilot is due 30 September.

In about thirty minutes: choose the architecture style, name the boundaries and which are deployable; write the style you rejected and the specific cost of rejecting it; then design the release path — what is deployed, in what order, behind what gates, and how each step is reversed.

Self-review — answer these before you open the reveal

Now score what you actually wrote, not what you meant. Each dimension links to its level descriptions.

Use of measurable architecture drivers

Quality of alternatives and trade-off reasoning

Delivery sequencing and uncertainty reduction

Operational ownership and initiative closeout

Not scored yet.

Reveal: a strong answer, a weak one, and the difference

Strong

Style: a modular monolith with one asynchronous edge. One deployable containing three modules with enforced import boundaries — eligibility, settlement, logistics — plus one worker process for carrier labels, fed from an outbox table written in the same transaction as the acceptance.

Driver by driver. Driver 2 (never inconsistent) puts the eligibility decision and the refund reservation in one aggregate and one transaction; that is the single decision that rules out splitting them across processes. Driver 3 (carrier outage) is the one thing that must not be synchronous, so it is the one asynchronous edge: acceptance writes the label intent to the outbox and commits, and the worker drains it. Driver 1 (800 ms) is comfortable in-process, and the fraud call inside it gets a 400 ms timeout with a manual-review fallback. Driver 4 (reversible in 10 minutes) is why there is one deployable and one pipeline: a single revert, and no cross-service version matrix. Driver 5 (six people, four weeks) is why the boundaries are import rules rather than network calls — the seams exist, and nobody pays the operational cost of them yet.

The boundaries are real, not decorative: three Python packages, an allowed-import table, and a test that fails the build when it is violated. Deployable boundaries: two, the web process and the label worker. Everything else is a compile-time boundary that could become a deployable one later without changing its interface, which is the only thing that makes “later” credible.

Rejected: three services, one per module. It genuinely wins on driver 5’s second half if the team ever grows past one squad, and on independent scaling of eligibility, which is the only module with a latency driver. The cost of rejecting it: the day a second team owns settlement, we will pay for an extraction we could have had for free now. The cost of choosing it: driver 2 becomes a distributed transaction or a compensation path, driver 4 becomes a three-service rollback with a version matrix, and driver 1’s budget starts paying two network hops — all inside four weeks and six people. I would rather pay the extraction later, with evidence, than the operations now, on a guess. Revisit when a second team takes ownership of any module, or when eligibility’s scaling needs diverge from the rest by more than a factor of three.

Release path.

StepDeployedGateReversal
1Schema expansion: returns tables and outbox, nothing reads them migration runs on a production-sized copy in under 2 minutes drop, since nothing reads them
2Whole system dark: routes registered, feature flag off synthetic transaction passes end to end in production flag is already off; revert the deploy
3Internal users only, by allow-list 20 real returns completed by staff, zero manual interventions empty the allow-list — seconds, no deploy
45% of eligible customers outcome dashboard live: median resolution time and support-contact rate, compared with the same weekday last week; p95 under 800 ms; manual queue under 50 a day flag to 0%; accepted returns already in flight continue, which is why the flag gates entry and not processing
525%, then 100% two consecutive weeks inside every guardrail flag percentage down; the data written stays valid either way

Every step reverses by a flag or a deploy, and none needs a restore, which is driver 4 satisfied by construction rather than by hope. The one exception is step 1’s migration, which is expansion only — and the contraction that eventually drops the old columns is deliberately after the pilot, because it is the only irreversible move in the plan and it is not needed to launch.

Temporary machinery, with its removal condition. The returns_v2 flag: removed when it has been at 100% for two weeks. The staff allow-list: removed at step 5. The dual-read shim over the old returns table: removed once the finance export has been reconciled against the new source for a full month. All three are on the closeout checklist with owners, because the flag that nobody removes is the config debt lesson 0035 warns about.

Weak — and plausible

I would use microservices with an eligibility service, a settlement service and a logistics service, communicating over a message bus for loose coupling. Each service has its own database following the database-per-service pattern. This gives us independent deployability, scalability and fault isolation, and it lets the team work on services in parallel to hit the deadline. For consistency between eligibility and settlement we would use the saga pattern with compensating transactions.

For release, we would deploy to staging, run the test suite, then deploy to production with a blue-green deployment so we can switch back instantly if there are problems.

Not one of the five drivers appears in the reasoning. The justification is a list of properties — loose coupling, independent deployability, fault isolation — that are good in general and were not asked for by anything on the page. Meanwhile driver 2, the guardrail with zero tolerance, is answered with “a saga with compensating transactions”, which is a way of being inconsistent for a while and then fixing it. That is exactly what the driver forbids.

“Parallel work to hit the deadline” has it backwards. Six people and four weeks is an argument for one deployable, one pipeline and one rollback. Three services means three of everything, built by the same six people, during the weeks they are also building the product.

Blue-green does not satisfy driver 4. Switching back instantly is true of the application and false of the database: the moment the green side has written rows in the new shape, reverting is a data problem. The strong answer’s release path reverses with a flag at every step and keeps the only irreversible move — the contraction — outside the launch.

No rejected option, so there is no decision. The strong answer names what the rejected style genuinely wins on, and the specific bill for rejecting it. That is the difference between choosing and preferring.

Part 3 · What to practice next

If you missedWhat it points atWhat to practice next
p5q1 or p5q2Domain language and boundaries — cutting by layer or by pattern rather than by change.0052 and 0053.
p5q3State machines — a transition table missing the column that decides authorization.0054, then redo Task 1.
p5q4Consistency boundaries — aggregates sized by convenience rather than by invariant.0055 and 0012.
p5q5Contract evolution — the belief that additive changes are automatically safe.0057, then the brownfield case on compatibility.
p5q6Choosing a style from drivers instead of from a default.0059 and the multi-team supplement.
Task 1 scored below PracticingCommands modeled as states, or actors left as “the system”.0054 and lab checkpoint 5.
Task 2 scored below PracticingRefusals and errors sharing a code, or a version number standing in for a policy.0057 and 0045.
Task 3 scored below PracticingA style chosen from properties rather than drivers, or a release path that needs a restore.0059 and 0060.

Exit capability for Phase V. Given a domain description, you can produce a guarded state machine with named actors and honest recovery, write both contracts for a seam with a compatibility policy a consumer could quote back at you, and choose an architecture style that each of your drivers can be traced to — including the one you rejected and what rejecting it will eventually cost.

Next: the capstone assessment — the whole packet, all twelve rubric dimensions, and one peer review.