Skip to content

Parties — Central Party-Management Aggregate

Granit.Parties is the central party-management aggregate for the framework, modelled after Odoo’s res.partner. A Party represents any natural person, legal entity, or organisational unit the platform interacts with — customers, suppliers, employees, leads — and can carry several roles simultaneously via the [Flags] PartyRoles enum. It powers Invoicing, Subscriptions, Payments, Tax, and CustomerBalance today, and is designed to absorb future Procurement / HR / CRM modules without schema churn.

Before Granit.Parties, billing identity was duplicated across Invoicing, Subscriptions, Payments, and CustomerBalance. Each module carried its own subset of name / address / VAT number / external-provider identifiers, and downstream consumers had to reconcile them. Granit.Parties consolidates the party abstraction in one aggregate so:

  • Tax / billing / payment data is captured once, referenced everywhere by PartyId.
  • External provider identifiers (Stripe, Mollie, Odoo, Sage, NetSuite) live on the party rather than scattered across module-specific mapping tables.
  • A single GDPR Article 17 erasure pseudonymises the party and lets downstream modules retain the row for accounting integrity.
  • DirectoryGranit.Parties.Abstractions/ Value objects, enums, integration events (lightweight)
  • DirectoryGranit.Parties/ Aggregate root, domain events, query / export definitions
    • Granit.Parties.EntityFrameworkCore EF Core persistence — PartiesDbContext, EfPartyStore
    • Granit.Parties.Endpoints Admin REST API — CRUD, lifecycle, addresses / emails / phones, tax status, merge
    • Granit.Parties.Mergeable Merge adapter + Party-children rewriter (registers with Granit.Mergeable)
    • Granit.Parties.MultiTenancy Wolverine handler that seeds a host-scoped Party per tenant
    • Granit.Parties.Privacy GDPR Article 15/17 export + erasure (pseudonymisation)
PackageRoleDepends on
Granit.Parties.AbstractionsPartyId, Address, BillingAddress, enums, ETOsGranit
Granit.PartiesParty aggregate, IPartyReader/IPartyWriter, query + export definitionsGranit.Parties.Abstractions, Granit.QueryEngine.Abstractions, Granit.DataExchange.Abstractions
Granit.Parties.EntityFrameworkCoreIsolated DbContext + child-graph reconciliation writerGranit.Parties, Granit.Persistence.EntityFrameworkCore
Granit.Parties.EndpointsAdmin API (host + tenant scope)Granit.Parties, Granit.Authorization, Granit.Validation
Granit.Parties.MergeableMerge adapter + Party-children / Party-parent reference rewritersGranit.Parties.EntityFrameworkCore, Granit.Mergeable.EntityFrameworkCore
Granit.Parties.MultiTenancyTenant-creation seeder (creates host-scoped Party per tenant)Granit.Parties, Granit.MultiTenancy
Granit.Parties.PrivacyPrivacy export provider + Article 17 pseudonymisation handlerGranit.Parties, Granit.Privacy.BlobStorage
graph TD
    CA[Granit.Parties.Abstractions] --> G[Granit]
    C[Granit.Parties] --> CA
    CEF[Granit.Parties.EntityFrameworkCore] --> C
    CEF --> P[Granit.Persistence.EntityFrameworkCore]
    CE[Granit.Parties.Endpoints] --> C
    CE --> AUTH[Granit.Authorization]
    CE --> V[Granit.Validation]
    CMT[Granit.Parties.MultiTenancy] --> C
    CMT --> MT[Granit.MultiTenancy]
    CP[Granit.Parties.Privacy] --> C
    CP --> PV[Granit.Privacy.BlobStorage]

Party is IMultiTenant. The multi-tenant filter is always active, even in host scope:

  • TenantId == nullhost-scoped parties (the SaaS host’s tenants-as-customers, vendors, internal staff).
  • TenantId == <tenant>tenant-scoped (a tenant’s e-commerce / CRM / procurement parties).

Cross-scope reads must explicitly bypass the filter via IDataFilter.Disable<IMultiTenant>() — leaking a tenant’s parties into a host admin browser would be a privacy regression.

A single party may simultaneously hold any combination of PartyRoles:

[Flags]
public enum PartyRoles
{
None = 0,
Customer = 1 << 0, // Invoicing, Subscriptions, Payments, Tax, CustomerBalance
Supplier = 1 << 1, // future Granit.Procurement
Employee = 1 << 2, // future Granit.Hr
Lead = 1 << 3, // future Granit.Crm
}

Roles are added/removed individually via Party.AddRole(...) / Party.RemoveRole(...). Downstream modules typically push role flags via event handlers (e.g., Invoicing adds Customer on first invoice).

Polyglot identity — at most one mapping per provider, enforced by a unique (PartyId, ProviderName) index and defensively at the aggregate:

party.AddExternalMapping(id, PartyExternalProviderNames.Stripe, "cus_NffrFeUfNV2Hib");
party.AddExternalMapping(id, PartyExternalProviderNames.Odoo, "12345");
string? stripeId = party.FindExternalId(PartyExternalProviderNames.Stripe);

