Business Calendar
Bsft.Calendar answers one question: what does "in two days" mean? By default it means 48 hours. In
a business process it usually means two working days, and a quote sent on Friday afternoon must not
be chased on Sunday morning.
The package is deliberately separate from the saga engine — working-day arithmetic is not saga domain, and other components can use it too.
Contract
public interface IBusinessCalendar
{
bool IsBusinessDay(DateOnly date);
DateTimeOffset Add(DateTimeOffset from, TimeSpan delay);
}
Add treats the delay as business time: it only accrues on days for which IsBusinessDay returns
true. Friday noon plus two business days is Tuesday noon. A non-positive delay returns the starting
instant unchanged.
Implementations
ContinuousBusinessCalendar— every day is a working day. The default, and the right answer for anything not tied to office hours.WeekdayBusinessCalendar— skips a configurable set of weekdays, Saturday and Sunday by default, with the day boundaries read in a time zone of its own.
Calendars that walk day by day, WeekdayBusinessCalendar among them, cap a single delay at ten years
and reject anything longer: the walk costs CPU proportional to the delay's length, so an unbounded
one taken from business data would burn it. ContinuousBusinessCalendar adds the delay to the
starting instant in one step and needs no such cap.
Which midnight ends the working day
A working day is a local notion, and the callers inside the framework hand the calendar a UTC
instant — ScheduleTimeout does. A WeekdayBusinessCalendar without a zone therefore delimits its
working days by UTC midnight, which is what the second constructor parameter exists to correct:
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
o.AddCalendar("berlin", new WeekdayBusinessCalendar(nonWorkingDays: null, timeZone: berlin));
Left at UTC, a one business day reminder scheduled at Monday 00:30 Berlin time — Sunday 22:30 UTC — counts as scheduled on the weekend, starts accruing at 02:00 local and fires an hour and a half late, and the skew repeats at every weekend boundary. The zone only decides where one day ends and the next begins; the instant handed in and the instant handed back are unaffected, and the result keeps the offset it arrived with. A day that a daylight-saving transition makes 23 or 25 hours long contributes exactly that much business time.
Omitting the parameter keeps UTC, so nothing changes for a calendar that does not ask for a zone.
Public holidays are not part of the framework. They differ per country, per federal state and
often per site; an application that needs them implements IBusinessCalendar itself and registers it
under a key.
Registration
services.AddBsftCalendar(o =>
{
o.AddCalendar("berlin", new WeekdayBusinessCalendar(
nonWorkingDays: null,
timeZone: TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin")));
o.AddCalendar("always-on", ContinuousBusinessCalendar.Instance);
});
Repeated calls are additive: AddSagaEngine already registers the default calendar options, and an
application calling AddBsftCalendar afterwards contributes another configuration step to that same
options instance rather than being silently ignored. Registration order does not matter.
BsftCalendarOptions is the package's primary options type but has no SectionName and
AddBsftCalendar has no IConfiguration overload: every member of it is an IBusinessCalendar
implementation chosen in code, and a configuration provider has nothing to bind to one. A host that
drives its calendars from configuration reads its own section and calls AddCalendar with the
instances it built.
Repeated calls are additive: AddBsftSaga already registers the default calendar options, and an
application calling AddBsftCalendar afterwards configures that same instance rather than being
silently ignored. Registration order does not matter.
IBusinessCalendarProvider.Get(key) resolves a calendar; null yields the default one. An unknown
key throws rather than falling back — silently computing a delay with the wrong working-day rules is
the kind of failure nobody notices for months.
Resolution per instance, not per application
The key is passed at the call site, not fixed at startup:
ctx.ScheduleTimeout("followup", TimeSpan.FromDays(2), new QuoteFollowUpDue(id), calendarKey: "berlin");
That is what allows two branches of the same company, running in the same process, to have different working weeks. See saga-timeouts.md.