Lesson 145 of 159
Written lesson · 21 min

Assessing the Impact on the Existing System

What a new Order status and a new provider operation touch.

Preview

You are viewing a free preview lesson.

In the previous lesson, Product's request became a bounded requirement: administrators can fully refund an eligible paid Order through the existing Payment Provider, and the refund becomes visible, durable state. We deliberately did not start by adding REFUNDED or a refunds table. Now we need to understand what the requirement actually changes. The goal of this lesson is:

Assess the refund requirement against the existing production system and classify each boundary as something that must change, may change, or should remain unchanged.

That is a key difference between greenfield implementation and production system evolution: every decision starts from code, data and contracts that already exist.

Start From the Existing System

Before changing anything, we recall what release 1.6.1 actually contains:

AreaWhat exists today
Order domainOrder (7.11) with markPaid() and cancel(), both allowed only from UNPAID (OrderNotPayable, OrderNotCancellable); totalCents() is derived from each OrderItem's historical unitPriceCents
Order statusOrderStatus { UNPAID, PAID, CANCELLED }, stored as TEXT with the orders_status_valid check (6.03), published as status in OrderResponse and as an OpenAPI enum (5.04, 5.10)
Payment boundaryPort PaymentService.pay(PaymentRequest) returning PaymentResult; adapter ProviderPaymentService with the HTTP client ProviderPaymentClient; failures PaymentOutcomeUnknown and PaymentProviderUnavailable (9.01–9.03)
Payment workflowPayOrderUseCase: three short phases with TransactionTemplate, the provider call outside any transaction (9.05, 9.09)
Payment statePaymentAttempt (PENDING, UNKNOWN, SUCCEEDED, DECLINED) in payment_attempts, with the partial unique index payment_attempts_one_live_per_order and the check payment_attempts_success_has_reference (9.08)
SchemaFlyway V1 to V9: products, inventory, orders, order items, the history index, the Product description, payment_attempts, the unique provider_reference index (V8, release 1.6.0) and idempotency_records (V9, release 1.6.1)
Create OrderIdempotent per Customer through the Idempotency-Key header (module 13)
SecurityCustomer ownership through findOwnedById and findOwnedForUpdate (8.03); hasRole("ADMIN") request rules for Product and Inventory administration (8.04)
ErrorsApiProblem through ApiExceptionHandler (5.07); payment problems 409 PAYMENT_IN_PROGRESS, 422 PAYMENT_DECLINED, 503 PAYMENT_PROVIDER_UNAVAILABLE (9.10)
ObservabilityECS logs with event.action and the request ID, the payment.attempts counter tagged by outcome, readiness that includes the database but not the provider, Actuator on port 8081 (module 11)
DeliveryCI with Testcontainers, images tagged by commit SHA, rolling deployments, additive migrations (module 12)

In this system, PAID and CANCELLED are terminal because v1 had no refund requirement. That was correct for v1. It is no longer automatically correct.

Payment Is Already Separate From the Order Lifecycle

One existing decision matters enormously here. Module 9 deliberately separated the Order lifecycle from the payment-attempt lifecycle: an attempt can be pending, succeed, be declined or end unknown, and none of that added PAYMENT_PENDING, PAYMENT_FAILED or PAYMENT_TIMEOUT to OrderStatus (9.06, 9.08). The same principle guides refunds. We should not automatically add REFUND_PENDING, REFUND_FAILED or REFUND_TIMEOUT either; those are states of an external operation, not business states of an Order.

Impact Classification

We classify each major area into one of three groups: must change, may change (the area must be revisited, but the outcome depends on design), or should not change. The classification keeps us from touching unrelated parts of the system merely because the feature is significant.

Order Domain: Must Be Revisited

The Order domain cannot remain untouched. Today PAID means "the payment completed, and no further status transition is supported". The new requirement says a paid Order may later have its payment refunded, which invalidates the old assumption that PAID is always terminal. So the Order lifecycle must at least be reconsidered.

