Wire a new external provider through Party.ExternalMappings
Every billing- or accounting-side provider stores its own customer identifier
directly on the Granit Party aggregate, in Party.ExternalMappings. There is
one canonical place to read or write the link — no per-provider mapping
aggregate, no parallel table, no bespoke EF context. This is the polyglot contract
the framework expects every new provider to honour.
Why a single registry
Section titled “Why a single registry”The first iteration of Granit had a ProviderCustomerMapping aggregate in
Granit.Payments and an OdooPartnerMapping aggregate in
Granit.Invoicing.Odoo. Both did the same thing — map a Granit identity to a
provider’s customer ID — at the cost of two storage schemas, two stores, two
migration paths, and two ways for downstream code to reach the same fact. PR
#1224 collapsed both
into Party.ExternalMappings. Future providers stay on this rail.
The contract
Section titled “The contract”A PartyExternalMapping is a (ProviderName, ExternalId) pair attached to a
Party. Constraints:
-
ProviderNameis the canonical key. Reuse the constants fromGranit.Parties.Domain.PartyExternalProviderNames— never invent a new string when one of the reserved values fits:Constant Provider StripeStripe payments MollieMollie payments OdooOdoo res.partnerSageSage customer NetSuiteNetSuite customer TenantReverse-link from a host-scoped party to the Granit tenant If your provider is not on this list, add a constant in a follow-up PR rather than coining a string ad hoc.
-
ExternalIdis the provider-natural string key for the customer in that provider —cus_abc123for Stripe, the integerres.partner.idrendered as a string for Odoo, etc. It is opaque to Granit and never reused across providers. -
Uniqueness per
(PartyId, ProviderName)is enforced by the aggregate —Party.AddExternalMapping(...)rejects duplicate provider names.
Reading
Section titled “Reading”Party? party = await partyReader.GetByIdAsync(partyId, ct);string? externalId = party?.FindExternalId(PartyExternalProviderNames.Stripe);The party may be host-scoped or tenant-scoped. When you do not know the active
scope (typical for a Wolverine handler running in some tenant context), wrap the
read in IDataFilter.Disable<IMultiTenant>() — see
Granit.Payments.Stripe.Internal.StripePaymentMethodManager for the canonical
pattern.
Writing
Section titled “Writing”After creating the customer in the provider:
party.AddExternalMapping(guidGenerator.Create(), "stripe", customer.Id);await partyWriter.UpdateAsync(party, ct);Always go through IGuidGenerator.Create() (Granit framework rule) — never
Guid.NewGuid(). The aggregate emits PartyExternalMappingAddedEto so
downstream consumers (Privacy, audit, sync orchestrators) observe the link
through the integration-event bus.
Where to put your provider implementation
Section titled “Where to put your provider implementation”A provider integration that needs the mapping — Granit.Payments.Stripe,
Granit.Invoicing.Odoo, a hypothetical Granit.Invoicing.Sage — references
Granit.Parties (full base, not just .Abstractions) because it needs both
IPartyReader and IPartyWriter. It does not create its own EF
configuration for the mapping — the Parties module already maps
PartyExternalMapping as a child entity of the Party aggregate.
A provider integration that only publishes or consumes Eto events — for
example, a webhook handler reacting to PartyExternalMappingAddedEto — should
reference only Granit.Parties.Abstractions to keep its dependency surface
minimal.
What you must not do
Section titled “What you must not do”- Do not create a new aggregate
*ProviderMapping(Stripe / Sage / NetSuite / …). UseParty.ExternalMappings. - Do not add a
(ProviderName, TenantId)row outside the party aggregate. Tenant ↔ party already lives inParty.ExternalMappingsunder the reserved provider nametenant(seeded automatically byGranit.Parties.MultiTenancy). - Do not reuse an
ExternalIdacross providers. Each provider lives in its own mapping entry, even if the IDs happen to look the same.
Related references
Section titled “Related references”- Granit.Parties module reference (TODO once the reference page lands)
Granit.Payments.Stripe.Internal.StripePaymentMethodManager— production reference for read-or-create-customer flowGranit.Invoicing.Odoo.Internal.OdooInvoiceSyncProvider— same flow for an accounting providerGranit.Parties.MultiTenancy.Handlers.SeedDefaultPartyOnTenantCreatedHandler— runtime seeder that registers the reservedtenantmapping at provisioning time