Table of Contents

Building and extending the service

A NotableDateService is built over a single immutable, already-validated NotableDateResource. The simplest construction takes just the resource; richer scenarios supply optional collaborators through NotableDateServiceOptions. This page walks the construction surface, the runtime-swap pair, code-first providers, display-name localization, and the trust-gated plugin system.

For the vocabulary used below (resource vs. document, rule vs. resolved date, nominal vs. observed) see Core concepts.

Constructing the service

The base constructor takes the loaded resource:

using Bodu.Globalization.Calendar;

NotableDateResource resource = NotableDateResourceLoader.Load(xml, CommonNotableDateResources.Resolver);
NotableDateService  service  = new NotableDateService(resource);

Resolution behaviour is carried by the resource itself - its <ResolutionPolicy> decides duplicate handling, same-day collisions, the priority direction, observed-date inclusion, and the working week. To change those, edit the document or build the resource differently; see Identity and resolution. Runtime collaborators are supplied through NotableDateServiceOptions, an object with init-only properties (there is no positional-collaborator constructor):

Property Type Purpose
Algorithms INotableDateAlgorithmRegistry? A custom algorithm registry for <Algorithm key="…"> rules. null uses the built-in keys only.
CollisionResolver INotableDateCollisionResolver? Consulted only when the resource's same-day collision policy is CollisionPolicy.Custom.
Handlers IAdjustmentHandlerRegistry? Consulted when an adjustment action is AdjustmentAction.Custom.
TriggerHandlers IAdjustmentTriggerHandlerRegistry? Consulted when an adjustment trigger is AdjustmentTrigger.Custom.
Providers IEnumerable<INotableDateProvider>? Code-first providers that contribute finished occurrences.

Set only the properties you need - unset ones keep the built-in defaults:

// Register your own INotableDateAlgorithm implementations by key (see the next section).
var algorithms = new NotableDateAlgorithmRegistry();

NotableDateResource resource = AsiaPacificCalendarData.LoadResource("AU");
NotableDateService service = new NotableDateService(
    resource,
    new NotableDateServiceOptions { Algorithms = algorithms });

Custom algorithm registry

A NotableDateAlgorithmRegistry maps string keys to INotableDateAlgorithm instances and chains fluently. The same registry instance should be handed to the loader (so the document validates) and to the service (so resolution can look the key up):

using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.Algorithms;

public sealed class PiDayAlgorithm : INotableDateAlgorithm
{
    public DateOnly? Calculate(int year) => new DateOnly(year, 3, 14);
}

var registry = new NotableDateAlgorithmRegistry()
    .Register("pi-day", new PiDayAlgorithm());

NotableDateResource resource = NotableDateResourceLoader.Load(xml, _ => null, registry);
NotableDateService  service  = new NotableDateService(
    resource, new NotableDateServiceOptions { Algorithms = registry });

Built-in keys (western-easter, orthodox-easter, qingming, vesak, losar, matariki, the Hindu-festival keys, …) need no registration. See Date calculation algorithms.

Custom collision resolver

When the resource declares <ResolutionPolicy sameDayCollisionPolicy="Custom">, the service delegates same-day reconciliation to your INotableDateCollisionResolver. It receives the day and the colliding occurrences and returns the set to keep:

using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.RangeResolution;

public sealed class HighestPriorityResolver : INotableDateCollisionResolver
{
    public IReadOnlyList<NotableDate> Resolve(DateOnly date, IReadOnlyList<NotableDate> colliding)
    {
        if (colliding.Count <= 1)
            return colliding;

        NotableDate winner = colliding.OrderByDescending(d => d.Priority).First();
        return new[] { winner };
    }
}

NotableDateService service = new NotableDateService(
    resource, new NotableDateServiceOptions { CollisionResolver = new HighestPriorityResolver() });

The built-in policies (KeepAll, HighestPriorityOnly, CategoryPriority) cover most needs and require no resolver; reach for Custom only for bespoke precedence. See Identity and resolution.

Custom adjustment handlers

Adjustment policies normally use built-in triggers and actions. When a policy declares <Trigger type="Custom" handlerKey="…"> or <Action type="Custom" handlerKey="…">, the service looks the key up in the handler registries you pass:

using Bodu.Globalization.Calendar;

var actions = new AdjustmentHandlerRegistry()
    .Register("skip-to-payday", new SkipToPaydayHandler());