Does OrderStatus Need REFUNDED?

Not necessarily. Two broad models are possible.

Option A: the refund changes OrderStatus. PAID can now become REFUNDED, a clear lifecycle state that a Customer reading the Order sees immediately. The costs are concrete in our codebase: the published enum changes in OrderResponse and the OpenAPI document, orders_status_valid needs a migration, existing clients must understand the new value, and financial processing state risks leaking into the Order lifecycle.

Option B: the Order stays PAID, and the refund is separate financial state. The Order keeps its status while a payment or refund record says "refunded". This cleanly separates the commercial Order state from financial operation history. The cost is that the Customer-facing Order representation needs another way to show the refund outcome.

Neither should be chosen casually; it is a real decision for the design lessons.

What Is Certain About the Domain

Even before choosing a representation, we know the business rules from 14.01, and some domain or application boundary must protect them. Part of eligibility clearly belongs to the Order: "is this Order PAID?" is a lifecycle question. But "has a provider refund already succeeded?" and "is there an unresolved refund attempt?" belong to the financial workflow. So eligibility will likely combine Order state with refund workflow state at the application layer, exactly as payment does today: PayOrderUseCase checks the Order's status and the absence of an unresolved PaymentAttempt under one lock.

markPaid(): Should Not Change

The requirement does not change what a successful payment means. markPaid() keeps moving UNPAID to PAID and nothing else. We must not weaken it so that some other state can become PAID again, or allow PAID → PAID as an arbitrary transition. Refund support adds a new responsibility; it does not corrupt existing payment semantics.

Cancellation: Should Not Become Refund

Cancellation still moves an UNPAID Order to CANCELLED and restores Inventory (7.12), and since 9.09 it refuses while a payment attempt is unresolved. The refund requirement does not redefine it, so cancel() on a PAID Order stays invalid. CancelOrderUseCase must not perform financial refunds: that would merge unpaid-Order cancellation with the reversal of a completed payment, which are different workflows.

The API stays stable too. POST /api/v1/orders/{orderId}/cancel keeps meaning "cancel an eligible UNPAID Order", not "cancel, or maybe refund, depending on the status", which would make it ambiguous for every client. Refund deserves its own explicit operation.

Inventory: Should Not Change

The refund workflow never calls InventoryRepository.restore(...), and the existing Inventory rules stay as they are. This boundary also protects future changes: a later returns requirement might introduce "return received, inspect the item, restock the eligible quantity" as its own workflow, and keeping refund independent now avoids false assumptions that it would have to undo.

Product, Pricing and Money: Should Not Change

Refund eligibility must not depend on the Product's current active state or price: a deactivated or repriced Product changes nothing about a historical paid Order, just as deactivation never rewrites Order history (7.03). ProductRepository should not become a collaborator of the refund workflow at all; if it does, that is a warning sign.

Two earlier decisions make this easy: OrderItem.unitPriceCents keeps Order.totalCents() historically stable, and PaymentAttempt stores the amountCents the provider was actually asked to charge (9.08). Money stays long cents in EUR. With no partial refunds, currencies or fractional allocation in scope, a full refund amount is an integer number of cents like every other amount, and no Money framework is needed.

Payment Capability: Must Change

This is the most obvious impact. The current boundary supports pay(...); the system now needs a refund operation, or another provider-neutral way to express the new financial capability. The refund workflow must not call the provider directly with HTTP details; the established direction stays use case → PaymentService port → provider adapter.

Do we extend PaymentService? That is an open design question. A sketch:

<!-- fragment -->
java
public interface PaymentService {

    PaymentResult pay(PaymentRequest request);

    RefundResult refund(RefundRequest request);
}

This could be reasonable, because both operations target the same external capability and provider, but we should first check that they share a coherent provider-neutral boundary. Nor should we create a RefundService just because the method name differs: PaymentService names the outbound port to an external capability, and any new abstraction should reflect a real external responsibility.

