基于DDD实现按请求类型和发起者分配审批人的方案问询
Awesome question—let’s walk through how to solve these DDD and state pattern hurdles without relying on external workflow tools. I’ve built similar approval systems using DDD principles, so here’s what I’d recommend:
1. Where to Put the Next Approver Logic? Request Aggregate vs State Classes
The key here is to split responsibilities cleanly, per DDD and single-responsibility principles:
- State classes: Focus solely on state transition rules (e.g., "from AwaitingApproval, what state comes next when approved?"). They shouldn’t hold business data about the request itself.
- Request aggregate root: Owns all core business data (request types, initiator level, amount increase) and handles the logic to calculate the next approver. This keeps your state classes lightweight and reusable, while keeping business rules encapsulated within the aggregate that owns the data.
Here’s how that might look in code:
class Request { public RequestType[] RequestTypes { get; } public Employee Initiator { get; } public decimal AmountIncrease { get; } public List<ApprovalHistoryEntry> ApprovalHistory { get; private set; } = new(); private IRequestState _currentState; public string CurrentAssignee { get; private set; } public Request(RequestType[] types, Employee initiator, decimal amountIncrease) { RequestTypes = types; Initiator = initiator; AmountIncrease = amountIncrease; _currentState = new AwaitingApprovalState(); // Handle initial assignment (including Rule 1) CurrentAssignee = GetInitialApprover(); } // Core logic to calculate initial approver (handles Rule 1) private string GetInitialApprover() { // Rule 1: Check if all request types skip intermediate approval var skipIntermediate = RequestTypes.All(t => !t.RequiresIntermediateApproval); if (skipIntermediate) { return GetProcessingDepartmentContact(); } // Fall back to initiator level rules (Rule 2/3) return Initiator.Level == EmployeeLevel.Level1 ? GetLevel2Manager() : GetLevel3Manager(); } // Calculate next approver based on current state and request data private string GetNextApprover() { return _currentState.Type switch { RequestStateType.AwaitingApproval => Initiator.Level == EmployeeLevel.Level1 ? GetLevel2Manager() : GetLevel3Manager(), RequestStateType.Level2Approved => GetLevel3Manager(), RequestStateType.Level3Approved => // Rule 4: Force Level4 if amount > $500, else processing dept AmountIncrease > 500 ? GetLevel4Manager() : GetProcessingDepartmentContact(), RequestStateType.Level4Approved => GetProcessingDepartmentContact(), _ => throw new InvalidOperationException("Unsupported state for approval") }; } public void Approve() { // Let the state class handle state transition var nextState = _currentState.Approve(this); // Log the approval to history ApprovalHistory.Add(new ApprovalHistoryEntry(_currentState.Type, CurrentAssignee, DateTime.UtcNow, ApprovalAction.Approved)); _currentState = nextState; // Assign next approver if we're not in a terminal state if (!_currentState.IsTerminal) { CurrentAssignee = GetNextApprover(); } } // Helper methods (GetLevel2Manager, GetProcessingDepartmentContact, etc.) } // State interface with type tracking for history interface IRequestState { RequestStateType Type { get; } IRequestState Approve(Request request); IRequestState Reject(Request request); IRequestState RejectWithChanges(Request request); bool IsTerminal { get; } } class AwaitingApprovalState : IRequestState { public RequestStateType Type => RequestStateType.AwaitingApproval; public bool IsTerminal => false; public IRequestState Approve(Request request) { // State transition logic based on initiator level return request.Initiator.Level == EmployeeLevel.Level1 ? new Level2ApprovedState() : new Level3ApprovedState(); } // Implement Reject and RejectWithChanges... } public enum RequestStateType { AwaitingApproval, Level2Approved, Level3Approved, Level4Approved, ProcessingDeptPending, Approved, Rejected }
2. Implementing "Directly Assign to Processing Department" Logic
As shown in the GetInitialApprover method above:
- Prioritize Rule 1 first: Check if all request types don’t require intermediate approval. If true, skip all manager approvals and assign directly to the processing department.
- Create a dedicated state (e.g.,
ProcessingDeptPendingState) for this scenario. When approved, this state transitions directly to a terminalApprovedStatesince no further approvals are needed.
This ensures Rule 1 takes precedence over initiator-level rules, as specified in your requirements.
3. Handling Reject With Changes: Is StatusHistory Valid?
Absolutely—tracking approval history is not just valid, it’s a core requirement for this domain. Your ApprovalHistory (I’d call it ApprovalHistoryEntry as a value object, since it’s part of the Request aggregate and has no independent lifecycle) will let you:
- Verify that the target approver is a valid previous participant in the flow
- Revert to the correct state for that approver
- Audit the entire flow for compliance
Here’s how to implement the backtracking logic:
class Request { public void RejectWithChanges(string targetAssignee, string reason) { // Find the historical entry where this user approved the request var targetEntry = ApprovalHistory.FirstOrDefault( h => h.Assignee == targetAssignee && h.Action == ApprovalAction.Approved ); if (targetEntry == null) { throw new InvalidOperationException("Cannot revert to a user who hasn't approved this request"); } // Revert to the state before this user's approval _currentState = MapStateTypeToState(targetEntry.PreviousStateType); CurrentAssignee = targetAssignee; // Log the revert action ApprovalHistory.Add(new ApprovalHistoryEntry( _currentState.Type, CurrentAssignee, DateTime.UtcNow, ApprovalAction.RejectedWithChanges, reason )); } // Helper to convert state enum back to state object private IRequestState MapStateTypeToState(RequestStateType type) { return type switch { RequestStateType.AwaitingApproval => new AwaitingApprovalState(), RequestStateType.Level2Approved => new Level2ApprovedState(), // ... map all state types _ => throw new InvalidOperationException("Unknown state type") }; } } // Value object for approval history public record ApprovalHistoryEntry( RequestStateType PreviousStateType, string Assignee, DateTime Timestamp, ApprovalAction Action, string? Reason = null ); public enum ApprovalAction { Approved, Rejected, RejectedWithChanges }
By storing the state type (not the state object) in history, you avoid serialization issues and keep your aggregate persistence-friendly.
Bonus: Keep Rules Flexible (Optional)
If your approval rules might change over time, you can extract the approver calculation logic into a domain service (e.g., ApprovalRoutingService) instead of keeping it directly in the Request aggregate. This keeps the Request focused on state management and flow execution, while the service handles the complex rule logic.
内容的提问来源于stack exchange,提问作者lostinwpf

