Building Skill Align – Part 6 – Project Staffing Assistant(Backend)

I started with the first feature in this project: Project Staffing Assistant.

Project Staffing Assistant helps managers decide which candidates are suitable for a project based on actual project requirements.

I began with the backend, building the intelligence layer in Apex.

The Core Service – SkillEvaluatorService

public with sharing class SkillEvaluatorService 

Two important design decisions:

  • public → Required because LWC will call this Apex class

  • with sharing → Ensures record-level security is respected

I had previously configured roles, OWD, and sharing rules (Refer here).
Using with sharing ensures this evaluation logic follows those configurations.

Apex Sharing Behavior:

  • Apex runs in system context by default. Object-level and field-level permissions are not automatically enforced.

  • with sharing enforces record-level sharing rules only, ensuring queries and DML respect the current user’s access.

  • with sharing does not enforce object or field permissions. You must explicitly handle CRUD/FLS (e.g., WITH SECURITY_ENFORCED or Security.stripInaccessible()).

  • If no sharing keyword is defined, the class inherits sharing from its caller, so behavior may vary depending on depending on how it is invoked.

  • Triggers run in system context. Even if a helper class is marked with sharing, the trigger executes in system mode.

Designing Data Transfer Objects

Instead of returning raw Employee__c or Employee_Skill__c records, I created Data Transfer Objects or DTOs.

DTOs define the structured connection between backend and UI. They wrap only the fields required by the frontend, preventing unnecessary exposure of internal data.

For this feature, the UI needed:

  1. Detailed skill gap information (for manager-level decision making)

  2. Candidate-level summary information

Note: @AuraEnabled is required for LWC(UI) to access Apex properties and methods.

Skill-Level DTO

public class SkillGapDetail {
    @AuraEnabled public String skillName;
    @AuraEnabled public Integer requiredLevel;
    @AuraEnabled public Integer impact;
}

Represents a single skill gap for a candidate.

Advantages:

  • All evaluation logic runs in Apex, so UI performs no calculations

  • Business logic stays in the backend

  • UI remains lightweight

  • Future logic changes don’t affect frontend code

Candidate-Level DTO

public class CandidateResult {
    @AuraEnabled public String employeeName;
    @AuraEnabled public Decimal gapScore;
    @AuraEnabled public Boolean isProjectReady;
    @AuraEnabled public SkillGapDetail detail;
}

For each evaluated employee, the UI receives:

  • Employee name

  • Final gap score

  • Ready / Not Ready flag

  • Skill Gap Detail

This keeps the response clean and structured.

Entry Point – evaluateProject()

@AuraEnabled
public static List<CandidateResult> evaluateProject(Id projectId, Integer topN)

Responsibilities:

  • Accept a Project Id

  • Evaluate unallocated employees

  • Rank them

  • Return top N candidates

  • Persist evaluation results

Guard Clause

  • Guard clauses help prevent unnecessary processing and avoid unexpected or confusing UI behavior.
if (projectId == null) return new List<CandidateResult>();

If no project is provided, evaluation stops.

Prevents:

  • Null pointer exceptions

  • Unexpected UI errors

  • Wasted governor limits

Load Project Requirements

List<Project_Skill_Requirement__c> reqs = [
    SELECT Skill__c, Required_Level__c,
           Importance__c, Weight__c
    FROM Project_Skill_Requirement__c
    WHERE Project__c = :projectId
];

Each requirement contains:

  • Skill

  • Required Level

  • Importance (Required / Nice-to-have)

  • Weight

After fetching, I converted them into Maps for fast access.

Why Maps?

Governor limits restrict queries per transaction. Querying inside loops risks hitting limits. By storing data in Maps:

  • Avoid repeated SOQL calls

  • Ensure constant-time lookups (O(1))

  • Keep code bulk-safe

Maps are essential in Apex for this reason.

Weighted Impact Formula

This is the heart of the evaluation engine.

I first compute the deficit to rank candidates:
deficit = requiredLevel – employeeLevel;

By itself, this treats all skills equally. To make evaluations more realistic, I introduced weighted scoring:

Integer impact = deficit * importanceMultiplier * weight;

Where:

  • Required skill → multiplier = 2

  • Nice-to-have → multiplier = 1

  • Weight → configurable per skill

From this I ensured,

  • Missing a critical skill has higher impact

  • Minor skills don’t disproportionately penalize a candidate

The result is a system that is realistic and flexible rather than rigid.

Effective Level – Making It Smarter

Raw skill levels aren’t always reliable. To improve accuracy, I introduced two adjustments:

  1. Confidence adjustment
  2. Staleness adjustment

1. Confidence Adjustment

Boolean isTrusted = (src == 'Manager-assessed');
Integer confidenceAdjust = isTrusted ? 0 : 1;
Integer afterConfidence = rawLevel - confidenceAdjust;
  • Self-assessed → reduce slightly
  • Manager-assessed → keep unchanged

2. Staleness Adjustment

Date staleCutoff = Date.today().addMonths(-12);

if (lastVerified == null) {
    stalenessAdjust = 2;
} else if (lastVerified <= staleCutoff) {
    stalenessAdjust = 1;
}

Never verified → larger reduction

Verified >12 months ago → slight reduction
Finally, the effective level is computed as:

Integer effectiveLevel = afterConfidence - stalenessAdjust;
if (effectiveLevel < 0) effectiveLevel = 0;

This makes the evaluation time and credibility aware, preventing outdated or inflated skill ratings from misleading staffing decisions.

Ranking Candidates

results.sort(new CandidateComparator());

Custom comparator:

private class CandidateComparator implements System.Comparator<CandidateResult> {
    public Integer compare(CandidateResult x, CandidateResult y) {
        if (x.gapScore != y.gapScore) {
            return (x.gapScore < y.gapScore) ? -1 : 1;
        }
        return x.employeeName.toLowerCase()
               .compareTo(y.employeeName.toLowerCase());
    }
}

Sorting priority :

  1. Lowest gap score

  2. Alphabetical order as tie-breaker

Using this comparator ensures deterministic sorting, providing consistent results across repeated evaluations.

Project Ready Logic

cr.isProjectReady = (requiredImpact == 0);

If all required skills have zero impact, the candidate is ready.

Nice-to-have gaps don’t block readiness, preventing unnecessary hiring when existing employees are suitable.

Persisting Recommendations

The evaluation results are stored in the Project_Candidate__c object.

A composite key is used to uniquely identify each candidate for a project:

pc.Project_Employee_Key__c =
    String.valueOf(projectId) + '|' + String.valueOf(employeeId);

Note: – The Project_Employee_Key__c is a Text field marked Unique and Required.

The records are then saved using:

upsert candidates Project_Employee_Key__c;

upsert ensures:

  • Insert if record doesn’t exist

  • Updates the record if it already exists

  • Prevents duplicate records

  • Allows re-evaluation to update previous scores