Provider Adapter: Must Change

Whatever boundary we choose, ProviderPaymentService and ProviderPaymentClient need refund support: the HTTP request, timeout behaviour, response mapping and error translation into the existing PaymentOutcomeUnknown and PaymentProviderUnavailable (9.03). Even the provider's authentication is still unknown since 9.02, so we do not add credentials or an API key for refunds either.

The exact implementation is blocked on provider research. We do not know enough to write trustworthy provider DTOs: whether full refunds are supported, which payment reference a refund requires, whether the provider deduplicates retries, what a confirmed rejection or a timeout means, and whether a refund ID comes back. These are not academic questions; they decide the consistency design.

PaymentAttempt: Review It, Do Not Reuse It Blindly

Payment already has durable attempt state, because it can succeed, be declined or end unknown and needs duplicate protection, provider references and reconciliation. Refund has similar failure characteristics. That does not automatically mean PaymentAttempt should be reused for refunds.

A payment attempt means "try to charge an unpaid Order"; a refund attempt means "try to return money for a previous successful payment". They differ in eligibility rules, external operation, direction of money movement, lifecycle meaning and provider references, and payment_attempts_one_live_per_order encodes payment's invariant, not a refund's. One generic record would make the model vague. A separate refund attempt could own its identity, the OrderId, a reference to the successful payment, the provider's refund reference, the amount, a state and timestamps, but those fields wait for the provider's semantics. The impact conclusion is only that a refund needs durable external-operation state.

Transactions: Reuse the Three-Phase Pattern

The refund calls a remote provider, so no database transaction may stay open across that call (9.05). The workflow therefore reuses PayOrderUseCase's three short phases (9.09): persist a durable attempt and commit, call the provider outside any transaction, then apply the result. Here the refund differs from Create Order idempotency (13.04), where the claim, the Inventory consumption and the Order commit atomically because only PostgreSQL is involved. A refund spans PostgreSQL and the provider, which cannot commit together.

Unknown Outcomes and Idempotency: Must Be Designed

A refund timeout is "outcome unknown", never "refund failed" (9.06), and while such a refund is unresolved, another administrator's request must not start a new one. So the likely invariant is:

At most one unresolved refund operation may exist for an Order at a time.

This mirrors the payment invariant, which PostgreSQL enforces with a partial unique index (9.08). How the refund's version of it is enforced comes later.

Refund idempotency must be designed as well. The Create Order incident taught us that retry safety needs a logical operation identity, but we should not simply reuse Create Order's Idempotency-Key header without considering provider semantics. There are two layers, our application's identity for the refund operation and the provider's idempotency or refund identity, and only the provider investigation can tell us how they line up.

Persistence: The orders Table May Change, New Tables Must Come

If we choose Option A, orders changes: orders_status_valid allows exactly UNPAID, PAID and CANCELLED, so a new value needs a migration that replaces the check. If refund state stays entirely separate, orders.status may stay as it is. Either path is manageable because 6.03 chose TEXT plus CHECK over a PostgreSQL enum type: adding a lifecycle value is an ordinary constraint replacement. Earlier simplicity improves changeability now.

Whatever we decide about OrderStatus, the workflow must survive an application restart, a provider timeout, a provider success followed by a local failure, and duplicate administrator requests. In-memory state cannot, so new persistence is unavoidable, arriving as the next migration, V10. But we do not decide the table yet: a CREATE TABLE refunds written now would skip what defines it, such as what a refund attempt is, whether an Order can have several, which provider identity it stores, which states exist and what must be reconciled. Impact analysis says persistence changes; design determines the schema.

Historical Data: Must Be Supported

The requirement applies to existing PAID Orders, so the design must not depend on metadata that historical Orders do not have. A refund will almost certainly refer back to the successful provider payment, so the first thing to check is whether every paid Order already stores that reference.

