Dynamics 365 Customer Service: Linking Multiple Products to a Single Case
Three architectures for linking more than one product to a case, what each one costs you in reporting and audit, and how to choose before you build.
Associating several products with one support case is a common requirement that the standard Case Product lookup does not solve, because that lookup holds exactly one product. There are three practical architectures. Add a native many to many relationship between Case and Product, which is the fastest to configure. Use Connections, which link a case to any record and give the link a named role. Or build a custom junction table, which is the only option that can carry quantity, serial number, fault code, and a per product outcome. Below: setup, trade offs, reporting, and audit implications for each.
One correction before the detail, because it is where most answers to this question start in the wrong place. Dynamics 365 Customer Service does not ship a Case to Product many to many relationship that you can simply switch on. What ships is a single Product lookup on the case, productid, which is the case side of the one to many relationship from Product. All three architectures below are things you add to the model, and the choice between them is a design decision with consequences for reporting, security, and audit that are difficult to reverse a year later. Solzet is a Microsoft Dynamics 365 Customer Engagement and Power Platform consultancy in Yerevan, Armenia, with MB-230 certified Customer Service consultants, and this is the same decision we work through on client environments. Table and column names below follow the platform as we configure it today, so check current Microsoft documentation before you build a plan around any specific schema name.
The short answer
If the link only has to say "this product was involved"
Use a many to many relationship between Case and Product. It is an afternoon of configuration, it needs no code, and users add products through a normal subgrid. It is the right answer far more often than the effort spent debating it suggests, and it is the cheapest thing to migrate away from later.
If the link has to carry information of its own
Use a custom junction table. Quantity, serial or asset number, fault code, per product status, per product resolution, warranty flag, who reported it and when: none of that fits on a many to many, and none of it belongs on the Connection table. If your audit or operational requirement includes any of those words, the decision is already made.
Use Connections when the link is one of many kinds of link
Connections earn their place when the case has to reference several sorts of record, products among them, with a named role on each. They are already on the case form, they cost nothing to set up, and they are genuinely flexible. They are also polymorphic, which is what makes reporting on them awkward, so choose them for the flexibility and not to avoid building a table.
Architecture 1: a native Case to Product many to many relationship
A many to many relationship between Case and Product is created in the maker portal like any other relationship. Dataverse creates and manages the intersect table behind it, so there is no table to design, no form to build, and no code. On the case form you drop a subgrid bound to the new relationship, and users add products with Add Existing Record. Removing one is a Remove on the subgrid, not a delete of anything.
What it gives you
- Fastest to configure of the three, and the only one with no ongoing maintenance surface of its own.
- The subgrid experience is the one users already know from every other associated view on the form.
- Associate and disassociate are single Web API calls, so integrations and Power Automate flows are trivial to write.
- It does not touch the Product table or the Case table, so it layers cleanly and is easy to remove if you outgrow it.
- With auditing enabled you get Associate and Disassociate entries against the case, so you can at least see that the set of products changed and when.
What it costs you
- You cannot add columns to a Dataverse managed intersect table. There is nowhere to put quantity, serial number, fault code, or a per product outcome, and that limit is absolute rather than something you can work around.
- There is no record to open, so the link has no form, no owner, no security of its own, and no business rules or plugins that fire on it.
- Rollup columns do not traverse a many to many relationship, so you cannot put a simple product count on the case without a flow or a plugin writing it.
- The audit entry tells you that a link changed, not why. If your requirement is evidence about a specific product on a specific case, an Associate entry is thin.
- In reporting it lands as a bridge table you join manually, and a bridge table can carry no measures, so every question beyond which products has to be answered somewhere else.
// Associate a product with a case through the custom N:N relationship.
// POST [Organization URI]/api/data/v9.2/incidents(<caseid>)/sol_incident_product/$ref
{
"@odata.id": "[Organization URI]/api/data/v9.2/products(<productid>)"
}
// Remove one. The intersect row is deleted for you.
// DELETE [Organization URI]/api/data/v9.2/incidents(<caseid>)
// /sol_incident_product(<productid>)/$ref
<!-- Reporting has to traverse the intersect, which carries only the two keys. -->
<fetch>
<entity name="incident">
<attribute name="ticketnumber" />
<attribute name="title" />
<link-entity name="sol_incident_product" from="incidentid" to="incidentid"
intersect="true">
<link-entity name="product" from="productid" to="productid" alias="p">
<attribute name="name" />
</link-entity>
</link-entity>
</entity>
</fetch>Architecture 2: Connections with named connection roles
Connections are the platform mechanism for relating any record to any other record with a named role on each side. The case form already carries a Connections associated view, so nothing has to be built. You define connection roles once, for example Affected Product, Root Cause Product, and Replacement Issued, and users connect products to the case with the role that describes what the product has to do with the complaint. It is the only one of the three approaches that answers the follow up question nobody asks until later, which is what happens when the case also has to reference an asset, a supplier contact, and a related case.
What it gives you
- No schema change to Case or Product beyond enabling connections, and the associated view is already on the form.
- The connection role is the semantics. Affected Product and Replacement Issued are different roles on the same mechanism, which a many to many cannot express at all.
- A connection is a real record, so it has an owner, an audit history, security, and columns you can read: description, effective start, and effective end.
- Connection roles can be limited to selected record types, which recovers some of the type safety a polymorphic lookup gives away.
- One mechanism covers products, assets, contacts, other cases, and anything else, so you are not adding a table each time a new kind of link is requested.
What it costs you
- Creating a connection creates a second, reciprocal connection record for the other direction, linked back through relatedconnectionid. Reports that count connections without filtering to one side count everything twice.
- The record2id lookup is polymorphic, so every query needs a record2objecttypecode filter or a typed link to keep contacts and accounts out of a product report.
- Custom columns on the Connection table apply to every connection in the system, not just product connections, so a Serial Number column would appear on the connection between a case and a supplier contact too.
- Nothing enforces that a product connection exists, that it exists only once, or that its role is the right one. The polymorphic lookups cannot be used in an alternate key, so duplicate prevention needs a rule or a plugin.
- The Connections view on the case mixes every connected record together unless you build filtered views per role, which is more configuration than people expect from the "no build" option.
// Connect a product to a case with a named role.
// POST [Organization URI]/api/data/v9.2/connections
{
"record1id_incident@odata.bind": "/incidents(<caseid>)",
"record1roleid@odata.bind": "/connectionroles(<caseroleid>)",
"record2id_product@odata.bind": "/products(<productid>)",
"record2roleid@odata.bind": "/connectionroles(<productroleid>)",
"description": "Reported faulty at first line triage"
}
// Dataverse creates the reciprocal connection record for you.
<!-- Read the products on one case. Filter the object type or you will get
every connected record, and read one side or you will count each link
twice. 1024 is the product object type code in a default environment;
confirm it in yours. -->
<fetch>
<entity name="connection">
<attribute name="record2id" />
<attribute name="record2roleid" />
<attribute name="effectivestart" />
<filter>
<condition attribute="record1id" operator="eq" value="<caseid>" />
<condition attribute="record2objecttypecode" operator="eq" value="1024" />
</filter>
<link-entity name="product" from="productid" to="record2id" alias="p">
<attribute name="name" />
<attribute name="productnumber" />
</link-entity>
</entity>
</fetch>Architecture 3: a custom junction table
A junction table, sometimes called a case product line, is a custom table with a required lookup to Case, a required lookup to Product, and whatever else the business actually needs to say about that pairing. It is the only architecture of the three in which the link is a first class record: it has a form, a quick create, views, business rules, plugins, an owner, security, and its own audit history. It is also the only one that can answer "which product on this case was replaced, when, under whose warranty, and by whom", which is usually what an audit requirement means when it is written down properly.
The two lookups, and what happens to them later
Case lookup required, Product lookup required. Set the Case relationship behaviour deliberately rather than leaving the default: cascade Delete so lines go with the case, cascade Assign and Share so lines follow the case owner, and set the Merge behaviour so lines move when two cases are merged. Set the Product relationship to referential with Restrict Delete, so nobody can delete a product that a closed case still points at. These four settings are the difference between a junction table that holds up and one that quietly loses rows.
The columns that justified building it
Quantity, serial or asset number, fault or symptom code, per product status, per product resolution, warranty in force, replacement issued, date reported. Only add the ones somebody will read. Each column is a thing to maintain and a thing to fill in, and a junction table with two lookups and nothing else is a many to many relationship with extra steps.
An alternate key to stop duplicates
Define an alternate key across the Case and Product lookups so the same product cannot be added to the same case twice. It costs one index, it removes an entire class of support call, and it gives integrations upsert semantics for free, which means a retried inbound message repairs the row instead of duplicating it.
A rollup column on the case
A rollup traverses a one to many relationship, so a junction table can put a product count or a total quantity directly on the case, which neither of the other two approaches can. Bear in mind rollup values are calculated asynchronously rather than on save, so use them for reporting and views, not for logic that has to be correct the instant a row is added.
Ownership and security
Decide whether the table is user or team owned or organization owned before you build the security roles, not after. User owned plus cascading Assign from the case keeps line security aligned with case security, which is what most service organizations want. Organization owned is simpler and is fine when everyone who can see a case can see everything on it. Retrofitting the other choice means rebuilding every role that touches it.
The form work that makes it usable
A quick create form so a line can be added from the subgrid without leaving the case, an editable grid where agents add several at once, and a view sorted so the open lines are at the top. Without this the junction table is technically correct and agents route around it, which is the failure mode that ends with the data living in the case description again.
<!-- One case, every product line, with the detail the other two
approaches have nowhere to put. This is a normal one to many query,
which is why the reporting story is so much simpler. -->
<fetch>
<entity name="sol_caseproduct">
<attribute name="sol_quantity" />
<attribute name="sol_serialnumber" />
<attribute name="sol_faultcode" />
<attribute name="statuscode" />
<filter>
<condition attribute="sol_caseid" operator="eq" value="<caseid>" />
</filter>
<link-entity name="product" from="productid" to="sol_productid" alias="p">
<attribute name="name" />
</link-entity>
<order attribute="createdon" descending="false" />
</entity>
</fetch>
// Upsert by alternate key, so a retried integration message repairs the
// line instead of creating a second one.
// PATCH [Organization URI]/api/data/v9.2/sol_caseproducts
// (sol_CaseId=<caseid>,sol_ProductId=<productid>)
{
"sol_quantity": 2,
"sol_faultcode": "INTERMITTENT_POWER"
}The cost of this option is not the table, it is everything around the table, and it is worth being honest about that before you commit. Building a junction entity where a relationship and a subgrid would have done the job is one of the more expensive versions of over-customizing a platform that already had an answer, which is the first item in our guide to common Dynamics 365 customization mistakes and how to fix them. The same guide covers the solution layering and plugin design traps that turn a small custom table into a component nobody can safely remove three release waves later.
The three side by side
Read this by the row that matters to you rather than by the column. In practice one or two rows decide the architecture and the rest follow.
| Question | Many to many | Connections | Junction table |
|---|---|---|---|
| Can the link carry its own data? | No. The intersect table cannot take columns. | Partly. Description and effective dates are there, and custom columns apply to every connection in the system. | Yes. Any column you need, on that pairing only. |
| Does the link have a meaning? | No. A product is either linked or it is not. | Yes. The connection role is the meaning, and one case can use several. | Yes, through a status or type column you control. |
| Is it auditable per link? | Associate and Disassociate entries on the case only. | Yes. A connection is a record with its own audit history. | Yes, with field level audit on every column on the line. |
| How does it report? | A bridge table you join manually, carrying no measures. | Awkward. Filter the object type, and read one side of the reciprocal pair. | Like any other child table, in views, charts, and Power BI. |
| Can the case roll it up? | No. Rollups do not traverse many to many. | Not reliably, because of the polymorphic lookup and the reciprocal record. | Yes. Count of lines or sum of quantity on the case. |
| Can duplicates be prevented? | Yes, the platform will not create the same association twice. | Not declaratively. The polymorphic lookups cannot form an alternate key. | Yes, with an alternate key across the two lookups. |
| What does it cost to build and keep? | An afternoon, and effectively nothing to maintain. | A day for roles and filtered views, then a shared table you no longer fully own. | A table, forms, views, and security roles, tested on every release. |
Step by step: choose it, build it, and make it survive
The first two steps are the ones that decide whether the rest is cheap. Everything after step three is the same regardless of which architecture the first two chose.
Write down what the link has to record, in one sentence
Before touching the maker portal, get the requirement stated as a sentence about the pairing rather than about the case. "This case involves these three products" needs a many to many relationship. "Two of these three were replaced under warranty and one is still under investigation" needs a junction table. "This case involves these products, this asset, and the supplier contact who handled it" needs Connections. Nearly every rebuild of this design we are asked to do traces back to this sentence never being written.
Decide whether the reporting requirement is countable
Ask what the service manager will need to see in a view, a chart, or Power BI. If the answer includes a number per case, such as how many products are affected or how many were replaced, you need something a rollup can traverse, which means the junction table. If the answer is a list on the case form and nothing more, a many to many relationship is enough. Deciding this now costs a conversation; deciding it after go live costs a migration.
Keep the standard Product lookup and define what it means
Whichever architecture you pick, keep incident.productid populated with the primary product and write down what primary means, whether that is the product the customer called about or the one that caused the fault. Entitlement scoping, routing and assignment rulesets, and SLA applicable when conditions are built over columns on the case record, so the column has to stay meaningful even once the real list lives in a subgrid.
Build it in your own solution with your own publisher
Create the relationship, the connection roles, or the table in a dedicated unmanaged solution with your own publisher and prefix, in a development environment, never directly in production. This is the step people skip because it is only one relationship, and it is how the accidental active layer that nobody can explain later gets created.
Configure the relationship behaviour and the keys
For a junction table, set cascade Delete, Assign, Share, and Merge from the case, set the Product relationship to referential with Restrict Delete, and add the alternate key across the two lookups. For a many to many, confirm the relationship name and check that both tables are where you expect. For Connections, define the roles, restrict each role to the record types it applies to, and confirm connections are enabled on both Case and Product.
Put it on the form where the agent is already looking
Add the subgrid to the case form on a tab agents open during triage, not a tab they discover during closure, and label it with the business word rather than the schema name. For a junction table add a quick create form so a line can be added without leaving the case, and an editable grid where several are added at once. Adoption of this design is a form design problem far more than a data model problem.
Handle the case lifecycle events
Decide and configure what happens on resolve, on cancel, on reactivate, and on merge. Whether lines stay open when the case is resolved, whether a merged case brings its products with it, and whether a reopened case keeps its previous product history are business questions with configuration answers, and the merge case in particular is the one that is always found in production rather than in testing.
Migrate whatever is already there
The requirement usually arrives because agents have been typing product names into the case description, filling in a second unrelated lookup, or opening one case per product. Extract what exists, map it to the new structure, load it with the alternate key so a re-run repairs rather than duplicates, and reconcile the counts. Doing this before go live is what stops the old habit surviving alongside the new design.
Test the reporting before you promote, not after
Build the actual view, chart, or Power BI page the requirement asked for, against realistic data volumes, while the design can still be changed cheaply. This is where a Connections based design reveals its double counting and where a many to many design reveals that the number the manager wanted was never available. Both are inexpensive discoveries in a sandbox and expensive ones in production.
Promote through managed solutions and document the decision
Export as managed, import through development to test to production, and record why this architecture was chosen along with what would trigger a move to another one. That note is what stops the same debate being reopened by the next consultant, and it is the artifact an auditor asks for when they want to know why case product history is stored the way it is.
The Product lookup you are tempted to abandon
Once the real list of products lives in a subgrid, the single Product lookup on the case looks redundant and agents stop filling it in. It is not redundant. Several parts of Customer Service read that column and cannot see your subgrid at all.
Entitlements are scoped by the product on the case
An entitlement can be limited to specific products, and it is the case record that is checked when an entitlement is applied. A product that appears only in a subgrid is not the product the entitlement logic reads, so leaving productid empty is how a support contract silently stops applying.
Routing and SLA conditions read columns, not subgrids
Unified routing rulesets and SLA applicable when conditions are built over columns on the case and its related records through the condition builder. A row in a many to many subgrid is not a column on the case, so a rule such as "premium products get the four hour SLA" has to read productid or a column you maintain on the case, not the list.
Views, charts, and queues are built on case columns
The queue view, the manager dashboard, and the chart that groups cases by product all group by a column on the case. With a list of products and no primary, cases either fall out of those groupings or get counted once per product, and neither answer is the one the manager wanted.
Decide what primary means and enforce it
The cheapest pattern is a business rule or a plugin that keeps productid in step with the list, for example setting it from the first line added and clearing it if that line is removed. Whatever you choose, define it once and write it down, because a primary product that means something different on each team is worse than not having one.
The SLA point is worth taking seriously, because a product driven SLA that reads the wrong column produces timers that look arbitrary rather than obviously broken, and that is a slow problem to find. If your KPI timers are already behaving in ways nobody can explain, our guide to fixing random SLA timer pauses in Dynamics 365 Customer Service works through the pause and resume status rules, business hours calendars, background flows, and plugins behind them, and none of that diagnosis is worth starting until the case columns the SLA reads are being populated reliably.
Two cases where none of the three is the answer
If you are tracking a specific unit, that is an asset, not a product
A product is a catalogue item. The particular machine at the customer site, with a serial number, an install date, and a service history, is a customer asset, and Field Service ships a customer asset table for exactly that. If your requirement includes phrases like "the unit we installed in March" or "this one has failed three times", model assets and link cases to them, and treat the product list as the catalogue level answer above it. Building serial number tracking into a product junction table works, right up until somebody asks for the history of one unit across cases.
One case per product is sometimes the correct answer
If each product has its own SLA clock, its own resolution, its own entitlement, and its own closure conversation with the customer, then those are separate cases and no amount of subgrid design will make one case behave like three. The platform has a parent and child case relationship for exactly this, including the option to inherit selected fields from the parent and to close children with the parent. Reach for it when the products are genuinely separate pieces of work, and reach for a product list when they are one piece of work that touches several items.
How Solzet runs this
We make you say the sentence before we build anything
The first session is spent turning "we need multiple products on a case" into a statement about what the link records and what the business will count. That conversation usually changes the architecture, and it always changes the column list. It is also the cheapest hour of the engagement.
We build the smallest thing that answers the requirement
If a many to many relationship covers it, that is what we deliver, and we say so rather than quoting a table with six columns nobody asked for. Over building this design is one of the customization mistakes we spend a good deal of our rescue work reversing, so we are not going to introduce it here.
We configure the lifecycle behaviour, not just the schema
Cascade behaviour on delete, assign, share, and merge, the alternate key, restrict delete on the product side, and what happens when a case is resolved or reopened. This is the part that separates a design that holds up in production from one that looks correct in a demo environment.
We build the report the requirement was really about
The view, the chart, or the Power BI page comes with the design and is tested at realistic volume before promotion, because it is the reporting requirement that decides which architecture was correct in the first place.
This is a small piece of a larger practice rather than a product. Our Dynamics 365 Customer Engagement consulting covers Sales, Customer Service, Field Service, and Customer Insights, including the configuration, custom development, data migration, and managed support that a change like this sits inside. We work remotely from Yerevan on B2B contracts, directly with mid-market clients and as a white-label subcontractor for Microsoft partners.
Frequently Asked Questions
How do I link multiple products to a single case in Dynamics 365 Customer Service?
There are three practical ways, and none of them is a switch you turn on, because the case ships with a single Product lookup rather than a many to many relationship. First, add a many to many relationship between Case and Product and put a subgrid on the case form. This is the fastest and needs no code, but the link cannot carry any data. Second, use Connections with named connection roles such as Affected Product or Replacement Issued, which is flexible and already on the case form but polymorphic and awkward to report on. Third, build a custom junction table with lookups to Case and Product plus columns such as quantity, serial number, fault code, and per product status, which is the only option that supports per product detail, rollups on the case, and a real audit trail. Choose by asking whether the link has to record anything of its own.
Is there an out of the box many to many relationship between Case and Product?
No. What ships is the Product lookup on the case, incident.productid, which is the case side of the one to many relationship from Product. A many to many relationship between Case and Product is something you create yourself in the maker portal, at which point Dataverse creates and manages the intersect table for you. That is still a native platform relationship with no code involved, so it is quick, but it is an addition to the model rather than a feature waiting to be enabled. Solutions layered on top, including Field Service, add their own product and asset related tables, so check what is already in your environment before adding another.
Should I use Connections or a custom entity to link products to a case?
Use Connections when the case has to reference several different kinds of record and the role on each link is the important part, for example an affected product, the asset it sits in, a supplier contact, and a related case. Use a custom junction table when the link between a case and a product has to carry information of its own, or when the requirement involves counting, rolling up, or auditing that link. The two costs that decide it in practice are that a connection creates a reciprocal record, so naive reports count every link twice, and that the record2id lookup is polymorphic, so every query needs an object type filter. A junction table behaves like a normal child table in views, charts, and Power BI, which is worth more than the day it takes to build.
Can I add custom fields to the Case to Product many to many relationship?
No. A Dataverse managed intersect table cannot take custom columns, and that is a hard platform limit rather than something a workaround gets around. If you need quantity, serial number, fault code, warranty status, or a per product outcome, the many to many relationship is the wrong architecture and you need a custom junction table with its own lookups to Case and Product. The good news is that migrating from a many to many to a junction table is straightforward, because the intersect rows give you exactly the pairs to create, so starting simple is a reasonable strategy rather than a mistake.
Do I still need the standard Product field on the case?
Yes, and it should stay populated with a primary product whichever architecture you choose. Entitlement scoping, unified routing and assignment rulesets, SLA applicable when conditions, queue views, and charts that group by product are all built over columns on the case record, and a row in a subgrid is not a column on the case. Define what primary means for your business, keep it in step with the list through a business rule or a light plugin, and document the definition. An empty Product lookup is how a support entitlement or an SLA quietly stops applying to cases that look fine on the form.
How does linking several products affect SLAs on the case?
The SLA is applied to the case, not to the products, so a single case has one set of SLA KPI instances no matter how many products are attached to it. If different products genuinely need different clocks, different resolutions, and separate conversations with the customer, they should be separate cases, and the parent and child case relationship exists for that. Where a product does drive the SLA, the applicable when condition has to read a column on the case, which is another reason to keep the primary Product lookup populated and meaningful.
How do I report on multiple products per case in Power BI?
A junction table behaves like any other child table, so it joins to Case and to Product as a normal fact table and supports counts, sums, and per product measures directly. A many to many relationship gives you a bridge table you join manually, which resolves the product names but carries no measures, so anything numeric has to come from elsewhere. Connections need two filters before the numbers are correct: one on record2objecttypecode so only products are counted, and one that reads a single side of the reciprocal pair, because Dataverse creates a matching connection record in the opposite direction and counting both doubles every figure. If a report was part of the requirement, build it against realistic volumes before you promote the design.
What happens to the linked products when cases are merged or deleted?
It depends on the architecture and on the relationship behaviour you configure, which is why it is worth setting deliberately rather than accepting the defaults. For a junction table, set the case relationship to cascade Delete so lines go with the case, cascade Assign and Share so they follow ownership, and configure the Merge behaviour so lines move to the surviving case. Set the Product side to referential with Restrict Delete so a product still referenced by closed case history cannot be deleted. Many to many associations do not carry across a merge, and connections are not reparented either, so if merging cases is part of your process that alone can decide the architecture.
Can Solzet design and build this in our environment?
Yes. Solzet is a Dynamics 365 Customer Engagement and Power Platform consultancy based in Yerevan, Armenia, with MB-230 certified Customer Service consultants. We run this as a small, contained piece of work: agree what the link has to record and what the business will count, build the smallest architecture that answers it in a proper solution with your publisher, configure the cascade, key, and merge behaviour, deliver the form and the report together, and migrate whatever product data is currently living in case descriptions. We deliver directly or on a B2B and white-label basis for other Microsoft partners.
Not sure which one your requirement needs?
Tell us what the link between a case and a product has to record, and what your service managers need to count. That is usually a short conversation, and it is the one that decides whether this is an afternoon of configuration or a table worth building.