Dapper Multi-Mapping — Joining Related Data into Object Graphs
Map JOIN query results to related objects in Dapper: splitOn for one-to-one, collecting one-to-many relationships manually, nested multi-mapping, and when to use multi-mapping versus separate queries.
What Multi-Mapping Solves
SQL JOIN returns flat rows. A prescription with its patient is one flat row:
prescription_id | medication | patient_id | patient_name | patient_mrn
Without multi-mapping: map everything manually — fragile, verbose.
With multi-mapping: Dapper splits the row at a specified column
and maps each part to a different C# type.
splitOn: tells Dapper where to split the columns in the row.
"Id" (default) → split at each column named "Id"
"PatientId" → split when the column named "PatientId" is reachedOne-to-One Multi-Mapping
// SQL: Prescription joined with its Prescriber (clinician)
const string sql = @"
SELECT
p.id AS Id,
p.medication AS Medication,
p.dose_amount AS DoseAmount,
p.dose_unit AS DoseUnit,
p.prescribed_at AS PrescribedAt,
-- Split here: next columns map to PrescriberDto
c.id AS Id,
c.full_name AS FullName,
c.specialty AS Specialty
FROM prescriptions p
INNER JOIN clinicians c ON c.id = p.prescriber_id
WHERE p.patient_id = @PatientId AND p.is_active = 1";
using var connection = _connectionFactory.CreateConnection();
var prescriptions = await connection.QueryAsync<PrescriptionDto, PrescriberDto, PrescriptionDto>(
sql,
(prescription, prescriber) =>
{
prescription.Prescriber = prescriber;
return prescription;
},
new { PatientId = patientId },
splitOn: "Id"); // split at each column named "Id"
// PrescriberDto maps: Id, FullName, Specialty
// PrescriptionDto maps: Id, Medication, DoseAmount, DoseUnit, PrescribedAtOne-to-Many Manual Grouping
// SQL JOIN with one-to-many returns duplicate parent rows
// Must group in C# — Dapper cannot do this automatically
const string sql = @"
SELECT
pat.id AS PatientId,
pat.mrn AS Mrn,
pat.first_name AS FirstName,
-- Split here: next columns are prescriptions
pr.id AS PrescriptionId,
pr.medication AS Medication,
pr.is_active AS IsActive
FROM patients pat
LEFT JOIN prescriptions pr ON pr.patient_id = pat.id AND pr.is_deleted = 0
WHERE pat.ward_id = @WardId AND pat.is_deleted = 0
ORDER BY pat.id";
using var connection = _connectionFactory.CreateConnection();
var patientDict = new Dictionary<Guid, PatientWithPrescriptionsDto>();
await connection.QueryAsync<PatientWithPrescriptionsDto, PrescriptionSummaryDto, PatientWithPrescriptionsDto>(
sql,
(patient, prescription) =>
{
if (!patientDict.TryGetValue(patient.PatientId, out var existing))
{
existing = patient;
existing.Prescriptions = new List<PrescriptionSummaryDto>();
patientDict.Add(patient.PatientId, existing);
}
if (prescription.PrescriptionId != Guid.Empty)
existing.Prescriptions.Add(prescription);
return existing;
},
new { WardId = wardId },
splitOn: "PrescriptionId");
var result = patientDict.Values.ToList();Three-Level Multi-Mapping
// Patient → Prescription → Medication (three types)
const string sql = @"
SELECT
pat.id AS PatientId,
pat.mrn AS Mrn,
pr.id AS PrescriptionId,
pr.dose AS Dose,
m.id AS MedicationId,
m.name AS MedicationName,
m.drug_class AS DrugClass
FROM patients pat
INNER JOIN prescriptions pr ON pr.patient_id = pat.id
INNER JOIN medications m ON m.id = pr.medication_id
WHERE pat.id = @PatientId";
var patientDict = new Dictionary<Guid, PatientDetailDto>();
await connection.QueryAsync<PatientDetailDto, PrescriptionDto, MedicationDto, PatientDetailDto>(
sql,
(patient, prescription, medication) =>
{
if (!patientDict.TryGetValue(patient.PatientId, out var existing))
{
existing = patient;
patientDict.Add(patient.PatientId, existing);
}
if (prescription != null)
{
prescription.Medication = medication;
existing.Prescriptions.Add(prescription);
}
return existing;
},
new { PatientId = patientId },
splitOn: "PrescriptionId,MedicationId"); // comma-separated splitOn for 3+ typessplitOn Column Naming
// splitOn must match a column in the result set.
// Use column aliases to make it unambiguous.
// BAD: both prescription and medication have "id" — ambiguous split
SELECT pr.id, pr.medication, m.id, m.name ...
// GOOD: distinct aliases for each Id column
SELECT pr.id AS PrescriptionId, pr.medication,
m.id AS MedicationId, m.name ...
// splitOn: "PrescriptionId,MedicationId"When to Use Multi-Mapping vs Separate Queries
Multi-mapping (JOIN approach):
✓ One-to-one relationships (prescription + prescriber)
✓ Small one-to-many (patient with under 10 prescriptions)
✓ When minimizing round trips is critical
Separate queries (two SELECT approach):
✓ One-to-many with large child collections
✓ Optional relationships (LEFT JOIN with many NULLs wastes bandwidth)
✓ When the JOIN creates a Cartesian product (multiple collections)
Multi-result sets (Dapper QueryMultiple — see next lesson):
✓ When you need separate queries in one database round trip
✓ Parent + multiple child collections in one network callProduction issue I've seen: A team wrote a multi-mapping query joining patients to prescriptions to lab results to observations — a three-way one-to-many JOIN. A patient with 20 prescriptions, 30 lab results, and 50 observations produced 20 × 30 × 50 = 30,000 rows in the result set, all of which had to be transferred and deduplicated. One patient's query returned 30,000 rows to return data for 100 total records. Switching to separate queries (patients, then prescriptions, then lab results) returned 100 total rows in 3 round trips.
Key Takeaway
Multi-mapping uses
splitOnto split JOIN rows into multiple C# objects. Use it for one-to-one joins or small one-to-many. For one-to-many, group in a dictionary manually — Dapper cannot deduplicate parent rows automatically. Use distinct column aliases to avoid ambiguoussplitOnvalues. Avoid multi-mapping for multi-collection JOINs — they create Cartesian products; use separate queries orQueryMultipleinstead.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.