The reference lives in one place, payment_attempts.provider_reference: every Order reaches PAID only in the transaction that marks its attempt SUCCEEDED (9.09), the check payment_attempts_success_has_reference forbids a SUCCEEDED attempt without a reference, the partial unique index allows at most one successful attempt per Order, and since V8 no reference can belong to two attempts. Every successful payment has recorded it since 9.08, so historical Orders have it too. In principle, every paid Order has exactly one successful payment with a provider reference.

"In principle" is not evidence, so we check it with a query that counts, per status, the Orders and the Orders that have a successful attempt:

sql
SELECT o.status,
       count(*) AS orders,
       count(pa.id) AS succeeded_attempts
FROM orders o
LEFT JOIN payment_attempts pa
       ON pa.order_id = o.id AND pa.status = 'SUCCEEDED'
GROUP BY o.status
ORDER BY o.status;

On a local PostgreSQL 18 database at V9, seeded with paid, cancelled and unpaid Orders (one with a declined attempt), it returns:

text
  status   | orders | succeeded_attempts
-----------+--------+--------------------
 CANCELLED |      1 |                  0
 PAID      |      3 |                  3
 UNPAID    |      1 |                  0
(3 rows)

Every PAID Order has its successful attempt, and no other Order has one. On production, succeeded_attempts must equal orders in the PAID row; otherwise some historical Orders need recovered provider data or an operational procedure before they can be refunded. The next lesson confirms what the provider expects as the payment reference.

Administrator Command API: Must Change

Administrators need a way to start the workflow, so a new API operation is required. Its exact path belongs to the design lessons, but it should be as explicit as the existing business actions POST /api/v1/orders/{orderId}/pay and .../cancel.

This is also the first administrator operation on Orders. Every Order lookup so far is Customer-scoped (findOwnedById, findOwnedForUpdate), and 8.03 deliberately added no unscoped findById because no workflow needed one. An administrator refunding another Customer's Order needs a purposeful, administrator-only lookup, used only after the endpoint's ADMIN rule has passed, rather than a Customer query called with someone else's ID.

Customer and Support Visibility: Must Change Somehow

Product says the Customer can see that the payment was refunded. Existing Order reads therefore need to expose the refund somehow, which affects GET /api/v1/orders and GET /api/v1/orders/{orderId} depending on the response design. Support is harder: today there is no administrator endpoint for reading Orders at all (8.04 deliberately invented none). Whether Support sees refunds through the administrator command's response or through a new read operation is a design question this change must answer, not skip.

Compatibility is the other side. OrderResponse publishes order.status().name() directly (5.04), so a new enum constant is immediately a new public value that a strict client may not handle (14.01); an additive optional field may be safer for some consumers. Nor does a new requirement automatically require /api/v2. Payment itself arrived as an additive v1 capability (5.09, 9.10). If we can evolve the v1 contract compatibly, /api/v1 stays; only if the sole reasonable representation breaks existing consumers may versioning become justified, and we need evidence before deciding.

Error Responses: Must Be Extended

Refund introduces new business conflicts, likely an Order that is not refundable, a refund already completed and a refund still unresolved. We do not invent public codes during impact analysis; the conclusion is that ApiExceptionHandler and its ApiProblem bodies must cover them, as 9.10 did for payment.

The existing status choices (5.05) stay, and refund failures must fit them, in particular 409 for a business conflict, 422 for a provider refusal and 503 for an outcome that cannot be confirmed now, as payment uses them.

Security: Must Change

A new privileged endpoint needs authorization: administrators only. Following 8.04, that is a request rule in the SecurityFilterChain, hasRole("ADMIN") on the refund operation's method and path, verified by security tests. We do not rely on the frontend hiding a button, or on a handler checking a role string by hand outside the established security patterns.

Customer ownership still does not authorize a refund: owning O1 lets a Customer view it, nothing more. The Customer identity model, on the other hand, should not change. We still need no Customer entity and no CustomerRepository; the external CustomerId from the token remains enough for ownership and reads.

Observability: Must Change