The reserved "tenant" provider name is used by Granit.Parties.MultiTenancy to link a host-scoped party back to its tenant.

ActiveSuspended; either may transition to terminal Archived. Archived parties are immutable. Pseudonymisation (GDPR Article 17) bypasses the mutability guard so erasure succeeds even on archived rows.

TaxStatus is an owned value object capturing whether the customer is VAT-exempt (NGO, public body), under B2B intra-EU reverse charge, or standard. Read by Granit.Tax.ITaxRateProvider when a partyId is supplied — exempt / reverse-charge customers yield a 0% rate regardless of country defaults.

Party implements IMergeable<Party> and participates in the generic Granit.Mergeable framework. A merge folds a loser Party into a survivor: loser is soft-archived with a tombstone (MergedIntoId = survivor.Id, MergedAt = now), survivor absorbs the chosen scalar fields, and every aggregate that persists a typed PartyId (Invoicing, Subscriptions, CustomerBalance, plus Party self-references) has its FK rewritten in bulk SQL inside the same TransactionScope.

Survivorship is decided per merge call, not declaratively. The admin previews the merge, sees a side-by-side diff with a default recommendation per conflicting field, picks Survivor or Loser per field, optionally writes a free-form reason, and submits. The chosen map is persisted in the audit log as ResolvedChoices.

Hard invariants that fail the merge synchronously (no override possible):

  • Same TenantId, same Kind, same DefaultCurrency.
  • Survivor Active. Loser Active or Suspended. Archived rejected.
  • Loser is not already merged (idempotent re-merge attempts return 409).

After a successful merge, the loser is hidden by the GranitFilterNames.MergeTombstone query filter from every standard listing. Bypass it explicitly when listing tombstones for audit (e.g. an admin “merged-out” tab):

using (dataFilter.Disable<IHasMergeTombstone>())
{
var tombstoned = await db.Parties
.Where(p => p.MergedIntoId != null)
.ToListAsync(cancellationToken);
}

Bulk-SQL rewriters cover persisted typed FK columns only. A PartyId may also live in JSON-serialized payloads that SQL cannot rewrite cleanly — Wolverine messages in the outbox / inbox, scheduled background jobs, queued webhook deliveries, queued notifications. Consumers that receive a PartyId must resolve it through the tombstone before processing:

PartyId resolved = await partyId.ResolveCurrentAsync(reader, dataFilter, ct);

Chain merges (A→B, then B→C) are collapsed at merge time (UPDATE parties SET MergedIntoId = C WHERE MergedIntoId = B), so a single ResolveCurrentAsync hop always reaches the final survivor — no recursion.

The audit log is intentionally not rewritten (immutable history); the query layer joins on parties.MergedIntoId to render “Party X (merged into Y on …)”.

See ADR-037 for the architecture rationale: override-at-merge-time vs MDM survivorship rules, single-Postgres TransactionScope orchestration, two-layer idempotency (HTTP + DB), tenant-wide advisory lock, and the tombstone follow-through pattern.


[DependsOn(
typeof(GranitPartiesModule),
typeof(GranitPartiesEndpointsModule),
typeof(GranitPartiesMultiTenancyModule))]
public class AppModule : GranitModule { }
builder.AddGranitPartiesEntityFrameworkCore(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
app.MapGranitParties();

MapGranitParties() exposes 19 endpoints under /parties (configurable via Parties:Endpoints:RoutePrefix). All endpoints require either Parties.Parties.Read or Parties.Parties.Manage.

MethodPathPermission
GET/partiesRead
GET/parties/{id}Read
POST/partiesManage
PATCH/parties/{id}Manage
POST/parties/{id}/{suspend|activate|archive}Manage
POST / DELETE/parties/{id}/addresses[/{addressId}]Manage
POST / DELETE/parties/{id}/emails[/{emailId}]Manage
POST / DELETE/parties/{id}/phones[/{phoneId}]Manage
POST / DELETE/parties/{id}/external-mappings[/{provider}]Manage
POST / DELETE/parties/{id}/roles[/{role}]Manage
PUT / DELETE/parties/{id}/tax-statusManage
GET/parties/{id}/merge/preview?loserId={guid}Merge
POST/parties/{id}/mergeMerge

POST /parties/{id}/merge accepts a standard Idempotency-Key header and a dryRun=true flag to preview the conflict resolution and rewrite counts without committing. Validation failures return 422; concurrency / already-merged collisions return 409.


Published via the Wolverine outbox to Granit.Parties.Abstractions:

EventWhen
PartyCreatedEtoParty aggregate created
PartyUpdatedEtoIdentity / address / contact-info changed
PartySuspendedEtoLifecycle → Suspended
PartyActivatedEtoLifecycle → Active
PartyArchivedEtoLifecycle → Archived (terminal)
PartyExternalMappingAddedEtoExternal provider id registered
PartyMergedEtoParty merged into a survivor — carries survivor id, loser id, resolved choices, rewrite counts
PartyPersonalDataPseudonymizedEtoGDPR Article 17 erasure ran