- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
When Search Rules Keep Multiplying in C#
TalentMatch is an internal recruiting tool that matches candidates to open roles. Its search endpoint filters candidates by minimum years of experience and required skill, but the same 'minimum experience' check is re-implemented as a private Func inside a separate validation service, and again as an inline if-statement inside a CSV importer. Every time a rule changes, someone forgets one of the three places it lives, and mismatched results reach recruiters. You will replace the duplicated logic with a small family of immutable specification classes shared by the repository's LINQ query, the validation service, and the importer, then extend the feature with optional filters, paging, related work-history loading, and DTO projection.
Lab Info
Table of Contents
-
Challenge
Step 1: Orientation to TalentMatch's duplicated search rules
TalentMatch's candidate search endpoint filters on minimum years of experience and a required skill. That filter lives in
CandidateRepository.SearchAsyncas a LINQWhereclause. Open the file and you will find a second, independent copy of the same experience check insideCandidateValidationService.IsEligible-- a privateFunc<Candidate,bool>that comparesYearsExperienceagainst a hardcoded number. A third copy sits insideCandidateCsvImporter.ImportRowas a plainifstatement guarding which imported rows get kept. The three copies have already drifted: the repository checks>= 3, the validator checks>= 2. Nobody edited the validator on purpose -- whoever last raised the threshold only found two of the three places it lives. Recruiters now see candidates pass validation who would never appear in a search, and CSV imports admit people the search endpoint would reject. Every new rule (a location filter, a remote-eligibility flag) is one more place this can happen. The fix is the specification pattern: wrap each eligibility rule in a small, immutable class that exposes the rule as anExpression<Func<Candidate,bool>>. An expression tree, unlike a compiledFunc, can be handed to EF Core so filtering happens in the database, or compiled and invoked in memory for a plain object with no database behind it at all. One class, two execution paths, zero drift. Over the next four steps you will give the minimum-experience and required-skill rules a single home, push that home into the repository's EF Core query, delete the two duplicates, compose optional filters with an AND combinator, add paging before the query is materialized, and finally reuse the exact same composed specification to validate a referral candidate before it is ever saved.info> If you get stuck, you can refer to the provided solution code for each task, available in the
solutionfolder. -
Challenge
Step 2: Build a single reusable candidate specification
src/Specifications.csalready declares the shape every rule will share:public interface ISpecification<T> { Expression<Func<T, bool>> ToExpression(); bool IsSatisfiedBy(T entity); }IsSatisfiedByis provided on each concrete class asToExpression().Compile()(entity)-- once a class can produce its expression tree, the in-memory check comes for free. The stub classes currently returnentity => true, which compiles but makes every candidate eligible. Both concrete specifications will implement the interface the same way: a private method builds the real expression, and a public wrapper just returns it. Follow the same shape for the skill rule. -
Challenge
Step 3: Move filtering into the repository and retire the predicate wrapper
With both specifications built,
SearchAsync,IsEligible, andImportRowcan all be pointed at the same class instead of their own private copy of the rule. EF Core's LINQ provider accepts anExpression<Func<T,bool>>inWheredirectly -- it inspects the tree and translates it, which is exactly why the rule had to be an expression and not a plain delegate. The validator never talks to a database -- it checks a candidate object that may not even be persisted yet. That is whereIsSatisfiedByearns its place: it compiles the same expression tree and runs it as an ordinary delegate. The importer parses one CSV row at a time into aCandidatebefore anything is saved, so it is in the same position as the validator: no query provider, just an object and a rule to check against it. -
Challenge
Step 4: Compose optional filters and page results before materializing
Two specifications cannot simply be ANDed together at the expression level, because each one's
ToExpression()builds its ownParameterExpression-- to the compiler they are two different variables namedcandidate, even though they mean the same thing.AndSpecificationinsrc/Specifications.csis given aReplaceParameterVisitorthat walks a tree and swaps every occurrence of one parameter for another, so both sides can be rebound onto a single shared parameter beforeExpression.AndAlsocombines their bodies.LocationSpecificationandRemoteEligibleSpecificationare already implemented insrc/Specifications.cs-- they follow the same pattern you just finished forAndSpecification.CandidateSearchService.SearchAsyncreceives a request that may or may not carry a location or a remote-only flag, and a page number and size that must be applied before the query is enumerated, not after. -
Challenge
Step 5: Load related history, project DTOs, and validate generated candidates
A candidate's work history is a separate table, related by foreign key. EF Core never loads a navigation property unless the query says so -- leaving it out silently returns an empty collection instead of failing loudly, which is why
GetWithHistoryAsynccurrently returns candidates whoseWorkHistoryis always empty. Returning a trackedCandidateentity straight from an endpoint leaks EF Core's internal state and exposes fields callers should not see.CandidateSearchResultis the DTO that search results and profile lookups should return instead --CandidateDtoProjector.ToSearchResultDtobuilds one from a loaded candidate, aggregating the work history you just started loading. A referral candidate arrives from a web form, not a CSV row or a search query, but the eligibility rules do not change depending on where a candidate came from.ReferralCandidateValidator.ValidateReferralis called from the referral submission endpoint inProgram.csbefore the candidate is ever added to the database -- it is the last of the three original duplicates' replacements, and the first place a brand-new candidate meets the rule at all. -
Challenge
Step 6: See the shared rules at work in the running API
Every task is done and graded. Now run TalentMatch as an API and call it the way a recruiter's tools would. Every response you see comes from the code you wrote, and the shared rules show up in each one.
Start the API
In the Terminal tab, start the API:
bash startApi.shThe script builds the app, starts it in the background, and waits until it's ready. Its logs go to
/tmp/talentmatch.log, so they won't mix with the responses you're reading. If the build fails, the script shows the end of that log so you can see why.At startup the app adds six candidates to its database. For the rest of this step, you'll call the API with
callApi.sh. Give it a path, and it prints the JSON response followed by the HTTP status code. For example,bash callApi.sh /candidates/1callshttp://localhost:5000/candidates/1. ### One rule, three places, one answerThis is the bug from Step 1. Ben Ivers has 2 years of experience, and the minimum is 3. The old validator checked
>= 2, so it accepted him even though search left him out. This endpoint asks the search query, the validation service, and the CSV importer about the same candidate:bash callApi.sh /candidates/2/eligibility bash callApi.sh /candidates/5/eligibilityBen gets
falsefrom all three. Eli Novak has exactly 3 years and getstruefrom all three. The repository runs the rule in a query, and the validator and importer run it on plain objects in memory. All of them use the sameMinimumExperienceSpecification, so their answers can't drift apart. ### Optional filters and pagingStart with the two required filters, then add the optional ones. Each optional filter you send is joined to the rule with an
AndSpecification. Filters you leave out are never added:bash callApi.sh "/candidates/search?minYears=3&skill=SQL" bash callApi.sh "/candidates/search?minYears=3&skill=SQL&location=Austin&remoteOnly=true" bash callApi.sh "/candidates/search?minYears=3&skill=SQL&pageSize=2&page=2"The first search finds four candidates. The second narrows that to the two remote-eligible candidates in Austin. The third returns the second page of the first search.
SkipandTakerun as part of the query, so only those two rows are loaded. ### Work history and the DTObash callApi.sh /candidates/1 bash callApi.sh /candidates/4Ada's
totalWorkHistoryYearsis7, the sum of her two jobs. That number is right only becauseIncludeloaded her history. Dana has no work history, and her total is0rather than an error. Both responses areCandidateSearchResultDTOs, so the trackedCandidateentity never leaves the server. ### A referral meets the same ruleSubmit a referral for someone with 1 year of experience, then submit the same person with 4 years:
bash callApi.sh /referrals '{"candidate":{"name":"Gia Park","location":"Austin","yearsExperience":1,"skills":["SQL"],"remoteEligible":true},"minimumYearsExperience":3,"requiredSkill":"SQL"}' bash callApi.sh /referrals '{"candidate":{"name":"Gia Park","location":"Austin","yearsExperience":4,"skills":["SQL"],"remoteEligible":true},"minimumYearsExperience":3,"requiredSkill":"SQL"}' bash callApi.sh "/candidates/search?minYears=3&skill=SQL&location=Austin&remoteOnly=true"Adding a JSON body after the path makes
callApi.shsend a POST. The API rejects the first referral before saving it, returningHTTP 400with yourValidationResultmessage as the reason. It accepts the second, and Gia then appears in the Austin remote search. The specification that approved her referral is the one search uses to find her. ### Wrap upStop the API when you're done:
bash stopApi.shTo rerun all the task checks, use
dotnet test tests/Lab.Tests.csproj --nologo --verbosity quiet. Everything you wrote is still open in the editor tabs.
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.