Refund is a financial workflow, so operations needs to see it: refund requested, confirmed, rejected, outcome unknown and reconciled become stable event.action names, the way module 11 named the payment events. The refund operation needs a durable attempt identity in the logs, because it outlives the request, just as paymentAttemptId does today, and bounded outcomes may get a counter like payment.attempts{outcome=...} (11.05).

Health Checks and CI: Should Not Change

Should readiness fail whenever the provider's refund capability is unavailable? No: 11.03 already decided that the Payment Provider is not a readiness dependency, because a provider outage would otherwise take Product browsing and Order history down with it. A provider problem may affect refunds without stopping normal traffic, and we do not expand readiness without a deliberate operational requirement. The CI pipeline from 12.02 already runs the build, the Testcontainers integration tests and the image build; refund tests simply join it.

Test Strategy: Must Expand

Refund adds tests across several layers, each running where module 10 put that kind of test:

LayerScenarios
Domain and use caseUNPAID and CANCELLED cannot be refunded; an eligible PAID Order can start the workflow; a refunded Order cannot be refunded again
Provider boundaryAgainst an HTTP stub, never the real provider: success, rejection, timeout, connection failure, malformed response, unknown outcome, duplicates
Persistence and transactionsOn real PostgreSQL: the durable attempt, the one-unresolved-attempt invariant, local completion, rollback
ConcurrencyTwo administrators refunding one Order never cause two provider refunds; this needs real concurrency, not domain checks
RecoveryThe provider refunds and the local update fails; no second refund is issued blindly. Likely the highest-risk scenario
API and securityStatus codes and problems, 401 without a token, 403 without ADMIN

Deployment and Migration: Must Be Planned

Because this is production, the old version must run against the new schema and the new version must read old rows. Lesson 12.05 already pictured this release: a refund workflow, new database structure and a new endpoint, deployed first and activated deliberately, with additive migrations (12.04). Migrations continue at V10 and the release follows 1.6.1; the exact migration waits for the final model.

Documentation and Backlog: Must Change

The OpenAPI document, the refund API behaviour, the provider integration design and the Order lifecycle documentation change, possibly with an RFC or ADR: a new financial workflow should stay traceable. The backlog evolves too. It ended at BACKEND-128, production added BUG-201, and now a new body of work arrives, not as one enormous "Add refunds" ticket but as separate, bounded items once enough design is complete.

The Impact Summary

We can now summarize the assessment:

ClassificationAreas
Must changePayment provider capability and adapter; the refund application workflow; durable refund persistence; the administrator API and its authorization rule; Customer and Support visibility; error mapping; tests; observability; migration and deployment planning; documentation
Must be revisited (may change)The Order lifecycle and OrderStatus; the orders table; the existing PaymentAttempt model; API compatibility and versioning; historical provider payment references; the shape of the PaymentService boundary; metrics
Should not changeProduct and current Product prices; Inventory quantities; unpaid cancellation; markPaid(); Create Order idempotency; Customer ownership and identity; money in long cents; handler → use case → repository; PostgreSQL as the local source of truth; provider isolation behind the port; health checks; CI

A Preliminary Dependency Map

Conceptually, the refund request flows through the same layers as payment, and the provider's result flows back into durable state that the Order reads expose:

Drawing diagram…

The exact classes and transactions are not designed yet.

What Existing Boundaries Give Us

Early in the course we chose to optimize for changeability rather than design every future feature, and refunds now test that decision. Historical unitPriceCents keeps the refund independent of current prices, the PaymentService port lets us extend the financial capability without putting provider code into Order, and because payment failures never became Order statuses, refund processing need not turn the Order lifecycle into provider telemetry. The boundaries let us add refunds without rewriting Product, Inventory, Order creation, the security model or the architecture.

The biggest unanswered question remains the provider contract (the ten questions from 14.01). Without it, the refund attempt's states, the idempotency strategy, the reference fields, reconciliation and the retry rules would all be guesses.

