Jan 01, 2025·8 min read

Time-based business rules in your domain model design

Learn how to model time-based business rules for cutoff times, grace periods, and scheduled actions without hiding logic in utility code.

Time-based business rules in your domain model design

Why time logic gets messy

Time logic rarely starts as a design choice. It usually slips in through a quick condition: if an order arrives before 5:00 PM, approve it today. Then the same idea shows up in a background job, a helper, or a controller. Before long, one rule lives in four places.

That spread is where trouble begins. One service checks whether a payment came in before a cutoff. A scheduled job closes pending requests at midnight. Another helper adds a 10-minute buffer because support asked for more flexibility. The product now depends on timing rules, but nobody can point to the place that defines them.

Small exceptions pile up fast because time is full of edge cases. Friday behaves differently from Monday. A holiday shifts a deadline. One customer gets a grace period, another does not. A retry that looked harmless in one part of the system changes what another part thinks is overdue.

The cost shows up later. A team fixes the API, then forgets the worker that runs every hour. Billing says an account is still active, but the nightly job already locked it. Support reads one policy, the product follows another, and users get mixed messages. These bugs waste time because they look random even when the real problem is simple duplication.

Teams often treat time as a technical detail, like formatting a timestamp or converting a timezone. That misses the point. If your business cares about deadlines, buffers, renewal dates, or delayed actions, time belongs in the domain itself.

When you model those rules in the domain, you stop scattering decisions across utility code. The code gets easier to read, change, and test. More importantly, the product starts behaving like one system instead of a stack of separate guesses about what the clock means.

Name the rules before you code

Teams often write date checks too early. A few if now > x lines look harmless, then the rule spreads across controllers, jobs, and helpers. A month later, nobody can answer a simple question: what did we actually promise the customer?

Write the rule as a business sentence before you touch code. "Orders placed before 5:00 PM ship today." "A customer gets 15 minutes to finish payment." "We retry a failed charge after 24 hours." If a product manager, founder, or support person can read that sentence and agree with it, you have something clear enough to model.

This also separates customer promises from technical timing. A promise is what the customer can expect. Technical timing is how your system makes that happen. "Refunds complete within 3 business days" is a promise. "A worker checks every hour" is an implementation detail. If you mix them, a small job schedule change can quietly change the product itself.

Give the concepts plain names and use them consistently: cutoff time, grace period, booking window, expiry time, retry delay. Boring names work better here. canShipToday(orderTime) tells the story better than isValidTime(). paymentExpiry is clearer than timeoutValue.

Keep each rule close to the action it controls. If checkout owns payment expiry, put that rule in the checkout or payment model. If shipping owns the daily cutoff, keep it with shipment scheduling. Do not bury time-based decisions in a shared date helper just because many parts of the app use clocks.

A quick test works well: when the rule changes, can one person find the main place to edit it in under a minute? If not, the rule still lives in scattered code, not in your model.

Model cutoff times as business rules

A cutoff is not just a time on the clock. It is a promise about what the business will do before that moment and what it will do after. If an order placed before 2:00 PM ships today, and an order placed after 2:00 PM ships tomorrow, that rule belongs in the model that owns shipping.

Raw timestamps do not explain intent. A field like cutoff_at = 2026-05-10T14:00:00 tells you when something happened, but not what that moment means. A better model stores the meaning: same-day shipping cutoff, payment hold deadline, booking confirmation deadline, or cancellation deadline.

That small change matters. It turns a timing rule into something people can read, test, and change without hunting through helper functions.

For each cutoff, define both sides of the rule. Before the cutoff, what is allowed? After the cutoff, what changes? Sometimes the answer is simple: before 5:00 PM, the system accepts same-day processing; after 5:00 PM, it queues the work for the next business day. Sometimes the answer is stricter: before the deadline, a customer can cancel online; after it, only support can step in.

If you do not model both sides, teams fill in the gap on their own. One screen says "too late," another still accepts the request, and a worker handles it differently again. The bug is not in the date math. The bug is that the product never had one clear rule.

Add grace periods without hiding intent

A grace period should solve a specific problem. Maybe card processing takes a minute, a user started checkout just before the deadline, or a batch job finishes a little late. If you cannot explain why the grace period exists, it will turn into a quiet rule that surprises users and confuses the team.

Treat it as its own rule, not as a secret change to the main deadline. The original cutoff still matters. The grace period is an extra allowance with a reason attached to it.

You also need a clear start and end point. Do not say "about 15 minutes after cutoff" and leave the rest to utility code. Say exactly when it starts and exactly when it stops. For example, an invoice is due at 5:00 PM in the account timezone, and grace runs from 5:00:00 PM to 5:15:00 PM.

Then decide what people can do during that window. A grace period usually lets someone complete an action that already started, such as retrying a payment or finishing checkout. It may let the account stay in read-only mode or keep a pending order alive while support steps in. It should not open the door to brand new actions that the cutoff was meant to block.

