Separate request lifecycle from eligibility rules, balance accounting, approval authority, and recorded exceptions.
A leave request looks like a form with two buttons until the rules become interesting. An employee changes teams while a request is pending. Two requests compete for the same balance. A manager approves an exception, then somebody changes the dates. If those cases are handled through scattered conditionals, the application gradually loses a coherent answer to a basic question: what did this approval authorize?
This article was drafted with AI assistance, then fact-checked and edited by the developer behind WorkBento.
A small state machine gives the request a stable lifecycle. It does not eliminate policy code. It creates an explicit boundary around that code, so eligibility, accounting, authority, and exceptions can evolve without redefining what “approved” means in every endpoint.
Give the request a deliberately small lifecycle
Start with six states: draft, pending, approved, rejected, cancelled, and completed.
A draft is editable and has no balance reservation. Submission moves it to pending after eligibility checks, balance reservation, and approver resolution succeed. Pending requests can be approved, rejected, or cancelled. Approved requests can be cancelled or completed. Rejected, cancelled, and completed requests are terminal in this model.
The state machine should answer which commands are structurally valid. A separate policy evaluator answers whether a particular actor may execute a command against a particular request.
That distinction matters. “Approve is valid from pending” is a lifecycle rule. “This manager may approve this employee’s request for these dates” is an authorization decision. “This leave type requires a second approver” is a routing rule. Combining all three into one status field produces states such as pending_manager_then_hr, which become difficult to maintain as routing changes.
Keep approval stages in a separate approval plan. A request remains pending until that plan is satisfied. Individual decisions belong to the plan; approved is the aggregate outcome.
I would also prohibit direct edits to submitted dates or leave type in this first model. Cancel and submit a replacement, with a link between the requests. That costs an extra action, but preserves the object that was actually evaluated. If amendment becomes necessary, introduce request revisions explicitly rather than quietly mutating an approved row.
Make policy produce decisions, not side effects
Policy is more than a table of settings. Eligibility may depend on employment dates, leave category, work schedule, location, and the dates requested. Some rules fit declarative configuration; others need ordinary, tested code.
The useful separation is between evaluating a request and changing the system. An evaluator receives explicit inputs and returns a decision with reasons. It does not reserve balance, email a manager, or update the request.
A compact Python result type can make that contract concrete:
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class SubmissionDecision:
policy_version: str
eligible: bool
reason_codes: tuple[str, ...]
required_units: Decimal
approver_ids: tuple[str, ...]
@property
def allowed(self) -> bool:
return (
self.eligible
and self.required_units > Decimal("0")
and bool(self.approver_ids)
)
This example assumes every submitted request requires positive units and at least one approver. An automatic approval policy would need a different contract. Decimal avoids binary floating-point arithmetic for fractional units, but the surrounding model must still define whether those units are hours or days, and which rounding rules apply.
The result deliberately does not claim that balance is available. A policy evaluator can calculate the required amount; availability must be established against authoritative accounting data inside the submission transaction.
Persist the policy version and relevant evaluated facts with the decision. A version label alone is insufficient if its configuration can be overwritten or if the evaluator reads mutable employee records. Retain an immutable policy definition and a bounded snapshot of the inputs needed to explain the result.
Avoid turning every rule into an editable expression language. Configuration works well for thresholds, eligible categories, and approval routes. Calendar calculations and interactions between rules often deserve code with a clear interface. The objective is a visible policy boundary, not the removal of all policy from software.
Treat balances as accounting with concurrency
For this model, reserve balance at submission. That prevents multiple pending requests from independently spending the same available units. Approval keeps the reservation in place. Rejection or cancellation releases it. Completion replaces the reservation with consumption.
A useful projection is:
available = posted entitlement − consumed units − active reservations
Here, posted entitlement includes grants and adjustments, while consumption and reservations are tracked separately. The signs and entry types must be unambiguous. Do not subtract an approved request twice by treating it as both consumption and an active reservation.
Reservation at submission has a real cost: a slow approval can tie up an employee’s balance. Reserving only at approval reduces that cost, but permits several apparently valid requests to compete until somebody approves one. Either approach can work. The UI and policy must reflect the chosen semantics.
The balance check and reservation must be atomic. Reading available balance, returning to application code, and inserting a reservation later leaves a race. One practical PostgreSQL design locks a stable balance-account row, calculates availability, inserts the reservation, and updates the request to pending within one transaction. Every operation that changes that account must follow the same locking protocol.
Give submission commands idempotency keys and enforce uniqueness for the reservation associated with a request revision. A retried HTTP request must return the existing outcome rather than reserve again.
Some categories may allow negative balances or need no balance account. Those are explicit accounting policies. They should not appear as unexplained bypasses in an endpoint.
Resolve approvers, then define what can change
“Send it to the manager” leaves several engineering questions unanswered. Which manager: the one at submission, approval time, or the start of leave? What happens when that person leaves the organization? Can a delegate act? Can an employee approve their own request through a second role?
At submission, resolve an approval plan containing the required stages and the eligible approvers. Store the organizational facts used to build it. At decision time, also check the actor’s current authority. A historical assignment should not allow a deactivated account to approve a request.
This creates a useful distinction: the plan records the intended route, while current authorization determines whether someone may act now. If a team change invalidates the route, use an explicit rerouting command. Record the old plan, replacement plan, actor, and reason. Do not recompute the route invisibly each time the page loads.
For multiple stages, define whether approvals are sequential, whether any one approver can satisfy a stage, and whether rejection ends the request immediately. Keep those semantics in the approval plan rather than inferring them from how many approval rows happen to exist.
Use optimistic concurrency on the request as well as balance locking. An approval command should supply the revision it examined. If another command cancelled the request or changed its plan, the approval fails with a conflict and the actor must review the current request. A transaction can then record the decision and advance the state together.
Notifications follow the committed transition. If reliable delivery matters, write an outbox event in that transaction and deliver it asynchronously. An email failure should not leave the request half approved.
Make overrides and history part of the model
An override should identify the rule being waived, the actor authorized to waive it, and the reason. “Administrator” is too broad a substitute for those details.
For example, an exception might permit a negative balance while leaving employment eligibility and separation of duties intact. Store that exception as a scoped authorization linked to the request revision and policy decision. A broad force=True flag makes it difficult to tell which safeguards still ran.
Some constraints should remain outside the override system: valid date ranges, supported units, referential integrity, and accounting consistency. Other constraints may be configurable as non-waivable. The boundary should be explicit enough that a reviewer can understand what an exception permits.
History needs both lifecycle events and accounting entries. A request event might record its identifier and revision, command, previous and resulting states, actor, timestamp, policy version, decision reasons, and any override reference. A balance entry records the reservation, release, consumption, or adjustment with a link back to the originating command.
Write these records in the same transaction as the transition. Application logs are useful for debugging, but are a weak substitute for durable business history.
Completion deserves particular care. Decide whether it follows scheduled dates or confirmed attendance, and what happens when actual leave differs from the request. A background job should not silently consume units merely because a date passed unless that is the agreed policy. In this model, a completed request stays terminal; corrections use compensating accounting entries and linked correction records.
You do not need full event sourcing to achieve this. A current request row, an append-only event table, and explicit balance entries can provide a comprehensible audit trail. Readers get a fast current view; reviewers can reconstruct the decisions behind it.
Applying the boundary in an HR platform
These boundaries are useful whether leave approval is a standalone service or one module in a larger HR application. The crucial implementation question is whether submission, authorization, accounting, and history meet in a transaction with clearly defined inputs. A collection of configurable settings cannot supply that guarantee by itself.
My own product, WorkBento — Python HR and Payroll Platform, provides a concrete application context for these ideas. It is a self-hosted FastAPI and React platform using PostgreSQL with pgvector, MongoDB, and Meilisearch. Its modules include employees, org chart, attendance, shifts, leave approvals, payroll, and role permissions. Those areas provide the surrounding context in which leave decisions must make sense; the state machine described here is an engineering design, not a claim about WorkBento’s internal implementation.
Docker Compose deployment and full source code are included, so a builder evaluating the platform can inspect how its implementation handles those boundaries and adapt it to their requirements.















