Lesson 144 of 159
Written lesson · 17 min

Understanding the Production Change Request

Turning "we need refunds" into questions, scope and acceptance criteria.

Preview

You are viewing a free preview lesson.

Until now, the Order Management Backend has evolved in one long arc: initial requirements, technical design, implementation, production readiness, shipping, a production incident and its corrective fix. Release 1.6.1 runs in production, and the application is no longer greenfield. It has a production database with historical Orders, nine Flyway migrations, API clients that depend on its contracts, a test suite, payment behaviour, security rules and observability that operations relies on.

Now Product brings a new requirement. This time nothing is broken. The business simply needs the system to do something it could not do before, and this module follows that change from the first conversation to a safe rollout.

The Production Change Request

Product and Support bring the following request:

Administrators need to be able to issue a full refund for a successfully paid Order through the existing Payment Provider. The Order must reflect the successful refund so Support and the Customer can see that the payment was refunded.

This capability was intentionally outside v1. Our Order lifecycle (7.11) knows two transitions out of UNPAID: a confirmed payment moves the Order to PAID, and a cancellation moves it to CANCELLED. Once an Order is PAID, the model treats it as terminal: markPaid() and cancel() both refuse it. There is no refund workflow, no refund API, no refund persistence, no refund operation at the provider boundary and no refund state anywhere in the system. That must now change.

Why This Is a Good Production Change

Refunds were not forgotten. They were deliberately excluded from the original scope: the v1 requirement (1.02, 1.04) kept paid-Order cancellation and refunds out, and the technical design (2.01) listed refunds among its non-goals and refused a speculative REFUNDED state. That let us keep the initial Order lifecycle to three states.

Now the business requirement has changed, so the engineering scope can change too. This is different from speculative design:

SituationCorrect response
"Maybe we will need refunds someday."Do not build them yet.
"Support needs refunds in production."Understand the requirement and evolve the existing system safely.

Do Not Start by Adding REFUNDED

The first instinct might be to open OrderStatus and add a value:

<!-- fragment -->
java
public enum OrderStatus {
    UNPAID, PAID, CANCELLED, REFUNDED
}

That may ultimately be part of the design, but it is too early. A production change request first has to answer what it actually asks for:

  • What does "refund" mean, and is it full or partial?
  • Who can request it, and which Orders are eligible?
  • What happens to Inventory?
  • What happens if the provider fails, or if the result is unknown? Can the refund be retried?
  • How is history represented, what do existing API clients see, and what happens to existing Orders?

Only then should we change the model.

Start With Product Intent

The business problem is that a Customer has successfully paid, Support determines that the payment must be returned, and the backend has no supported way to perform that operation. The desired outcome is a chain of four facts: an administrator initiates the refund, the existing Payment Provider processes it, the successful refund becomes durable system state, and the Customer and Support can see that the Order was refunded. That is the requirement we need to preserve through every design decision that follows.

First Scope Decision: Full Refund Only

For this change request, Product confirms:

v1 refund support is full-refund only.

If an Order total is €120.00, the supported refund is €120.00. Not €20.00, not one OrderItem only, and not an arbitrary amount.

Without this clarification, "refund" hides major complexity. Partial refunds raise their own questions: can one Order be refunded several times, can the refunded total exceed the Order total, can individual OrderItems be refunded, what if an item has quantity 5 and only 2 are refunded, how do we represent the remaining refundable amount, and how are repeated partial refunds displayed? Those are real requirements, but not this one. The scope remains one successful full refund for an eligible paid Order.

Second Scope Decision: Administrator-Initiated

Customers do not refund their own Orders. The operation belongs to an administrator or an authorized support operation, and customer self-service refunds are not part of this change.

Refunding money is a privileged financial operation. The requirement is not "any authenticated user can refund an Order they can see"; it is "an authorized administrator initiates it". The application already distinguishes the two kinds of permission: Customers act on Orders they own (8.03), and administrators carry the ADMIN role from the token's roles claim (8.04). The new capability uses that established model, and refund authority does not change the Customer's read ownership.

Third Scope Decision: Only Successfully Paid Orders

Refunding an Order for which no payment succeeded makes no sense. An UNPAID Order is not refundable, and neither is a CANCELLED one. The exact domain and API error behaviour will be designed later; for now, the business rule is:

Only an Order with a successfully completed payment can become eligible for refund.

Product also confirms that this happens at most once:

A successfully refunded Order cannot be refunded again.

Retry safety still matters, because the same refund operation may need to be retried after an uncertain provider outcome. But a separate, new attempt must never refund the same payment twice.

Refund Is Not Cancellation

This distinction is fundamental. Cancellation (7.12) moves an UNPAID Order to CANCELLED and restores the Inventory its creation consumed. A refund starts from a payment that already succeeded and returns money. These are different operations with different meanings, so a refund must never be implemented as order.cancel().

Inventory Is Not Restored

Product confirms another important rule:

A financial refund does not automatically restore Inventory.

This change request introduces no returns, no receipt of physical items, no warehouse restocking and no fulfilment reversal. A Customer receiving money back does not mean the product is back in sellable stock, so a successful refund does not call InventoryRepository.restore(...).

Our cancellation flow restores Inventory for a specific reason: the Order was unpaid, its Inventory was consumed at creation, and the Order will no longer proceed. A refund happens later, after the financial transaction completed. Whether stock should come back requires a separate physical-return requirement, and we do not invent one. For the same reason the change needs no return requests, warehouse receipts, restocking, shipment, delivery or tracking numbers: the Order Management Backend still does not model those, and the requirement should change only what it actually needs.

The Refund Amount Comes From History

The refund amount must be what was actually paid, not something recalculated from current Product data. Suppose Order O1 has an item with unitPriceCents = 5000, and the Product's price later becomes 7000. Refunding O1 must not suddenly return 7000 because the catalogue changed; every OrderItem keeps its purchase-time price (4.06). Ideally the refund corresponds to the successful payment itself, and the exact relationship will be assessed once we inspect the existing payment persistence and the provider contract.

The Existing Payment Provider Performs the Refund

The business already uses the external Payment Provider for payments (module 9), and the refund must go through the same integration boundary. We are not changing database state by hand to simulate a refund: the money actually has to be returned through the provider. That immediately tells us something important:

A refund is an external side-effecting workflow, just like payment.

So everything module 9 learned applies before we write a line: a timeout does not prove that the refund did not happen (9.03), PostgreSQL cannot roll back a provider success, so neither "mark refunded, then call" nor "call, then mark refunded" is safe on its own (9.05), and a blind retry after a lost response could refund twice (9.04). A confirmed rejection must leave the Order unrefunded; an unknown outcome is a real state that must be represented and recovered, not guessed (9.06).

Provider Refund Semantics Are Not Yet Known

Module 9 established what our application needs from a payment provider and built a placeholder protocol, but it left the payment contract's idempotency, reference format and status lookup open (9.02, 9.06), and it never looked at refunds at all. We do not know the provider's refund API, its idempotency behaviour, its refund reference format, its timeout semantics or whether a refund can be looked up afterwards. We must not invent those details silently.

So the change request contains a real technical unknown:

Before implementing provider-facing refund execution, we must understand the existing Payment Provider's refund capabilities and failure semantics.

Lesson 1.08 warned against fake spikes such as "Investigate whether PostgreSQL supports transactions". This one is genuine, and its answers affect correctness. The investigation should answer ten questions:

  1. How is a full refund initiated?
  2. Which identifier of the successful payment is required?
  3. Does the provider accept an idempotency key or a merchant refund reference?
  4. What does a confirmed successful response look like?
  5. What does a confirmed rejection look like?
  6. What happens on duplicate refund requests?
  7. Can refund status be queried later?
  8. Can a timeout leave the outcome unknown?
  9. Does the provider expose a stable refund ID?
  10. Are there provider states between "requested" and "completed"?

Provider-facing implementation stays blocked until the essential answers are known. That does not stop all analysis, as the next lesson shows.

Existing Orders and Clients Constrain the Change

Historical Orders already exist in production, in all three states, and Product says a PAID Order that existed before refund support must be refundable after the feature launches. Whatever refund persistence we introduce must let such an Order, with its existing payment information, enter the new workflow; we cannot require data that older paid Orders could never have, unless we can derive or migrate it safely. At the same time, historical UNPAID Orders stay unpaid and CANCELLED Orders stay cancelled, and GET /api/v1/orders/{orderId} must keep working for every old row.