A common mistake is to treat grace time as a silent extension. That makes reports messy and turns rules into guesswork. If an order was late but accepted under grace, your model should store both facts: the original cutoff and the grace rule that allowed it.

A small wording change shows the difference. "Subscription renews by midnight" means one thing. "Subscription renews by midnight, with a 10-minute grace period for payment retries only" is much tighter. Users get a fair chance to recover, and your team can still explain every outcome.

Plan scheduled actions step by step

Fractional CTO for Tough Logic
Bring in senior technical help for product rules your team keeps rewriting.

Scheduled work goes wrong when teams treat it like a timer and nothing more. A delayed action should still follow the same business rule when it runs, even if hours or days have passed.

A good model separates the decision from the job runner. The domain decides what should happen later, why it should happen, and when it becomes due. The scheduler only wakes up and asks, "Is this still allowed?"

A simple flow looks like this. Start with a business event, such as "invoice not paid by 5:00 PM" or "trial ends tomorrow." Let that event create a planned action in the domain with a clear due time and the rule behind it. Save that due time as data, not as a guess inside a background script. When the job runs, load the current state and ask the rule again before doing anything. Then record the outcome: action sent, skipped, canceled, or retried, along with the reason.

That recheck matters more than people think. A customer may pay two minutes before the reminder job starts. If the job trusts an old decision, it sends the wrong email and your support team pays for it.

This is where the code stays readable. Instead of scattering date checks across cron jobs, workers, and helper functions, you keep the rule close to the object that owns it. The scheduled job becomes boring, which is exactly what you want.

A small example makes it clear. Say an account should lock 24 hours after a failed compliance check. Your domain can create a "lock account" action with a due timestamp. When the worker picks it up the next day, it checks the account again. If the user fixed the issue, the worker marks the action as canceled and records why.

That last part matters too. When someone asks, "Why did this action run?" or "Why did it not run?" your system should answer without guesswork. Store the rule, the due time, and the outcome in one place.

Decide on clocks, timezones, and calendars

Most timing bugs appear when different parts of the system disagree on "now." Pick one source of current time and pass it into the domain. That can be a clock interface or a small service, but every rule should read from the same place.

This pays off fast in tests. You can freeze time at 4:59 PM, one minute before midnight, or the start of daylight saving time and check the real behavior instead of guessing.

Local time should exist only where the business cares about local time. Store timestamps in UTC by default, then convert them when a rule needs local meaning. A billing cutoff, office hours promise, or delivery window may depend on a city or country. A cleanup task usually does not.

Write down which timezone owns each rule. "Orders placed before 5:00 PM Europe/Berlin ship today" is clear. "Orders placed before 5:00 PM ship today" is not. The vague version works only by accident.

A small example shows why this matters. Say your support team promises same-day replies for tickets opened before 4:00 PM New York time. A customer in Tokyo sends a ticket at 5:10 AM local time. The rule should still check New York time, because that timezone owns the promise.

Calendars need the same treatment. Weekends, public holidays, and business days are not small utility details. They are part of the rule. If a loan payment moves to the next business day, your model should say which calendar decides that: US banking days, UAE working days, or your company support calendar.

Keep the choices explicit. One clock decides "now" for the domain. Each rule names its timezone. UTC stores timestamps unless local meaning is required. Business days and holidays come from a defined calendar. When teams hide those choices in helpers and date math, edge cases get much harder to test.

A simple example scenario

Review Your Billing Logic
Check renewals, retries, and grace periods before they cause support issues.

A subscription app renews every month on the first day at 09:00 in the customer's billing timezone. That rule should live in the domain model, not inside random date helpers, because several parts of the product depend on it.

Take one customer on the Pro plan. They want to switch to Basic before the next renewal. The business rule says plan changes are allowed until 18:00 on the last day of the current billing cycle. On May 31 at 17:50, the app accepts the change for the next invoice. At 18:01, it should not throw a vague error. It should record that the current cycle will renew on Pro and that the lower plan will start after that.

That cutoff is not just a UI rule. Billing, support, and background jobs all need the same answer to one question: "Can this subscription change plans right now?" When the model owns that rule, every part of the system stays in sync.

Now the renewal charge runs at 09:00 on June 1 and fails. The account should not lose access right away. The business gives a 72-hour grace period so the customer has time to fix the card. During that window, the subscription is still active, but it is in a different state. The model can say both things clearly: access is allowed, and payment is overdue.

The later actions come from those same rules. At 09:00 on renewal day, the system attempts the charge. If payment fails, it sets grace_period_ends_at to June 4 at 09:00 and schedules retries for June 2 and June 3 at the same time. If all retries fail, it suspends the account when the grace period ends.

That is what good time logic looks like. One place defines the cutoff, the grace period, and the retry schedule. The rest of the app asks the model for dates and decisions instead of rebuilding the same logic over and over.

