CQRS is optional and belongs inside the feature slice when a task flow needs explicit write/read separation.
application/<bounded_context>/features/<feature>/ commands/ <command_use_case>.py queries/ <query_use_case>.pyCommands change state. Queries read projections. Read models flatten state for screens, reports, or generated contracts.
Create only the directories the feature needs. Read-only features have only
queries/; write-only features have only commands/. Empty folders, a cqrs/
wrapper, and global application/commands or application/queries are
forbidden.
Application handlers coordinate commands and repositories; domain rules remain pure. CQRS is a feature-local application detail, never an architecture layer.
Workflow Status Projection
Section titled “Workflow Status Projection”Workflow progress is a query/read-model concern. Long-running Restate handlers
write durable facts into Supabase ledgers, then backend queries expose a shared
WorkflowStatusProjection endpoint for portal polling.
Branch settlement dashboards are the same kind of query model: command-side
settlement and financial lifecycle ledgers remain service-role facts, while
BranchSettlementOperatorDashboard exposes branch-scoped totals and public
workflow status for the portal. Dashboard responses also include
settlementStatusCounts, derived from branch settlement board states, so the
portal can show waiting, confirmation, settled, reconciliation, and arrears
counts without scanning rows or reusing source STATUS_FL integers.
Branch settlement read adapters must also gate boards through
BranchProfile.branch_settlement_participation and BranchProfile.is_active;
source branch ids remain trace fields only, not query conditions such as
not in (36, 37).
BranchSettlementReconciliationExport is a query-side export dataset, not a
command. Stored artifact rendering can use the dataset without rereading
service-role ledger tables. The storage command freezes that dataset into a
sanmopia_business_report_snapshots row, records rendered metadata in
sanmopia_business_report_exports, and uploads the private
business-report-exports object. Long-running export rendering now also has a
Restate start surface:
POST /branch-settlement-reconciliation-export-workflow-starts/{branchProfileId}.
The API authorizes the branch operator through Supabase Auth and SpiceDB, builds
the reconciliation export dataset immediately, and sends that frozen dataset to
BranchSettlementReconciliationExportWorkflow. The workflow renders the same
dataset later, so a delayed worker cannot accidentally pick up changed
settlement amounts. Export workflow request and transition state is recorded in
sanmopia_business_report_exports and read through the shared
WorkflowStatusProjection kind branch_settlement_reconciliation_export.
Manual repair stays command-side: retry reuses the stored business-report
snapshot and workflow key, while cancellation marks the workflow read state as
cancelled. Both actions record last_repair_action,
last_repaired_by_user_id, and last_repaired_at on the export row so
operator repair does not become hidden state.
Application code should model starts and invocations through the neutral workflow engine port. Restate is the current engine implementation, not a domain or application vocabulary prefix.
That projection must normalize reservation booking, reservation operation follow-up, payment, financial lifecycle, privacy, and later care-delivery workflows into one display contract: request id, workflow kind, business entity id, source portal, actor user id/role, durable step, public status, blocked reason, retry/failure state, timestamps, resulting read-model ids, and current actor-stage capabilities.
Supabase workflow ledger adapters share one application-platform helper for
status normalization, JSON payload copying, and timestamp parsing. Feature
adapters should not reimplement those row rules inside booking, payment,
financial lifecycle, operation follow-up, or projection readers. Supabase row
value parsing follows the same rule: CQRS query adapters should use the shared
SupabaseRow helper for required text, optional text, booleans, integers,
dates, datetimes, and non-negative amounts instead of growing local
_required_* helpers per file.
Portal UI must render status and command availability from this read model. It must not infer behavior from Restate workflow names, invocation ids, internal keys, handler steps, or Supabase service-role ledger table shape.
Operator reservation detail editing follows the same split. Financial mutation
locks are a query-side preview produced by
OperatorFinancialMutationLockPolicy, exposed through
POST /operator-financial-mutation-lock-decisions/{reservationId}. The portal
does not send actorKind; the backend derives it from the authenticated
branch/HQ actor context, then returns field-level locks for price_version,
service_balance, payment_date, and reservation_status with
policyVersionLabel, required capabilities, source anchors, and stable reason
codes. Write commands must still re-check the same policy against a Supabase
current-state snapshot and expected revision before mutating reservation or
payment facts.
Payment product plans follow the same split inside pricing_settlement.
Supabase stores versioned rows in
sanmopia_payment_product_plan_catalog_items; a Pydantic adapter validates
each row, then derives the domain PaymentProductPlanCatalog and
PaymentProductPlanBook. The pre-registration command validates
paymentProductPlanCode through that same current catalog before any Kill Bill
handoff, while the query-side catalog exposes GET /payment-product-plans and
GET /payment-product-plans/{planCode} for UI selection. The read model carries
product family, obligation purposes, allowed payment methods, provider route,
refund/conversion policy, settlement participation, realization timing, fee
basis, settlement line templates, communication routes, effective dates, and
virtual-account expiry. UI code may filter/render this catalog, but must not
recalculate product meaning, settlement fee lines, or notification routing from
payment purpose, source payment type ids, template ids, or hardcoded amounts.
Reservation payment pre-registration is a command over an existing
backend-owned obligation, not a create-payment DTO. Its public body contains
only paymentId; authenticated identity and Idempotency-Key complete the
command envelope. The handler loads booking, method, purpose, product plan, and
amount from Supabase, resolves the actor through member profile plus SpiceDB,
checks a family grant’s exact scope/revision/effective interval/amount ceiling,
then freezes ReservationPaymentActorSnapshot. Query responses expose the
frozen obligation, fee, and actor projection. They never accept browser totals,
actor kinds, delegation revisions, or capability booleans as command facts.
Customer-visible charge/payment display is also a query-side projection, not a
frontend calculation. GET /reservation-financial-lifecycles/{reservationBookingId}/customer-visible-charge-payment-summary
combines finalized charge snapshots with Supabase payment records and returns
display lines, payable-now state, paid/refund totals, receipt refs, projection
revisions, and caller-observed total mismatch diagnostics. Browser code may pass
a previously observed total for drift detection, but must render backend
computedCustomerTotalKrw and must not rebuild totals from PRICE_TABLE, DOM
data-price, coupon rows, or mutable payment maps.
Caregiver matching policy follows the same rule. Legacy service-detail strings
and UI/admin query branches are normalized into backend-owned
CaregiverServiceProfile, CaregiverClassEligibilityPolicy,
CaregiverClassEligibilityDecision, and CaregiverMatchingPolicy contracts.
The query side exposes GET /caregiver-matching-policies/active, resolved from
Supabase active, effective_from, and effective_until policy rows. Frontend
code can display the active policy or pass its version into optimizer inputs,
but cannot decide eligible caregiver classes from Korean service labels,
provider-count strings, or hidden admin search constants. Candidate snapshots
and optimizer inputs must carry policy version evidence before assignment
commands consume them. Supabase persists those versions in
sanmopia_caregiver_matching_policies and
sanmopia_caregiver_class_eligibility_rules; stage DB lint and generated types
are part of the policy migration gate.
Service calendar holiday administration uses CQRS inside the
service_calendar/features/holiday_audit slice. Publication and branch-closure
override writes are commands with actor, reason, expected revision, idempotency
key, provider evidence, and effective date; the query side reads frozen audit
snapshots from Supabase instead of rebuilding history from current holiday rows.
POST /service-calendar-holiday-publication-decisions requires headquarters
service-calendar manage permission, while
POST /branch-service-calendar-holiday-overrides requires branch manage
permission. POST /service-calendar-holiday-change-decisions is the query-side
guard for add/modify/delete intent against active holiday policy versions; it
returns accepted creation, revision, withdrawal, idempotent replay, duplicate
revision, or required policy selection with existing policy ids. POST /service-calendar-holiday-impact-previews is the query-side preview for
publication or branch-override policy changes; it reads the latest reservation
service-calendar plan snapshots through Supabase and returns affected reservation
plan ids plus follow-up impact kinds for charge repricing, caregiver assignment
review, daily-report selection, refund requote, and settlement recalculation. UI
code must not decide duplicate-date handling, branch closure meaning, raw
add/edit/delete alerts, or affected reservation warnings from raw holiday rows.
The shared projection endpoint is protected. Backend verifies the Supabase Auth
bearer token first, reads the Supabase workflow ledger through service-role
adapters, then checks actor visibility through SpiceDB object permissions or
same-user ownership fields such as motherUserId and memberUserId. Frontend
clients must use the generated contract helper for the Authorization header.
Branch Settlement Operator Board
Section titled “Branch Settlement Operator Board”Branch settlement review is a query/read-model concern. Settlement commands own
board opening, deposit confirmation, and review transitions, while
BranchSettlementOperatorBoard flattens persisted settlement period rows and
directional line items for operator screens.
Branch settlement calculation read models must carry DDD source lines, not
source-era amount buckets. PromotionalCouponCost source parity is captured in
sanmopia_branch_promotional_coupon_cost_snapshots, mapped to
PromotionalCouponCost, and emitted as promotional_coupon_cost calculation
source lines. Deleted product coupon 5,000/10,000 source rows stay separate as
cancelled_gift_coupon_commission and must not be folded back into
promotional_coupon_cost. Voucher fees use VoucherServiceCommissionCatalog
for effective-dated rate selection and produce voucher_service_commission
line items; UI code must never rebuild that total from voucher row count.
Branch settlement delivery uses BranchSettlementDeliveryFeeCatalog and
produces delivery_fee line items; reservation quote rental delivery remains a
separate pricing read model.
The read model must compute display totals from lineItems and keep the stored
net balance visible as storedNetBalanceKrw. Operators can then see
reconciliationDeltaKrw when stored settlement data and line-derived totals do
not match. Portal code must not rebuild this by querying Supabase tables or
duplicating settlement formulas in the frontend.
Manual adjustment rows use SettlementManualAdjustmentCatalog to freeze
catalog version, unit amount, quantity, direction, actor, statement revision,
and lineage evidence. Branch-scope frozen lines materialize as
branch_adjustment line items and may be receivable or payable; frontend
PRICE * COUNT is diagnostic only.
Inter-office settlement command handlers must open branch-facing lines through
InterOfficeSettlement.branch_settlement_line_items_for(branchProfileId).
inter_office_branch_share is payable to the branch, while
inter_office_service_fee is receivable from the branch. Fee lines must carry
charged-branch evidence in the frozen payload, so source serviceOffice /
otherOffice strings and view-side RESULT_PRICE / FEE / ALL_PRICE
arithmetic cannot become command authority.
Inter-office branch acknowledgement uses
InterOfficeSettlementAcknowledgement with service-delivery or
customer-relationship side, actor, expected statement revision, dataset
revision, and projected line keys. AcknowledgeBranchSettlementDepositCommand
persists that evidence in SettlementTransitionAudit.metadata; source
fbranch-type, DANBEE_STATUS_FL, and OTHER_OFFICE_STATUS_FL are not command
authority.