Skip to content

Auto-maintain Party.Roles via integration events

Party.Roles is a cheap [Flags] int column that reflects the actual activity of a party across the platform — Customer if they have invoices, Supplier if a purchase order has been issued to them, Employee if HR has onboarded them, etc.

Granit deliberately does NOT compute these flags via cross-module joins (Odoo tried that in v8/v9 and rolled back to counter columns for the same reasons it would hurt us): query cost, broken module isolation, reversed dependency direction. Instead, each downstream module pushes role flags into the central Party aggregate via its own integration-event handlers.

Add one when your module emits an event that proves a party is acting in a given role. The handler converts that “first time we observed activity” signal into an idempotent Party.AddRole(role) call.

Source event (your module)Role to addWhy
InvoiceCreatedEto / InvoiceFinalizedEtoPartyRoles.CustomerParty has an invoice → they are a customer.
SubscriptionCreatedEtoPartyRoles.CustomerRecurring billing relationship.
PurchaseOrderCreatedEtoPartyRoles.SupplierWe bought from this party.
LeadCreatedEtoPartyRoles.LeadCRM has registered a sales lead.
EmployeeOnboardedEtoPartyRoles.EmployeeHR onboarded this party as staff.

The handler is a Wolverine-discovered public class in your module’s .Wolverine package (or wherever your other event handlers live), with a public static HandleAsync method matching the Granit handler discovery rules.

using Granit.Parties;
using Granit.Parties.Domain;
using Granit.Parties.Domain.ValueObjects;
using Granit.DataFiltering;
using Granit.Domain;
using Granit.Invoicing.Events;
namespace Granit.Invoicing.Wolverine;
public class InvoiceCustomerRoleHandler
{
public static async Task HandleAsync(
InvoiceCreatedEto @event,
IPartyReader parties,
IPartyWriter writer,
IDataFilter dataFilter,
CancellationToken cancellationToken)
{
// Lookup with the multi-tenant filter disabled — this handler runs in a
// background context where the active scope may not match the party's.
Party? party;
using (dataFilter.Disable<IMultiTenant>())
{
party = await parties
.GetByIdAsync(PartyId.Create(@event.PartyId), cancellationToken)
.ConfigureAwait(false);
}
if (party is null) { return; }
// AddRole is idempotent: calling it on every event is safe.
if (party.AddRole(PartyRoles.Customer))
{
await writer.UpdateAsync(party, cancellationToken).ConfigureAwait(false);
}
}
}

The handler MUST:

  • Be public class (non-static) with a public static HandleAsync method — required for Wolverine handler discovery without taking a dependency on WolverineFx itself.
  • Use IDataFilter.Disable<IMultiTenant>() around the lookup so the party resolves regardless of the integration-event’s active scope.
  • Treat AddRole as idempotent. Persist only when the call returns true so the outbox does not accumulate no-op rows.
  • Live in a downstream module that depends on Granit.Parties — never the reverse. An architecture test enforces this dependency direction.

The auto-maintenance handler does not preclude manual admin curation. The endpoint exposed by Granit.Parties.Endpoints (POST /parties/{id}/roles) lets a host operator assign or revoke roles directly — typically for legacy data backfills or GDPR-driven party closures.

A RoleAddedAt / LastObservedAt timestamp per role is intentionally NOT part of the current contract. Add it (and a corresponding event) only if a retention or decay rule lands on the roadmap; until then, the simpler [Flags] int keeps reads to a single index lookup.