var triggers = new AdjustmentTriggerHandlerRegistry()
    .Register("if-school-term", new IfSchoolTermTrigger());

NotableDateService service = new NotableDateService(
    resource, new NotableDateServiceOptions { Handlers = actions, TriggerHandlers = triggers });

An IAdjustmentHandler implements DateOnly? Adjust(AdjustmentHandlerContext); an IAdjustmentTriggerHandler implements bool ShouldAdjust(AdjustmentTriggerContext). See Observance adjustment rules for the trigger / action catalogues and the context members.

Code-first providers

When a source cannot be expressed as an authored rule - occurrences pulled from a database, an HR system, or computed by bespoke logic - implement INotableDateProvider and register it through NotableDateServiceOptions.Providers. A provider returns finished NotableDate occurrences for a requested range and territory:

using Bodu.Globalization.Calendar;

public sealed class CompanyEventsProvider : INotableDateProvider
{
    public IEnumerable<NotableDate> GetNotableDates(DateRange range, string territory)
    {
        var foundingDay = new DateOnly(range.StartDate.Year, 6, 15);
        if (range.StartDate <= foundingDay && foundingDay <= range.EndDate)
            yield return new NotableDate(
                Date:        foundingDay,
                ActualDate:  foundingDay,
                IsObserved:  false,
                Identity:    new NotableDateRuleIdentity("company-events", "company-founding-day", "default"),
                DisplayName: "Company Founding Day",
                TerritoryCode: territory,
                Category:    NotableDateCategory.Civic,
                Priority:    0,
                DurationDays: 1,
                IsNonWorkingDay: true,
                Tags:        Array.Empty<string>(),
                AdjustmentPolicyId: null,
                AdjustmentReason:   null);
    }
}

NotableDateService service = new NotableDateService(
    resource, new NotableDateServiceOptions { Providers = new[] { new CompanyEventsProvider() } });

Provider occurrences are terminal: the service intersects them with the requested range and applies any query filter, but they do not pass through adjustment policies or declarative overrides - a provider that needs an observed-date shift must compute it itself. They do take part in the final ordering and the resource's same-day collision policy alongside resource occurrences.

Swapping the rule set at runtime

A resource is immutable, so a live change means loading a new resource and swapping it in. Build the service over a MutableNotableDateResourceProvider via ReloadableNotableDateService; the reloadable service rereads the provider's Current on each query:

var provider = new MutableNotableDateResourceProvider(AsiaPacificCalendarData.LoadResource("AU"));
INotableDateService service = new ReloadableNotableDateService(provider);

// later, when the rules change - the live service picks it up atomically on the next query:
provider.Reload(AsiaPacificCalendarData.LoadResource("NZ"));

ReloadableNotableDateService accepts the same optional collaborators as NotableDateService (custom algorithm registry, collision resolver, adjustment-handler registries) after the provider argument. The pairing is what the DI companion's AddReloadableNotableDateService registers for you - see Calendar dependency injection.

Localizing display names

Resolution stays culture-agnostic: each NotableDate carries the invariant DisplayName authored in the resource. To present culture-specific names, implement INotableDateNameLocalizer and apply it to resolved occurrences with the Localize extensions on NotableDateLocalizationExtensions:

using System.Globalization;
using System.Resources;
using Bodu.Globalization.Calendar;

public sealed class ResxNameLocalizer : INotableDateNameLocalizer
{
    private readonly ResourceManager _resources;

    public ResxNameLocalizer(ResourceManager resources) =>
        _resources = resources;

    // Return null to fall back to the occurrence's existing display name.
    public string? GetDisplayName(NotableDate notableDate, CultureInfo culture) =>
        _resources.GetString(notableDate.NotableDateId, culture);
}

var localizer = new ResxNameLocalizer(
    new ResourceManager("MyApp.Resources.HolidayNames", typeof(Program).Assembly));

IReadOnlyList<NotableDate> dates = service
    .Resolve(2026, "FR")
    .Localize(localizer, CultureInfo.GetCultureInfo("fr-FR"));   // display names now French

Localize returns a copy with the localized DisplayName (or the original occurrence when the localizer returns null), so the localization step is opt-in and never mutates resolution output. A single-occurrence overload (notableDate.Localize(localizer, culture)) is also available.

Plugin system