Common mistakes that cause time bugs

Most time bugs start with a simple mix-up: the app reads server time, but the business rule uses a different clock. A billing system may run on UTC while the company promises "same-day changes until 5:00 PM Chicago time." If the code asks the server for "now" and stops there, customers near the cutoff get the wrong result.

Another common mistake is hiding real rules inside helpers with names like isValidTime() or canRunNow(). Those names sound neat, but they hide the reason behind the decision. Was the order still inside the payment window? Did the warehouse buffer apply? Did support extend the deadline for one account? When the name stays vague, people reuse the method in places where it does not belong.

Scheduled jobs often make this worse. A nightly job may cancel unpaid orders or release reservations, but teams sometimes let the job skip the same checks they use in normal requests. The job sees an old timestamp and acts. The domain rule never runs. That creates two truths: one for live actions and one for background work.

The warning signs are usually obvious once you know what to look for. The code compares against server local time. A helper hides a business decision behind a generic name. A scheduled job updates status straight from timestamps. You see raw values like 15 minutes with no label. Every pause or delay gets called a grace period, even when it means something else.

Magic numbers age badly. "15 minutes" might mean a card payment window, a shipping buffer, or a cooling-off period. Those are different rules. Give them names that explain intent, such as paymentGracePeriod or pickupCutoffBuffer.

One more trap is calling every delay a grace period. Some delays exist to retry a failed task. Some exist to batch work. Some protect the customer from a harsh cutoff. If the team uses one label for all of them, they start applying the wrong behavior in the wrong place. Clear names, one business clock, and the same checks everywhere an action can happen will prevent a lot of pain.

Quick checks before release

Fix Scattered Time Rules
Get a focused architecture review for cutoffs, grace periods, and scheduled actions.

A release is not ready if the team cannot explain the time rules in plain language. Each rule should fit into one short sentence, such as "Orders placed after 5:00 PM ship the next business day" or "Customers can still cancel for 15 minutes after payment." If a new teammate needs a long tour through helper methods to understand that, the rule is buried too deep.

You also want one clear owner for each rule. A cutoff should live in one place, not partly in checkout, partly in a background job, and partly in a date helper nobody wants to touch. That single source makes rules easier to change without breaking something far away.

Before release, check a few things:

  • Test each rule just before, exactly at, and just after the boundary.
  • Check that scheduled jobs record why they acted, waited, or skipped work.
  • Read a few logs and ask whether a support person could use them in a real customer conversation.
  • Pick one rule and ask a teammate who did not build it to explain it back to you.
  • Trace each rule to one object or service that owns it.

Boundary tests catch the bugs people actually hit. "4:59 PM" often works. "5:00 PM" is where systems disagree. "5:01 PM" shows whether the rule changed state the way the business expected.

Logging matters for the same reason. When a scheduled action fires late, skips a customer, or runs twice, the team needs more than a timestamp. They need a reason: outside business hours, inside grace period, waiting for payment confirmation, holiday calendar blocked execution.

Support teams feel the quality of this work first. If they can explain to a customer why something happened, your model is probably clear. If they have to ask engineering to decode every case, the logic still leaks into scattered code.

Next steps for cleaning up old logic

Old timing rules rarely fail all at once. They usually rot in one workflow first: an approval that closes too early, a renewal that runs twice, or a reminder that fires after the user already acted. Pick that one messy path and clean it end to end.

A narrow rewrite works better than a broad cleanup. Take a single flow, trace every place where code checks the date or time, and move those checks into named rules inside the domain model. If a method says isAfterCutoff(), canStillCancel(), or scheduleRetryAt(), most product and engineering teams can read it without guessing.

Generic helpers are often the real mess. A function like addBusinessDays() or isExpired() sounds useful, but it hides intent when every team uses it a little differently. Rename helpers around the business rule they support. "Payment can settle until 5 PM local time" is better than "compare timestamps and add offset." The code may get a little longer in spots, but it becomes much easier to trust.

What to change first

  • Find one workflow where time bugs already cost support time or manual fixes.
  • Write down the rule in plain language before you touch code.
  • Replace vague date helpers with names that match product behavior.
  • Add tests for the boundary moments: one minute before cutoff, exact cutoff, grace period end, and delayed job execution.

Those tests matter more than most refactors. Time bugs hide in edge cases, not in normal days. If a delayed action should run at 9:00 AM tomorrow, test 8:59, 9:00, and 9:01. If a grace period lasts 24 hours, test the exact handoff, not just the happy path.

If the rules still feel muddy, bring product into the rewrite early. Many teams discover they do not have a code problem first. They have an unclear policy.

Sometimes a short outside review helps. Oleg Sotnikov at oleg.is works with startups and small teams on product architecture, infrastructure, and practical AI adoption, and a focused review can help turn scattered time logic into a small set of clear domain rules without forcing a full rewrite.