Practice Exercise 1: Where Does a New Status Go?

Solutions: lesson 14.14.

In the module 13 project, list the files a new OrderStatus value would reach with grep -rlE "OrderStatus|orders_status_valid" src. Sort the main-code hits into code that decides behaviour, code that stores or maps the value, and the schema, and say which would change under Option A. Then find where the status enters an API response: why does that grep miss it, and what can no search of this repository find?

Practice Exercise 2: Refunds in payment_attempts?

A colleague suggests storing a refund as one more row in payment_attempts. Try it on the local database inside BEGIN … ROLLBACK, after \set ON_ERROR_ROLLBACK on, which makes psql set a savepoint before each statement so one error does not abort the rest. Insert a PAID Order with its SUCCEEDED attempt, then a PENDING row for the same Order as the "refund", then one with the status REFUND_PENDING. Predict which constraint answers each insert and what V7 would have to give up to accept them.

Practice Exercise 3: Classify the Pull Request

A first refund pull request contains five changes. Classify each as must change, may change or should not change, and name the existing decision it touches:

  1. RefundOrderUseCase injects ProductRepository to read the current price.
  2. CancelOrderUseCase calls the provider's refund when the Order is PAID.
  3. Readiness gains a check that calls the provider's refund endpoint.
  4. PaymentService gains a refund(...) method.
  5. markPaid() also accepts PAID, "to make retries easier".

Knowledge Check

Question 1

Why should refund processing states stay out of OrderStatus?

Question 2

Why can the refund workflow not lock the Order with findOwnedForUpdate?

Question 3

Why does the lesson count successful payment attempts per status instead of trusting the constraints?

Question 4

Why does the provider still not become a readiness dependency?

Knowledge Check Answers

Answer 1

They describe one external operation, which can time out and recover while the Order's business meaning stays the same. Payment kept them in PaymentAttempt for that reason, and so must refunds.

Answer 2

It is scoped to the Customer who owns the Order, and an administrator refunds other Customers' Orders. Passing someone else's CustomerId would fake ownership; the workflow needs a deliberate administrator lookup behind the ADMIN rule.

Answer 3

The constraints guarantee a reference for every SUCCEEDED attempt, not an attempt for every PAID Order: a manual data fix could have made an Order PAID without one. Only the data shows whether historical Orders are refundable.

Answer 4

A provider outage would mark every instance unready and take Product browsing and Order history down with it. A failed refund affects only refunds, which handle the failure themselves.

Engineering Principle

Impact analysis asks what the new requirement touches before asking how to implement it. A production change should alter only the responsibilities whose existing assumptions are no longer sufficient.

Knowing what must not change is part of safe system evolution: existing boundaries are assets that reduce the blast radius of a change.

Summary

  • We assessed the refund requirement against what modules 1–13 actually built and classified each area as must change, may change or should not change.
  • PAID was terminal only under the v1 scope, so the Order lifecycle must be revisited; whether REFUNDED enters OrderStatus is still open, and refund processing states do not become Order statuses.
  • markPaid(), cancellation, Inventory, Product, historical pricing, money in cents, Customer identity, health checks and CI stay as they are.
  • PaymentService and its adapter must gain refunds; refunds likely need their own durable attempt state, and payment's three-phase TransactionTemplate pattern applies again.
  • Timeouts are unknown outcomes, at most one refund may be unresolved per Order, and refund idempotency is designed together with the provider's capabilities.
  • New persistence starts at V10; historical paid Orders stay refundable through payment_attempts.provider_reference, which we verify rather than assume.
  • An administrator command, Customer and Support visibility, error mapping, the ADMIN rule, observability, tests, rollout planning, documentation and the backlog must all change, without an automatic /api/v2.

The next lesson resolves the external unknowns before we design anything: how the provider accepts a refund, which payment reference it needs, whether it deduplicates retries, whether a timeout leaves the outcome unknown and whether a refund can be looked up later.

Think first

Coming soon

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