- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Working Through LINQ Collections in C#
A conference's organizing team has a season's worth of sessions, speakers, and attendee registrations sitting in plain in-memory collections, and the scheduling tool needs a reliable query layer over them before the next release. You are handed the domain types (Session, Speaker, Registration) already wired up, and your job is to fill in the LINQ-based logic that powers the agenda views, room schedules, cross-season attendee comparisons, attendance reports, and a lazy tag-streaming feature the frontend team is waiting on.
Lab Info
Table of Contents
-
Challenge
Step 1: Tour the session, speaker, and registration data
Welcome to the conference query layer
The conference runs a season of conference sessions, and the organizing team's scheduling tool needs a reliable way to search, filter, join, and summarize its data. Right now that data —
Session,Speaker, andRegistration— sits in plain in-memory collections, waiting for a query layer to sit on top of it. That query layer is what you will build, one LINQ technique at a time. ## The domain typesAll of the domain types live in
src/Models.cs:Speakeris a record withIdandName.Sessionis a mutable class withId,Title,Track,Room,StartsAt,SpeakerId,Tags, andAttendeeCount. It is mutable on purpose: later you will write query results back onto it.Registrationlinks anAttendeeto a session through itsSessionId.Attendeeis a record withNameandEmail. Because it is a record, two attendees are equal only when both values match exactly, which matters when you compare rosters in Step 3.
The same file also holds the small result types your queries will produce, such as
SessionBriefingandAttendanceSummary, plusAttendeeEmailComparer.The top of
src/Program.csseeds sample data: four sessions, three speakers, and separate spring and fall registration lists, so the same person can show up in both seasons. The console program runs every query you write against that data, and you can run it at any point withdotnet run --project src/Lab.csproj. ## How they relateA
Session.SpeakerIdpoints at aSpeaker.Id— that relationship is what aJoinwill exploit in Step 4. ARegistration.SessionIdpoints at aSession.Id, and grouping registrations by that key is how attendance gets counted. Two sessions can also share tags inSession.Tags, which is what lets you flatten and stream tag data later without ever touching the speaker or registration collections. ## What's still missingThe agenda views need session lookups and projections. The room schedule needs ordering, filtering, safe single-element lookups and a morning and afternoon split, and the organizers want to know who attended both seasons. The attendance report needs joins, grouping, and a way to push computed totals back onto
Sessionobjects. The frontend team is also waiting on a lazy, page-friendly tag stream. Every one of those gaps is a task in the steps ahead — starting with the most basic speaker lookup.info> If you get stuck, you can refer to the provided solution code for each task, available in the
solutionfolder. -
Challenge
Step 2: Build the foundational speaker and session projections
Query syntax vs. method syntax
LINQ offers two equivalent ways to write the same query: query syntax, which reads like SQL (
from x in xs where ... select ...), and method syntax, which chains extension methods (xs.Where(...).Select(...)). They compile to the same calls, but method syntax composes more easily when a pipeline grows past one or two operators — which is why the rest of the query layer in this lab is written that way.src/SpeakerQueries.csstill has one lookup written the old way. ## Projecting withSelectSelecttransforms every element of a sequence into something else, one-to-one, without changing how many elements come out. It is the tool for shrinking aSessiondown to only the fields a particular view needs, or for reshaping data into a different type entirely.src/SessionQueries.csneeds exactly that: a lightweightSessionBriefingfor eachSession. ## Flattening withSelectManySelectkeeps a one-to-one shape, but aSessionholds a whole list of tags — a one-to-many relationship.SelectManyis the operator for that case: it projects each element to a sequence, then flattens all those sequences into one. Used onSession.Tags, it turns "N sessions, each with a list of tags" into one flat stream of session-and-tag pairs, which is what the tag dashboard actually consumes. -
Challenge
Step 3: Order, filter, partition, and compare session data
Ordering and filtering together
Wherenarrows a sequence down to matching elements;OrderByand its follow-upThenBycontrol the order those elements come out in. The two compose cleanly: filter first so you never sort data you are about to discard, then order, then break ties withThenBy. The track agenda insrc/SessionQueries.csneeds exactly that shape. ## Safe single-element lookupsFirst()andSingle()throw when nothing matches, which is rarely what a query layer wants to expose to a caller.FirstOrDefault()andSingleOrDefault()return a fallback — usuallynullfor reference types — instead, letting the caller decide what an absent result means. Combined with ordering,FirstOrDefault()is how you safely grab "the earliest one, if there is one" instead of crashing on an empty room. ## Set operations and custom equalityExcept,Intersect, andUniontreat sequences like mathematical sets, but they need to know when two elements count as "the same one." By default that isEquals, which for a record compares every field — too strict when twoAttendeevalues differ only by email casing. Passing anIEqualityComparer<T>overrides that comparison, soIntersectcan recognize"[email protected]"and"[email protected]"as the same attendee across two events' rosters. ## Partitioning with a predicateSometimes you need both halves of a filter: the sessions that match and the ones that don't. Calling
Wheretwice works, but it reads the source twice.ToLookupwith aboolkey reads it once and hands back both groups: everything undertrueand everything underfalse. Each group keeps the source order, and asking for a key with no elements returns an empty sequence instead of throwing. The printed day schedule needs the catalog split into a morning block and an afternoon block. -
Challenge
Step 4: Join, group, and update conference records
Joining two collections
Joincombines two sequences on a shared key, much like a SQL inner join: for every pair whose keys match, it produces one output element, and pairs with no match on either side are dropped. Sessions and speakers are linked bySpeakerId/Id, soJoinis howsrc/AgendaQueries.csturns two separate collections into one list of speaker-enriched agenda entries. ## Grouping and aggregatingGroupBypartitions a sequence into groups sharing a key, and each group is itself a sequence you can aggregate withCount(),Sum(), orAverage(). GroupingRegistrationvalues bySessionIdis how the number of attendees per session gets calculated — one group per session, one aggregate per group. ## Writing query results back onto source objectsLINQ queries are normally read-only: they describe new sequences without changing what they read from. But
Sessionis a mutable class specifically so a computed summary can be written back onto it, and that mutation always happens in an ordinaryforeach, never insideSelect— mixing side effects into a projection makes deferred execution unpredictable, which is exactly what the next step examines. -
Challenge
Step 5: Trace deferred execution and stream results lazily
Deferred vs. immediate execution
Most LINQ operators, including
WhereandSelect, build a query object without running it. The query only executes when something enumerates it — aforeach, aToList(), anAny(). Until then, it re-reads the source collection every time it is enumerated, which means changes to that source made after the query was declared, but before it runs, are still visible.ToList()andToArray()break that: they run the query immediately and freeze a snapshot. ## Writing your own iteratorBuilt-in operators like
Distinctare themselves implemented as custom iterators, and you can write the same kind: a method that returnsIEnumerable<T>and usesyield returninside its body produces values lazily, one at a time, only as the caller enumerates them. Tracking state — like which tags have already been seen — across thoseyield returncalls with a localHashSet<T>is what lets a hand-written iterator stay both lazy and duplicate-free. ## Chaining a custom iterator into a pipelineA method you write with
yield returnis a perfectly ordinaryIEnumerable<T>to everything downstream, so built-in operators likeSkipandTakechain onto it exactly as they would ontoDistinct()orWhere(). Because every link in the chain stays lazy, asking for one page of tags only pulls as many values through your iterator as that page needs — not the whole tag catalog. -
Challenge
Step 6: Watch the conference query layer at work
See the query layer work on real data
Every task is done and graded. Now watch the pieces work together on one shared data set in
src/Program.cs: four sessions, three speakers and two seasons of registrations. You'll run the program once, then make three small edits that make deferred execution, lazy streaming and custom equality visible. ### Run the whole query layerIn the Terminal tab, run:
dotnet run --project src/Lab.csprojEach section is labeled with the task it exercises. Look for these lines:
- Shape versus order: the briefings list
Advanced Deferred Execution | Core | A1 | 11:00 AMbeforeBuilding Iterators | Advanced | B2 | 10:00 AM. YourSelectfrom Task 2.2 changes each element's shape but never the order. The Core agenda just below is in time order only because Task 3.1 asks for it withOrderByandThenBy. - One pass, two blocks:
Morning (3)andAfternoon (1). A singleToLookupcall from Task 3.4 built both, andGrouping and Joining Dataat 1:00 PM is the only afternoon session. - Joined and written back:
Grace Hopperappears twice in the enriched agenda, because your Task 4.1 join produces one row per matching pair. Further down,Intro to LINQ: AttendeeCount = 2is read straight off theSessionobjects, so your Task 4.3 write-back changed the source data, not a copy. ### Watch deferred execution happen
In
src/Program.cs, find the Task 5.1 section and this line:var upcoming = SchedulePipeline.GetUpcomingSessionsQuery(sessions, cutoff);Directly below it, add:
sessions[2].StartsAt = new DateTime(2024, 9, 10, 15, 0, 0);Run the program again. The Task 5.1 list grows from two sessions to three, with
3:00 PM | Building Iteratorsin the middle. You moved that session after the query was created, and it still shows up, because your query reads the data only when theforeachenumerates it.Now add
.ToList()to the end of theGetUpcomingSessionsQuery(...)call and run once more. The list drops back to11:00 AM | Advanced Deferred Executionand1:00 PM | Grouping and Joining Data, becauseToList()ran the query immediately, before the change. Remove both edits. ### Watch your iterator streamIn
src/SchedulePipeline.cs, insideStreamDistinctTags, add this line directly above youryield return(use your own loop variable if it isn't namedtag):Console.WriteLine($" (yielding {tag})");Run the program. Task 5.2 logs all five tags, but Task 5.3 logs only
(yielding linq),(yielding csharp)and(yielding performance).Skip(1)pulled one tag andTake(2)pulled two more, then stopped asking, soiteratorsanddatawere never produced at all. That is what lazy means, and it works only because your Task 5.2 method usesyield returninstead of building a list. Remove the line. ### Watch the equality rule decide who matchesPat registered as
[email protected]in spring and[email protected]in fall. Insrc/RegistrationQueries.cs, remove theAttendeeEmailComparerargument from yourIntersectcall and run the program. Task 3.3 now reportsAttendees at both events: 0, becauseAttendeeis a record and its default equality treats the two spellings as different people.Put the comparer back and run once more.
Pat Nguyen <[email protected]>returns, in the spring spelling, becauseIntersectkeeps elements from the first sequence.You now have a tested LINQ query layer for the conference: lookups, projections, ordering, partitioning, set comparison, joins and write-backs, plus a lazy tag stream you wrote yourself.
- Shape versus order: the briefings list
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.