API clients matter just as much. Customers already consume Order responses whose status is one of UNPAID, PAID and CANCELLED, documented as an enum in our OpenAPI contract (5.10). As 5.09 showed, a client may switch over exactly those three values, so a new value such as REFUNDED can change client behaviour even though nothing was removed. Adding it is an API compatibility decision, not a trivial Java enum edit. During a rolling deployment (12.04, 12.05) the old application version and the new schema briefly coexist too, so the migration should be additive where possible.

Visibility and the Command

Whether Order.status gains REFUNDED or the financial outcome lives in separate refund state is not yet decided; a combination is possible too. What Product does require is visibility: the Customer and Support must be able to see that the payment was refunded. Do not confuse that required observable behaviour with its representation.

We also need a command surface: an administrator requests a full refund, and the backend runs it. POST /api/v1/orders/{orderId}/refund is one possibility, but the exact shape belongs to the design. What we should avoid is generic payment CRUD such as /api/v1/refunds or PATCH /payment: our API favours explicit business operations like POST /api/v1/orders/{orderId}/pay and .../cancel, and 9.10 deliberately refused a generic /payments API.

The Refined Change Request

This change is not done merely when the provider returns "refund succeeded": the Definition of Done (1.06) applies as it did to every earlier feature. We can now rewrite the initial request as clear behaviour:

markdown
# Production Change Request — Full Order Refunds

## Goal

Authorized administrators can issue a full financial refund for an eligible
paid Order through the existing Payment Provider.

## Required Behaviour

- Only successfully paid Orders are eligible for refund.
- The refund amount is the full amount of the successful payment.
- A successful refund becomes durable system state.
- The Customer and Support can see that the payment was refunded.
- The same successful payment must not be refunded twice.
- A provider failure must not mark the Order refunded.
- An unknown provider outcome is handled safely, not retried blindly.
- Historical paid Orders are eligible where the required payment
  information exists.
- Refund initiation requires administrator authority; there is no
  customer self-service refund.
- A refund does not restore Inventory; returns and restocking are
  separate future concerns.

## Scope

Full refunds of paid Orders, initiated by administrators, through the
existing Payment Provider, with a durable outcome, safe failure handling,
protection against retries and duplicates, support for historical paid
Orders, and visibility for Customers and Support.

## Non-Goals

Partial and item-level refunds, returns, restocking, shipping, fulfilment,
refunds to a different payment method, store credit, coupons, discount
changes, multiple Payment Providers, automatic refunds on cancellation and
customer self-service refunds.

The non-goals keep the request from expanding into an entire commerce platform.

Open Technical Questions

Before implementation we still need answers to these questions:

  1. How should refund state be represented, and does OrderStatus change?
  2. What durable refund-attempt model is needed?
  3. Which refund capabilities does the Payment Provider offer, what identifies the original provider payment, and how does its idempotency work?
  4. How do we recover unknown outcomes?
  5. Which API starts a refund, and how is the refund exposed to Customers?
  6. How do we stay compatible with existing API clients?
  7. Which migration supports historical paid Orders?
  8. Which concurrency rules stop two administrators from refunding the same Order at the same time?

At this stage we do not create a REFUNDED value, a refunds table, a RefundService, a POST /refund endpoint or provider DTOs. We first needed a bounded, explicit requirement, and that is now complete.

Preliminary Risk Register

Even before detailed design, the requirement exposes seven risks:

RiskWhat can go wrong
Double refundTwo administrators, a retry or a timeout make the provider receive the refund twice.
Provider success, local failureThe provider refunds, then the database update fails; money has moved while local state stays stale. A major consistency risk.
Unknown outcomeThe refund call times out; we do not know whether the provider refunded, and a blind retry may be dangerous.
Concurrent refund requestsTwo authorized users refund O1 at nearly the same time; application and database coordination must ensure one logical refund workflow wins.
API compatibilityA new OrderStatus value may not be recognized by existing clients; the API evolution needs deliberate review.
Historical OrdersOlder paid Orders may lack data that new refund persistence introduces; the feature must not silently make them impossible to refund.
Inventory restorationA developer assumes refund equals cancellation and restores Inventory, which violates the requirement.

Practice Exercise 1: The Amount From History

Solutions: lesson 14.14.