The optional Bodu.Globalization.Calendar.Plugins package loads external assemblies that contribute custom date-calculation algorithms, behind an explicit, deny-by-default trust gate. A plugin assembly advertises itself with an assembly-level attribute; the host evaluates it against a trust policy before any plugin type is activated, then registers the plugin's algorithms into a NotableDateAlgorithmRegistry for use by <Algorithm key="…"> rules.

Authoring a plugin

A plugin implements INotableDateAlgorithmPlugin and is named by an assembly-level NotableDatePluginAttribute:

using Bodu.Globalization.Calendar.Algorithms;
using Bodu.Globalization.Calendar.Plugins;

[assembly: NotableDatePlugin(typeof(Contoso.Holidays.ContosoPlugin))]

namespace Contoso.Holidays;

public sealed class ContosoPlugin : INotableDateAlgorithmPlugin
{
    public string  Name    => "Contoso.Holidays";
    public Version Version => new(1, 0, 0);

    public IEnumerable<KeyValuePair<string, INotableDateAlgorithm>> GetAlgorithms()
    {
        yield return new("contoso-founders-day", new FoundersDayAlgorithm());
    }
}

Loading and registering

NotableDatePluginLoader evaluates trust, activates the plugin, and registers its algorithms. LoadFrom(Assembly, …) loads from an already-loaded assembly; LoadFrom(string assemblyPath, …) loads the file into a dedicated AssemblyLoadContext:

using System.Reflection;
using Bodu.Globalization.Calendar.Algorithms;
using Bodu.Globalization.Calendar.Plugins;

// Trust only assemblies whose strong-name public-key token is on the allow-list.
IPluginTrustPolicy trust = new StrongNamePluginTrustPolicy(allowedPublicKeyTokens);

Assembly assembly = Assembly.LoadFrom("Contoso.Holidays.dll");
INotableDatePlugin plugin = NotableDatePluginLoader.LoadFrom(assembly, trust);   // throws if untrusted

var registry = new NotableDateAlgorithmRegistry();
int registered = NotableDatePluginLoader.RegisterAlgorithms(plugin, registry);

// The registry now backs <Algorithm key="contoso-founders-day"> rules:
NotableDateResource resource = NotableDateResourceLoader.Load(xml, CommonNotableDateResources.Resolver, registry);
NotableDateService  service  = new NotableDateService(resource, new NotableDateServiceOptions { Algorithms = registry });

Trust policies

Trust is decided by an IPluginTrustPolicy, whose Evaluate(PluginTrustContext) returns a PluginTrustResult (PluginTrustResult.Trusted() or PluginTrustResult.Rejected(reason)). The bundled policies:

Policy Behaviour
AllowAllPluginTrustPolicy Trusts every assembly. Development / tests only.
StrongNamePluginTrustPolicy Allow-list by strong-name public-key token. Constructor takes IEnumerable<string> allowedPublicKeyTokens.
FileHashPluginTrustPolicy Allow-list by SHA-256 file hash. Constructor takes IReadOnlyDictionary<string, byte[]> keyed by assembly name.
CompositePluginTrustPolicy Combines policies with AND / short-circuit semantics: CompositePluginTrustPolicy(params IPluginTrustPolicy[]).
DelegatingPluginTrustPolicy Decides with a Func<PluginTrustContext, PluginTrustResult> delegate.

A trust policy is evaluated against a PluginTrustContext (the assembly name, path, file hash, and public-key token), so a DelegatingPluginTrustPolicy can apply any custom rule:

using Bodu.Globalization.Calendar.Plugins;

// Must pass both a hash check AND a strong-name check.
IPluginTrustPolicy policy = new CompositePluginTrustPolicy(
    new FileHashPluginTrustPolicy(allowedHashesByAssemblyName),
    new StrongNamePluginTrustPolicy(allowedPublicKeyTokens));

// A bespoke rule over the context.
IPluginTrustPolicy custom = new DelegatingPluginTrustPolicy(ctx =>
    ctx.AssemblyName.StartsWith("Contoso.", StringComparison.Ordinal)
        ? PluginTrustResult.Trusted()
        : PluginTrustResult.Rejected("assembly is not from the Contoso namespace"));
Warning

AllowAllPluginTrustPolicy trusts every assembly and is intended for development and tests only. Use a strong-name, file-hash, or composite policy in production.

Plugin exceptions

The loader signals failure with the NotableDatePluginException hierarchy:

See the Plugins package reference for the full type list.

Where to go next