Separate domain rules from framework plumbing without overengineering

A cancellation rule becomes hard to change when it is scattered across a route handler, database hooks, UI messages, and an email adapter. A developer cannot answer whether a booking is refundable without understanding the web framework and reconstructing several side effects. Moving every function behind an interface does not automatically help. The useful separation is between the business decision and the mechanisms that obtain inputs or deliver effects.
This article uses an illustrative booking cancellation policy. A booking is refundable before its cutoff unless a nonrefundable fare was chosen. Time, booking state, and fare terms affect the answer. HTTP objects and database connections do not. The example describes an approach; it does not establish a universal refund policy or claim that a particular implementation has been tested.
Find the rule inside the plumbing
Trace the current operation from request to response. Highlight statements that answer business questions: who may cancel, whether the booking is active, whether a refund is owed, and what state follows cancellation. Separately mark transport parsing, database reads, logging, provider calls, and response serialization. This gives you a candidate boundary based on responsibility.
Do not move code solely because a handler is long. Some long handlers contain straightforward orchestration, while a short helper may hide a critical rule. Look for duplicated policy and rules that require framework objects to test. If the browser and server independently compute eligibility, decide which computation is authoritative. The UI may show a preview, but the server must apply the rule using trusted booking data at the time of the operation.
Define explicit policy inputs
Give the policy a small input containing the relevant booking state, fare terms, actor permission, and current instant. Pass time explicitly instead of calling the clock throughout the rule. The output should express a decision: allowed or denied, reason, refund eligibility, and intended next state. Keep provider-specific error codes and HTTP status values outside this result.
Explicit inputs make edge cases visible. What happens exactly at the cutoff? Can an already cancelled booking be cancelled again? Does an administrator have a special override, and is that override audited? These are requirements to resolve, not defaults for a generator to invent. Name the units and timezone semantics where time is involved. A compact input contract often exposes more ambiguity than an elaborate class hierarchy would.
Extract one decision without rewriting everything
Begin by moving the refund decision into a plain function or small domain operation. Leave the existing route responsible for authentication, trusted data loading, invoking the decision, persistence, and response mapping. Preserve behavior while making it independently understandable. Add an adapter only when a real dependency boundary requires one.
A practical artifact is a decision table. Include refundable and nonrefundable fares, active and cancelled bookings, instants before and at the cutoff, and actors with and without permission. Write the expected decision for each meaningful combination. Do not generate every mathematical combination if some states cannot occur. This table guides both the extraction and the review. It also avoids hiding policy inside a dozen methods whose interactions are harder to follow than the original handler.
Keep orchestration and consistency explicit
A pure eligibility decision does not guarantee that persistence is safe. Two concurrent cancellation requests can both read an active booking and both attempt a refund. The route or application operation must use a conditional state transition, transaction, or equivalent storage mechanism to protect the invariant. Separating the rule makes reasoning easier; it does not eliminate coordination.
Similarly, a successful booking update and failed refund provider call create a partial outcome. Model the refund state separately if delivery must be retried. Avoid calling the provider from the policy function, because that combines a deterministic decision with unreliable external work. See the outbox design when durable state and an external effect must be coordinated. Keep the intended policy result distinct from whether the effect was delivered.
Verify behavior at two levels
Test the decision table against the extracted rule using explicit time inputs and synthetic booking data. Boundary cases deserve attention: exactly at the cutoff, missing fare information, and an already completed cancellation. The assertions should state eligibility and reason rather than the number of private helper calls. These tests make policy changes easier to review.
Then test a smaller set of application paths with realistic persistence and authentication. Confirm that untrusted request values cannot replace stored fare terms, that the state transition is atomic enough for the invariant, and that public responses map denials correctly. Unit tests alone cannot prove database behavior or provider delivery. Record which boundary each test covers. A clean function test suite does not justify declaring the full cancellation workflow correct.
Choose abstractions only when needed
An interface becomes useful when there are multiple implementations, a volatile external dependency, or a test boundary that cannot be expressed more simply. It is less useful when every one-line function gets a matching interface, factory, and implementation file. Those layers add navigation cost without improving the decision's clarity.
Keep domain vocabulary close to the feature. A generic rules engine may be justified for many independently configured policies, but one cancellation rule usually does not require it. If a second policy appears, compare the real overlap before generalizing. Sharing a clock or a money type may help; sharing a generic condition evaluator may obscure meaning. The smallest successful separation is often one explicit input type and one function whose result a reviewer can understand without opening framework documentation.
Handle changing policy as a product change
After extraction, a policy update should still be reviewed as behavior, not as a harmless refactor. Changing cutoff interpretation can affect users with existing bookings. Decide whether fare terms are captured at purchase time or read from a mutable current configuration. Define how old bookings behave when new rules are introduced.
Document the source of the rule and its effective date where relevant. A versioned policy or stored terms snapshot may be appropriate, but do not add either automatically. Ask which historical facts the application must preserve. The separation gives you a place to make the choice explicit. It also makes accidental behavior changes easier to spot because the policy can be compared directly with its decision table.
Give AI a bounded extraction task
Provide the handler, associated types, and required behavior. Ask the assistant to identify business decisions and propose one small extraction that preserves them. Require it to list assumptions about time, actor permissions, and existing state. Do not ask it to redesign the entire feature when the immediate need is a comprehensible rule.
Have it derive candidate tests from the decision table and search for duplicated eligibility logic. Review each suggestion against the actual code path. If the assistant creates several new layers, ask which concrete dependency or change each layer addresses. AI is useful for mapping the separation and finding forgotten branches, but the policy must come from product requirements and the concurrency mechanism must be verified at the storage boundary.
Sources
The C4 abstraction guidance provides context for separating responsibilities at an appropriate level of detail.
OWASP authorization guidance supports keeping trusted permission checks in server-side paths. The cancellation policy and extraction sequence are original illustrative design guidance.
About the Author

Kamruz
Full Stack Developer
A passionate developer who loves creating modern web applications and sharing knowledge with the community.
More About Me