Open psql on the local database of module 13 (docker compose exec postgres psql -U order_management -d order_management) and work inside BEGIN … ROLLBACK, so nothing stays behind. Insert a Product priced at 4500 cents, a PAID Order with three units of it at that price, and its SUCCEEDED payment attempt for the Order total, with a provider reference. Then raise the Product's price to 5200 cents. Write one query that shows, for that Order, the attempt's amount_cents, the total of its order_items and what the same items would cost today. Before you run it, decide which number a refund must send and which table a careless implementation would read.

Practice Exercise 2: In Scope or Not?

Support sends seven requests in the first week after launch. For each, say whether this change request covers it and name the rule from this lesson that decides:

  1. Refund €20 of a €120 Order because one item arrived damaged.
  2. Refund an UNPAID Order whose Customer says the card was charged.
  3. Refund a paid Order and put the keyboard back into stock.
  4. Let customer-742 refund their own Order from the Order page.
  5. Retry the refund of O1 after the first request timed out.
  6. Refund an Order paid in March, before refunds existed.
  7. Refund O1 again because the Customer has not seen the money yet.

Practice Exercise 3: Questions Before the Model

Two months later Product writes: "Customers should be able to cancel a paid Order within 24 hours." Write at least six questions you would ask before touching OrderStatus or Order.cancel(), each naming the decision it settles. Which of them does the refund work already answer?

Knowledge Check

Question 1

Refunds were a non-goal in v1. Why is building them now not a correction of a design mistake?

Question 2

Cancellation and refund both "undo" a purchase. Why must a refund never be implemented with order.cancel()?

Question 3

A refund request to the provider times out. Why may the backend neither mark the Order refunded nor simply send a new refund?

Question 4

Why is adding REFUNDED to OrderStatus more than a one-line Java change?

Question 5

Why are historical paid Orders part of the requirement rather than a detail for the migration?

Knowledge Check Answers

Answer 1

The v1 scope excluded refunds because nobody needed them, which kept the lifecycle at three states. Now the business has a real requirement, and evolving the system for a confirmed need is exactly what YAGNI allows.

Answer 2

Cancellation moves an UNPAID Order to CANCELLED and restores Inventory; a refund returns money for a payment that already succeeded and restores nothing. Merging them would give one method two meanings and restock goods that were never returned.

Answer 3

A timeout does not prove that the provider did not refund, so marking it refunded may be false and a new request may refund twice. The outcome is unknown and must be recorded and resolved, not guessed.

Answer 4

The status is published in OrderResponse and the OpenAPI contract, where clients may switch over exactly three values, and orders_status_valid allows only those three. A new value is an API compatibility decision and a migration, not just an enum edit.

Answer 5

Product wants Orders paid before the launch to be refundable, so the design may not depend on data those Orders never recorded. Whether they have it decides the persistence model, not just a script.

Engineering Principle

A production change request must be interpreted in the context of the system that already exists. Existing data, API clients, tests, production behavior, and external integrations are part of the requirements now.

A feature correctly excluded by YAGNI can later become a legitimate requirement. "A paid Order can be fully refunded" is that requirement; REFUNDED, a refunds table and a particular endpoint are design decisions that still need evaluation.

Summary

  • Refunds were a deliberate v1 non-goal; Product now makes them a real requirement, so this is system evolution, not the correction of a scope mistake.
  • The scope is a full refund of a successfully paid Order, initiated by an administrator, through the existing Payment Provider, at most once per Order. Partial refunds, returns, restocking, shipping, store credit, multiple providers and self-service refunds stay out.
  • A refund is not a cancellation and does not restore Inventory; the amount comes from Order and payment history, never from current Product prices.
  • A refund is an external side effect, so module 9's rules on timeouts, retries and unknown outcomes apply from the start.
  • The provider's refund semantics are a genuine unknown, so a focused investigation comes before provider-facing implementation.
  • Existing data and clients are part of the requirement: historical paid Orders must stay refundable, and a new OrderStatus value is an API compatibility decision.
  • Whether the outcome is represented as REFUNDED or as separate refund state is still open.

The next lesson takes this confirmed requirement and inspects the existing system area by area, classifying each as something that must change, may change or must not change, before we write any refund code.

Think first

Coming soon

Checkpoint questions that ask you to commit to an answer before the lesson shows it.