Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Intermediate
Last updated
Oct 29, 2026
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. 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.SearchAsync as a LINQ Where clause. Open the file and you will find a second, independent copy of the same experience check inside CandidateValidationService.IsEligible -- a private Func<Candidate,bool> that compares YearsExperience against a hardcoded number. A third copy sits inside CandidateCsvImporter.ImportRow as a plain if statement 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 an Expression<Func<Candidate,bool>>. An expression tree, unlike a compiled Func, 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 solution folder.

  2. Challenge

    Step 2: Build a single reusable candidate specification

    src/Specifications.cs already declares the shape every rule will share:

    public interface ISpecification<T>
    {
        Expression<Func<T, bool>> ToExpression();
        bool IsSatisfiedBy(T entity);
    }
    

    IsSatisfiedBy is provided on each concrete class as ToExpression().Compile()(entity) -- once a class can produce its expression tree, the in-memory check comes for free. The stub classes currently return entity => 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.

  3. Challenge

    Step 3: Move filtering into the repository and retire the predicate wrapper

    With both specifications built, SearchAsync, IsEligible, and ImportRow can all be pointed at the same class instead of their own private copy of the rule. EF Core's LINQ provider accepts an Expression<Func<T,bool>> in Where directly -- 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 where IsSatisfiedBy earns 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 a Candidate before 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.

  4. 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 own ParameterExpression -- to the compiler they are two different variables named candidate, even though they mean the same thing. AndSpecification in src/Specifications.cs is given a ReplaceParameterVisitor that walks a tree and swaps every occurrence of one parameter for another, so both sides can be rebound onto a single shared parameter before Expression.AndAlso combines their bodies. LocationSpecification and RemoteEligibleSpecification are already implemented in src/Specifications.cs -- they follow the same pattern you just finished for AndSpecification. CandidateSearchService.SearchAsync receives 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.

  5. 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 GetWithHistoryAsync currently returns candidates whose WorkHistory is always empty. Returning a tracked Candidate entity straight from an endpoint leaks EF Core's internal state and exposes fields callers should not see. CandidateSearchResult is the DTO that search results and profile lookups should return instead -- CandidateDtoProjector.ToSearchResultDto builds 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.ValidateReferral is called from the referral submission endpoint in Program.cs before 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.

  6. 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.sh
    

    The 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/1 calls http://localhost:5000/candidates/1. ### One rule, three places, one answer

    This 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/eligibility
    

    Ben gets false from all three. Eli Novak has exactly 3 years and gets true from 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 same MinimumExperienceSpecification, so their answers can't drift apart. ### Optional filters and paging

    Start 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. Skip and Take run as part of the query, so only those two rows are loaded. ### Work history and the DTO

    bash callApi.sh /candidates/1
    bash callApi.sh /candidates/4
    

    Ada's totalWorkHistoryYears is 7, the sum of her two jobs. That number is right only because Include loaded her history. Dana has no work history, and her total is 0 rather than an error. Both responses are CandidateSearchResult DTOs, so the tracked Candidate entity never leaves the server. ### A referral meets the same rule

    Submit 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.sh send a POST. The API rejects the first referral before saving it, returning HTTP 400 with your ValidationResult message 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 up

    Stop the API when you're done:

    bash stopApi.sh
    

    To 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

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

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.

Get started with Pluralsight