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.
When to add a handler
Section titled “When to add a handler”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 add | Why |
|---|---|---|
InvoiceCreatedEto / InvoiceFinalizedEto | PartyRoles.Customer | Party has an invoice → they are a customer. |
SubscriptionCreatedEto | PartyRoles.Customer | Recurring billing relationship. |
PurchaseOrderCreatedEto | PartyRoles.Supplier | We bought from this party. |
LeadCreatedEto | PartyRoles.Lead | CRM has registered a sales lead. |
EmployeeOnboardedEto | PartyRoles.Employee | HR onboarded this party as staff. |
Handler pattern
Section titled “Handler pattern”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 apublic static HandleAsyncmethod — required for Wolverine handler discovery without taking a dependency onWolverineFxitself. - Use
IDataFilter.Disable<IMultiTenant>()around the lookup so the party resolves regardless of the integration-event’s active scope. - Treat
AddRoleas idempotent. Persist only when the call returnstrueso 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.
Manual admin overrides
Section titled “Manual admin overrides”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.
Future extension
Section titled “Future extension”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.