[Canonical page](<https://docs.dhtmlx.com/planning-engine/java/resources-and-assignment/skills-and-requirements/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Skills and requirements

This guide shows how to staff tasks by skill: describe who can do what, state what each task needs, and get slots that [`Project#suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/java/api/project-suggest-assignments.md>) fills. It also covers how earlier assignments stay attached to their requirements from one run to the next.

## How the skill layer works

The scheduling engine doesn't know about skills. Auto-assignment works on **slots**: one place for one person on one task, with a list of resources allowed to take it. The skill layer, [`SkillLayer.slots`](<https://docs.dhtmlx.com/planning-engine/java/api/skill-layer-slots.md>), builds those slots from the data you already work with: workers and their skills. It ships in the same JAR and package as the engine.

Three inputs go into `SkillLayer.slots`:

- The accepted state of a solved [`Project`](<https://docs.dhtmlx.com/planning-engine/java/api/project/project.md>). The skill layer reads its scheduled leaf tasks, resources and active assignments through [`Project#roster`](<https://docs.dhtmlx.com/planning-engine/java/api/project-roster.md>).
- A [`Pool`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/pool.md>) of [`Worker`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/worker.md>)s. Each worker is a resource id plus a list of [`Skill`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/skill.md>)s. A skill value is `0`/`1` for yes-or-no skills, or an integer grade (1 = junior, 2 = senior, and so on). [`Skill.value`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/skill.md>) defaults to `1.0`. Pool order is the tie-break preference.
- A list of [`Requirement`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md>)s. A requirement says "task T needs `count` people who satisfy all of these [`SkillMatch`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md#skillmatch>)es, each booked at `units`". The call takes a `Vec<Requirement>` or any `Collection`, so `List.of(...)` works.

`slots` returns a [`Vec<Slot>`](<https://docs.dhtmlx.com/planning-engine/java/api/suggest/slot.md>), ready to pass to [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/java/api/project-suggest-assignments.md>):

- Each requirement becomes `count` slots with ids `"<requirement id>#1"`, `"#2"` and so on, in requirement order. [`SkillLayer.slotId`](<https://docs.dhtmlx.com/planning-engine/java/api/skill-layer-slots.md#skilllayerslotid>) builds the same string.
- `allowed` lists the pool workers that pass every match of the requirement, in pool order.
- `value` is the requirement's `units`: the rate a new assignment books.
- `assignment` names the existing assignment that already fills the slot, or is `null`. See [Binding existing assignments](<https://docs.dhtmlx.com/planning-engine/java/resources-and-assignment/skills-and-requirements.md#binding-existing-assignments>).

A slot's id is also the id of the assignment that fills it. After you commit a proposal, the task carries assignments named `be#1`, `be#2`, and the next `slots` call finds them by id. That's what keeps staffing stable across runs. It also means a requirement id belongs to its task: reuse `be` on another task after a commit and `slots` refuses it.

Skills, pools and requirements aren't stored in [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/java/api/data/project-data.md>). You pass them on every call, so they can come from wherever your app keeps them. `slots` reads the project and never changes it.

## Pools and skills

Build a pool, describe what each task needs, and turn both into slots:

```java
import org.dhtmlx.plen.*;
import java.util.List;

public class PoolSlots {
    static Worker worker(String resource, Skill... skills) {
        return new Worker().withResource(Id.str(resource)).withSkills(List.of(skills));
    }

    static Skill skill(String id, double value) {
        return new Skill().withId(Id.str(id)).withValue(value);
    }

    public static void main(String[] args) {
        // Two 2-day tasks starting Monday 2026-08-10.
        Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
        ProjectData data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.str("api")).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.str("ui")).withStartDate(monday).withDuration(2.0)))
            .withResources(List.of(
                new ResourceData().withId(Id.str("ann")),
                new ResourceData().withId(Id.str("bob")),
                new ResourceData().withId(Id.str("cat"))));
        Project project = new Project();
        project.solve(data, new SolveOptions());

        // The pool: who can do what. Order is the tie-break preference.
        Pool pool = new Pool().withWorkers(List.of(
            worker("ann", skill("backend", 1.0), skill("frontend", 1.0)),
            worker("bob", skill("backend", 1.0)),
            worker("cat", skill("frontend", 1.0))));
        // What each task needs.
        List<Requirement> requirements = List.of(
            new Requirement(Id.str("be"), Id.str("api"),
                List.of(SkillMatch.atLeast(Id.str("backend"), 1.0))),
            new Requirement(Id.str("fe"), Id.str("ui"),
                List.of(SkillMatch.atLeast(Id.str("frontend"), 1.0))));

        for (Slot slot : SkillLayer.slots(project, pool, requirements)) {
            System.out.println(slot.id + " on " + slot.task + ": allowed " + slot.allowed
                + ", value " + slot.value);
        }
    }
}
```

Output:

```text
be#1 on api: allowed [ann, bob], value 1.0
fe#1 on ui: allowed [ann, cat], value 1.0
```

Ann has both skills, so she's eligible for both slots. Nothing is assigned yet: `slots` only says who *may* take each place. Choosing who *does* is the job of [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/java/api/project-suggest-assignments.md>).

A worker's `resource` must name a resource in the project, and each worker appears once. A worker can be in the pool without being eligible for anything, and a project resource can stay out of the pool. A resource outside the pool is never offered for a slot.

## Requirements and matches

[`new Requirement(id, task, matches)`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md>) creates one slot at full rate (`count` 1, `units` 1.0, `assignments` `null`). Use [`withCount`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md#withid-withtask-withcount-withunits>) and `withUnits` to ask for more people or a lower rate. A [`SkillMatch`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md#skillmatch>) compares a worker's skill value with a number:

| Factory | Holds when |
|---|---|
| [`SkillMatch.atLeast(skill, v)`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md#atleast-nomore-equal>) | worker value `>= v` |
| `SkillMatch.noMore(skill, v)` | worker value `<= v` |
| `SkillMatch.equal(skill, v)` | worker value `== v` |

All three are inclusive. A skill the worker doesn't list counts as `0`, so `noMore(Id.str("qa"), 0.0)` means "not a tester". A requirement with several matches needs all of them. A requirement with an empty match list allows every worker in the pool. To test one worker against one match, call [`SkillMatch#holds(worker)`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md#holds>).

```java
import org.dhtmlx.plen.*;
import java.util.List;

public class Matches {
    static Worker worker(String resource, Skill... skills) {
        return new Worker().withResource(Id.str(resource)).withSkills(List.of(skills));
    }

    static Skill skill(String id, double value) {
        return new Skill().withId(Id.str(id)).withValue(value);
    }

    static Requirement requirement(String id, SkillMatch... matches) {
        return new Requirement(Id.str(id), Id.str("release"), List.of(matches));
    }

    public static void main(String[] args) {
        ProjectData data = new ProjectData()
            .withTasks(List.of(new TaskData(Id.str("release"))
                .withStartDate(Moment.fromYmdHm(2026, 8, 10, 8, 0))
                .withDuration(3.0)))
            .withResources(List.of(
                new ResourceData().withId(Id.str("ann")),
                new ResourceData().withId(Id.str("bob")),
                new ResourceData().withId(Id.str("cat")),
                new ResourceData().withId(Id.str("dan"))));
        Project project = new Project();
        project.solve(data, new SolveOptions());

        // Grades: QA level 1 = junior, 2 = senior.
        Pool pool = new Pool().withWorkers(List.of(
            worker("ann", skill("backend", 1.0), skill("qa", 1.0)),
            worker("bob", skill("backend", 1.0), skill("devops", 1.0)),
            worker("cat", skill("qa", 2.0)),
            worker("dan", skill("qa", 1.0))));

        // Two half-time backend developers.
        Requirement backend = requirement("be", SkillMatch.atLeast(Id.str("backend"), 1.0))
            .withCount(2)
            .withUnits(0.5);
        // A senior tester.
        Requirement seniorQa = requirement("qa", SkillMatch.atLeast(Id.str("qa"), 2.0));
        // A junior tester: exactly grade 1.
        Requirement juniorQa = requirement("qa-jr", SkillMatch.equal(Id.str("qa"), 1.0));
        // A backend developer who also does devops: every match must hold.
        Requirement ops = requirement("ops",
            SkillMatch.atLeast(Id.str("backend"), 1.0),
            SkillMatch.atLeast(Id.str("devops"), 1.0));
        // Anyone who is not a QA: a missing skill counts as 0.
        Requirement reviewer = requirement("review", SkillMatch.noMore(Id.str("qa"), 0.0));

        List<Requirement> requirements = List.of(backend, seniorQa, juniorQa, ops, reviewer);
        for (Slot slot : SkillLayer.slots(project, pool, requirements)) {
            System.out.println(slot.id + ": value " + slot.value + ", allowed " + slot.allowed);
        }
    }
}
```

Output:

```text
be#1: value 0.5, allowed [ann, bob]
be#2: value 0.5, allowed [ann, bob]
qa#1: value 1.0, allowed [cat]
qa-jr#1: value 1.0, allowed [ann, dan]
ops#1: value 1.0, allowed [bob]
review#1: value 1.0, allowed [bob]
```

`withCount(2)` gives two slots, `be#1` and `be#2`, each booking half-time. Grades turn "senior tester" and "junior tester" into `atLeast` and `equal` matches on the same skill. `review` excludes Ann, Cat and Dan because each of them has a QA grade above `0`.

A slot list can allow the same person on several slots of one task. [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/java/api/project-suggest-assignments.md>) never puts one resource on a task twice, so Ann can't end up in both `be#1` and `be#2`.

## Binding existing assignments

When the task already has active assignments, `slots` attaches them to slots, so a filled place isn't offered again. Binding runs per task, over all requirements on that task, in three steps:

1. **Slot id.** An assignment whose id is one of the requirement's slot ids (`be#2`) binds to that slot. This is how assignments created by an earlier [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/java/api/project-suggest-assignments.md>) run reconnect to their slots. It binds even when the resource no longer matches. The slot is then *broken*.
2. **Explicit list.** A non-null `assignments` list on a requirement replaces automatic binding for it. Listed entries take the remaining slots in order, and leftover slots stay empty. An empty list leaves every slot empty.
3. **Matching.** The task's remaining assignments go to the empty slots of requirements whose `assignments` is `null`. An assignment can go to a slot only when its resource is in the slot's `allowed`. The engine runs a maximum bipartite matching in input order. It fills as many slots as possible instead of giving each slot the first fit.

Here Ann (backend and frontend) and Bob (backend only) already work on `api` under ids the skill layer didn't create:

```java
import org.dhtmlx.plen.*;
import java.util.ArrayList;
import java.util.List;

public class Binding {
    static Worker worker(String resource, String... skills) {
        List<Skill> list = new ArrayList<>();
        for (String id : skills) {
            list.add(new Skill().withId(Id.str(id)));
        }
        return new Worker().withResource(Id.str(resource)).withSkills(list);
    }

    static AssignmentData booking(String id, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str("api"))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    static void print(String label, List<Slot> slots) {
        List<String> bound = new ArrayList<>();
        for (Slot slot : slots) {
            bound.add(slot.assignment == null
                ? slot.id + " empty"
                : slot.id + " <- " + slot.assignment);
        }
        System.out.println(label + ": " + bound);
    }

    public static void main(String[] args) {
        // Ann and Bob already work on `api`, under ids the skill layer didn't create.
        ProjectData data = new ProjectData()
            .withTasks(List.of(new TaskData(Id.str("api"))
                .withStartDate(Moment.fromYmdHm(2026, 8, 10, 8, 0))
                .withDuration(2.0)))
            .withResources(List.of(
                new ResourceData().withId(Id.str("ann")),
                new ResourceData().withId(Id.str("bob"))))
            .withAssignments(List.of(booking("a1", "ann"), booking("a2", "bob")));
        Project project = new Project();
        project.solve(data, new SolveOptions());

        Pool pool = new Pool().withWorkers(List.of(
            worker("ann", "backend", "frontend"),
            worker("bob", "backend")));
        Requirement backend = new Requirement(Id.str("be"), Id.str("api"),
            List.of(SkillMatch.atLeast(Id.str("backend"), 1.0)));
        Requirement frontend = new Requirement(Id.str("fe"), Id.str("api"),
            List.of(SkillMatch.atLeast(Id.str("frontend"), 1.0)));

        // Automatic: a maximum matching, so Bob takes backend and Ann frontend.
        print("matching", SkillLayer.slots(project, pool, List.of(backend, frontend)));

        // Explicit list: `be` takes a1, whatever the matching would do.
        Requirement pinned = backend.deepClone()
            .withCount(2)
            .withAssignments(List.of(Id.str("a1")));
        print("explicit", SkillLayer.slots(project, pool, List.of(pinned, frontend)));

        // Empty list: every slot of `be` starts empty.
        Requirement fresh = backend.deepClone().withAssignments(List.of());
        print("empty list", SkillLayer.slots(project, pool, List.of(fresh, frontend)));
    }
}
```

Output:

```text
matching: [be#1 <- a2, fe#1 <- a1]
explicit: [be#1 <- a1, be#2 empty, fe#1 empty]
empty list: [be#1 empty, fe#1 <- a1]
```

A greedy pass would give `be` to Ann, the first backend assignment, and leave `fe` empty. The matching gives backend to Bob so Ann can cover frontend. The explicit list pins `a1` to `be#1`. Bob's `a2` can't fill `fe#1`, so that slot stays empty. With an empty list, `be` starts empty and Ann's assignment goes to `fe`.

The example changes copies made with [`deepClone()`](<https://docs.dhtmlx.com/planning-engine/java/api/assign/requirement.md#deepclone>), because the `withX` setters change the record in place and return the same object.

An assignment that ends up in no slot stays on the task. It still blocks its resource for the task's other slots, and `suggestAssignments` never repairs or moves it.

`slots` checks explicit lists up front:

- At most `count` entries, each an active assignment on the requirement's task, and each bound once.
- If an assignment carries one of the requirement's slot ids, the list must name it. It keeps its own slot.

## From slots to suggestions

Pass the slots straight to [`Project#suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/java/api/project-suggest-assignments.md>), commit the proposal with [`apply`](<https://docs.dhtmlx.com/planning-engine/java/api/project-apply.md>), and reschedule. The next run with the same requirements binds the committed assignments by slot id, so the proposal stays stable. When a worker's skills change, the bound slot becomes broken, and the [`BROKEN`](<https://docs.dhtmlx.com/planning-engine/java/api/suggest/suggest-options.md#assigntarget>) target repairs it:

```java
import org.dhtmlx.plen.*;
import java.util.ArrayList;
import java.util.List;

public class Suggest {
    static Worker worker(String resource, String skill) {
        return new Worker()
            .withResource(Id.str(resource))
            .withSkills(List.of(new Skill().withId(Id.str(skill))));
    }

    public static void main(String[] args) {
        // `api` and `ui` both run Monday-Tuesday. Bob already works on `api`.
        Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
        ProjectData data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.str("api")).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.str("ui")).withStartDate(monday).withDuration(2.0)))
            .withResources(List.of(
                new ResourceData().withId(Id.str("ann")),
                new ResourceData().withId(Id.str("bob"))))
            .withAssignments(List.of(new AssignmentData()
                .withId(Id.str("a2"))
                .withTask(Id.str("api"))
                .withResource(Id.str("bob"))
                .withValue(1.0)));
        Project project = new Project();
        project.solve(data, new SolveOptions());

        Pool pool = new Pool().withWorkers(List.of(
            worker("bob", "backend"),
            worker("ann", "backend")));
        List<Requirement> requirements = List.of(
            new Requirement(Id.str("be-api"), Id.str("api"),
                List.of(SkillMatch.atLeast(Id.str("backend"), 1.0))),
            new Requirement(Id.str("be-ui"), Id.str("ui"),
                List.of(SkillMatch.atLeast(Id.str("backend"), 1.0))));

        // Run 1: fill the empty slot.
        Vec<Slot> slots = SkillLayer.slots(project, pool, requirements);
        SuggestResult proposal = project.suggestAssignments(slots, new SuggestOptions());
        for (SuggestStep step : proposal.steps) {
            System.out.println("pass " + step.pass + ": " + step.slot + " -> " + step.toResource);
        }

        // Commit the proposal and reschedule.
        ApplyReport report = project.apply(proposal.changes);
        if (!report.rejected.isEmpty()) {
            RejectedOperation rejected = report.rejected.get(0);
            throw new IllegalStateException(rejected.path + ": " + rejected.message);
        }
        project.solve(null, new SolveOptions());

        // Run 2: the same requirements bind to what run 1 committed.
        slots = SkillLayer.slots(project, pool, requirements);
        for (Slot slot : slots) {
            System.out.println(slot.id + " <- " + slot.assignment);
        }
        SuggestOptions options = new SuggestOptions().withTargets(
            List.of(AssignTarget.EMPTY, AssignTarget.BROKEN, AssignTarget.VALID));
        proposal = project.suggestAssignments(slots, options);
        System.out.println("run 2: " + proposal.steps.size() + " steps");

        // Run 3: Ann moved to frontend, so her slot is now broken.
        pool = new Pool().withWorkers(List.of(
            worker("bob", "backend"),
            worker("ann", "frontend")));
        slots = SkillLayer.slots(project, pool, requirements);
        SuggestOptions check = new SuggestOptions().withTargets(List.of());
        System.out.println("invalid: " + project.suggestAssignments(slots, check).invalid);
        proposal = project.suggestAssignments(slots, options);
        for (SuggestStep step : proposal.steps) {
            System.out.println("pass " + step.pass + ": " + step.slot + " "
                + step.fromResource + " -> " + step.toResource);
        }
    }
}
```

Output:

```text
pass 0: be-ui#1 -> ann
be-api#1 <- a2
be-ui#1 <- be-ui#1
run 2: 0 steps
invalid: [be-ui#1]
pass 0: be-ui#1 ann -> bob
```

Run 1 fills `be-ui#1` with Ann. Bob comes first in the pool, but he's already busy on `api`. The committed assignment gets the slot's id. In run 2 it binds back to its slot, and with every target on there's nothing to improve. In run 3 Ann no longer has the backend skill. `withTargets(List.of())` only reports the broken slot in `invalid`. The full run repairs it by moving the assignment to Bob.

Some things to keep in mind:

- `slots` needs a solved, clean project. After `apply`, call [`solve(null, …)`](<https://docs.dhtmlx.com/planning-engine/java/api/project-solve.md>) before you build slots again.
- `suggestAssignments` checks the slots again: unknown tasks or resources, duplicate slot ids, and a slot id that is already an assignment on another task are errors. Slots from `slots` pass these checks, unless the project changed in between.
- Targets, `fixed` assignments, `newResourcePenalty`, leveled scoring and the step log are covered in [Suggesting assignments](<https://docs.dhtmlx.com/planning-engine/java/resources-and-assignment/suggesting-assignments.md>).
- To keep a person on a slot whatever the load, add the slot's assignment id to [`SuggestOptions.fixed`](<https://docs.dhtmlx.com/planning-engine/java/api/suggest/suggest-options.md>).

## Errors

`SkillLayer.slots` throws an unchecked [`MatchException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/match-exception.md>) with two subclasses:

- [`MatchException.Project`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/match-exception.md#project>): the project is unsolved, or it has unsolved edits after [`apply`](<https://docs.dhtmlx.com/planning-engine/java/api/project-apply.md>). `code()` returns [`ProjectError.NO_BASELINE`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/project-exception.md#projecterror>), and `error` holds the wrapped [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/project-exception.md>). `slots` takes no baseline token, so there's no stale-baseline case.
- [`MatchException.Invalid`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/match-exception.md#invalid>): bad pool or requirement input. The `path` and `detail` fields hold the location and the reason, and `getMessage()` is `"<path>: <detail>"`.

A `null` argument throws `NullPointerException`.

`MatchException` isn't a [`PlenException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/plen-exception.md>). Its simple name also clashes with `java.lang.MatchException`, so `import org.dhtmlx.plen.*;` alone leaves `MatchException` ambiguous. Add a single-type import:

```java
import org.dhtmlx.plen.*;
import org.dhtmlx.plen.MatchException;
import java.util.List;

public class Errors {
    public static void main(String[] args) {
        Pool pool = new Pool().withWorkers(List.of(new Worker()
            .withResource(Id.str("ann"))
            .withSkills(List.of(new Skill().withId(Id.str("backend"))))));
        Requirement requirement = new Requirement(Id.str("be"), Id.str("api"),
            List.of(SkillMatch.atLeast(Id.str("backend"), 1.0)));

        // Not solved yet: no accepted state to read.
        Project project = new Project();
        try {
            SkillLayer.slots(project, pool, List.of(requirement));
        } catch (MatchException.Project e) {
            System.out.println("project error: " + e.code());
        }

        ProjectData data = new ProjectData()
            .withTasks(List.of(new TaskData(Id.str("api"))
                .withStartDate(Moment.fromYmdHm(2026, 8, 10, 8, 0))
                .withDuration(2.0)))
            .withResources(List.of(new ResourceData().withId(Id.str("ann"))));
        project.solve(data, new SolveOptions());

        // `count` 0 is invalid input with a path.
        Requirement empty = requirement.deepClone().withCount(0);
        try {
            SkillLayer.slots(project, pool, List.of(empty));
        } catch (MatchException.Invalid e) {
            System.out.println("invalid: " + e.path + ": " + e.detail);
        }
    }
}
```

Output:

```text
project error: NO_BASELINE
invalid: requirements[0].count: must be at least 1
```

The `path` points at the offending item:

| Path | Reason |
|---|---|
| `pool.workers[i].resource` | Unknown resource, or a worker listed twice |
| `pool.workers[i].skills[j].id` | A skill listed twice for one worker |
| `pool.workers[i].skills[j].value` | Not a finite number |
| `requirements[i].id` | Duplicate requirement id, or one of its slot ids is an assignment on another task |
| `requirements[i].task` | Unknown or unscheduled task |
| `requirements[i].count` | `0` |
| `requirements[i].units` | Not finite, or `0` or less |
| `requirements[i].matches[j].value` | Not a finite number |
| `requirements[i].assignments` | More entries than `count`, or an assignment with this requirement's slot id is left out |
| `requirements[i].assignments[k]` | Unknown or inactive assignment, on another task, or bound twice (the message names the other requirement when there is one) |

`slots` throws on the first problem it finds and builds nothing. It never changes the project, so you can fix the input and call again. To run the pool checks alone against a [`Roster`](<https://docs.dhtmlx.com/planning-engine/java/api/suggest/roster.md>), call [`SkillLayer.validatePool(roster, pool)`](<https://docs.dhtmlx.com/planning-engine/java/api/skill-layer-slots.md#skilllayervalidatepool>).

[`toPlenException()`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/match-exception.md#toplenexception>) converts either subclass into the matching engine exception: `Invalid` becomes a validation [`InputException`](<https://docs.dhtmlx.com/planning-engine/java/api/errors/input-exception.md>) with the same `path`, and `Project` returns the wrapped `ProjectException`. Convert at the call site and one `catch` chain then covers both calls:

```java
import org.dhtmlx.plen.*;
import org.dhtmlx.plen.MatchException;
import java.util.List;

public class Staff {
    // One exception family for both calls: convert the MatchException.
    static SuggestResult staff(Project project, Pool pool, List<Requirement> requirements) {
        Vec<Slot> slots;
        try {
            slots = SkillLayer.slots(project, pool, requirements);
        } catch (MatchException e) {
            throw e.toPlenException();
        }
        return project.suggestAssignments(slots, new SuggestOptions());
    }

    public static void main(String[] args) {
        try {
            SuggestResult result = staff(new Project(), new Pool(), List.of());
            System.out.println(result.steps.size() + " steps");
        } catch (ProjectException e) {
            System.out.println("project error: " + e.code);
        } catch (InputException e) {
            System.out.println("input error at " + e.path + ": " + e.getMessage());
        }
    }
}
```

Output:

```text
project error: NO_BASELINE
```

See [Error handling](<https://docs.dhtmlx.com/planning-engine/java/error-handling.md>) for the engine's exception model.
