Tuesday, July 22, 2025

Deserializing YAML into Java Records: Custom SnakeYAML Constructors Done Right

 (Originally published in LinkedIn)

Java records are compact, expressive, and immutable — everything you want in a clean domain model. But once you need to deserialize structured configuration or content (like YAML) into those records, especially with a few mutable wrinkles in the mix, things get… interesting.

That’s exactly the challenge I faced while building a configurable test engine.

Rather than abandon records or introduce mapping layers, I decided to go one level deeper and implement custom SnakeYAML constructors. This allowed me to preserve a clean model, support record-based structure, and even sneak in a little controlled mutability with a custom Holder<T>.

Let’s explore how that worked — and why it turned out to be such a solid pattern.


The Model

At the core, I had a simple, expressive domain built on records:

public record TestModel(String title, List<Section> sections, Results results) {}

public record Section(String name, List<Question> questions) {}

public record Question(String text, List<Answer> answers, Holder<Answer> selectedAnswer) {
    public Question(String text, List<Answer> answers) {
        this(text, answers, new Holder<>());
    }
}

public record Answer(String text, int score) {}

public record Results(List<ResultRange> ranges) {}

public record ResultRange(int min, int max, String text) {
    public boolean contains(int score) {
        return score >= min && score <= max; }
}

With Holder<Answer> used for dynamic state (selectedAnswer) that evolves during runtime — while the rest remains immutable. You can explore the full code here.


YAML Input Example

title: Strength Assessment
sections:
  - name: Morning
    questions:
      - text: How many push-ups?
        answers:
          - text: Less than 10
            score: 1
          - text: More than 30
            score: 5
results:
  - min: 0
    max: 5
    text: Keep going!

Custom Constructors with SnakeYAML

The solution: implement a custom Constructor that overrides SnakeYAML’s default behavior for each class.

Yaml yaml = new Yaml(new TestConstructor(new LoaderOptions()));
TestModel model = yaml.loadAs(inputStream, TestModel.class);

Here’s a breakdown of each custom constructor and how it maps YAML → Java:


TestConstructor

First, we need to tell SnakeYML how to deserialise each class. First we tell to use TestModelConstruct to deserialise the root object and tag each constructor with its class. Then add a TypeDescriptor for each class, so SnakeYML loads our classes.

private class TestConstructor extends Constructor {

    private static final Tag SECTION_TAG = new Tag(Section.class);
    private static final Tag QUESTION_TAG = new Tag(Question.class);
    private static final Tag ANSWER_TAG = new Tag(Answer.class);
    private static final Tag RESULTS_TAG = new Tag(Results.class);
    private static final Tag RESULT_RANGE_TAG = new Tag(ResultRange.class);

    public TestConstructor(LoaderOptions loadingConfig) {
        super(TestModel.class, loadingConfig);
        this.yamlClassConstructors.put(NodeId.mapping, new TestModelConstruct());
        this.yamlConstructors.put(SECTION_TAG, new SectionConstruct());
        this.yamlConstructors.put(QUESTION_TAG, new QuestionConstruct());
        this.yamlConstructors.put(ANSWER_TAG, new AnswerConstruct());
        this.yamlConstructors.put(RESULTS_TAG, new ResultsConstruct());
        this.yamlConstructors.put(RESULT_RANGE_TAG, new ResultRangeConstruct());
        Stream.of(TestModel.class, Section.class, Question.class, Answer.class, Results.class, ResultRange.class)
            .map(TypeDescription::new)
            .forEach(this::addTypeDescription);
    }
}

AbstractTestModelConstruct

This class implements some basic utilities and is the base for the rest of our more specific Constructs:

private class AbstractTestModelConstruct extends Constructor.ConstructMapping {

    protected String getScalarValue(Node valueNode) {
        return ((ScalarNode)valueNode).getValue();
    }

    protected List<Node> getSequenceValue(Node valueNode) {
        return ((SequenceNode) valueNode).getValue();
    }

    protected List<NodeTuple> getMappingValue(Node node) {
        return ((MappingNode) node).getValue();
    }

    protected <T> List<T> getChildren(NodeTuple tuple, Class<T> type, Tag tag) {
        return getChildren(tuple.getValueNode(), type, tag);
    }

    protected <T> List<T> getChildren(Node node, Class<T> type, Tag tag) {
        return getSequenceValue(node).stream()
            .map(n -> buildObject(n, type, tag))
            .collect(Collectors.toList());
    }

    protected <T> T buildObject(Node node, Class<T> type, Tag tag) {
        node.setType(type);
        node.setTag(tag);
        node.setTwoStepsConstruction(false);
        node.setUseClassConstructor(false);
        return (T) constructObject(node);
    }

}

TestModelConstruct

All the classes are internal to TestConstructor, so you can access Constructor.ConstructMapping, which is protected. Handles the root object: title, sections, results.

private class TestModelConstruct extends AbstractTestModelConstruct {

    @Override
    public Object construct(Node node) {
        node.setTwoStepsConstruction(false);
        String title = null;
        List<Section> sections = null;
        Results results = null;
        for (NodeTuple tuple : getMappingValue(node)) {
            switch (getScalarValue(tuple.getKeyNode())) {
                case "title":
                    title = getScalarValue(tuple.getValueNode());
                    break;
                case "sections":
                    sections = getChildren(tuple, Section.class, SECTION_TAG);
                    break;
                case "results":
                    results = buildObject(tuple.getValueNode(), Results.class, RESULTS_TAG);
                    break;
            }
        }
        return new TestModel(title, sections, results);
    }

}

Uses utility helpers like getScalarValue, getChildren, and buildObject to abstract away the boilerplate of walking YAML nodes.


SectionConstruct

Builds each section by extracting the name and deserializing its list of questions:

private class SectionConstruct extends AbstractTestModelConstruct {

    @Override
    public Object construct(Node node) {
        String name = null;
        List<Question> questions = null;
        for (NodeTuple tuple : getMappingValue(node)) {
            switch (getScalarValue(tuple.getKeyNode())) {
                case "name":
                    name = getScalarValue(tuple.getValueNode());
                    break;
                case "questions":
                    questions = getChildren(tuple, Question.class, QUESTION_TAG);
                    break;
            }
        }
        return new Section(name, questions);
    }
}

QuestionConstruct

Here’s where it gets interesting. We only provide text and answers in YAML — but the record has an extra Holder<Answer> for the selected answer:

private class QuestionConstruct extends AbstractTestModelConstruct {

    @Override
    public Object construct(Node node) {
        String text = null;
        List<Answer> answers = null;
        for (NodeTuple tuple : getMappingValue(node)) {
            final ScalarNode keyNode = (ScalarNode) tuple.getKeyNode();
            switch (keyNode.getValue()) {
                case "text":
                    text = getScalarValue(tuple.getValueNode());
                    break;
                case "answers":
                    answers = getChildren(tuple, Answer.class, ANSWER_TAG);
                    break;
            }
        }
        return new Question(text, answers);
    }
}

This is a great example of using overloaded constructors with records to manage default values elegantly.


AnswerConstruct

Straightforward mapping of answer text and score:

private class AnswerConstruct extends AbstractTestModelConstruct {

    @Override
    public Object construct(Node node) {
        String text = null;
        int score = 0;
        for (NodeTuple tuple : getMappingValue(node)) {
            switch (getScalarValue(tuple.getKeyNode())) {
                case "text":
                    text = getScalarValue(tuple.getValueNode());
                    break;
                case "score":
                    score = Integer.parseInt(getScalarValue(tuple.getValueNode()));
                    break;
            }
        }
        return new Answer(text, score);
    }
}

ResultsConstruct + ResultRangeConstruct

  • ResultsConstruct parses a list of ranges.
  • ResultRangeConstruct pulls out min, max, and descriptive text.

private class ResultsConstruct extends AbstractTestModelConstruct {

    @Override
    public Object construct(Node node) {
        return new Results(getChildren(node, ResultRange.class, RESULT_RANGE_TAG));
    }

}

private class ResultRangeConstruct extends AbstractTestModelConstruct {
    @Override
    public Object construct(Node node) {
        int min = 0;
        int max = 0;
        String text = null;
        for (NodeTuple tuple : getMappingValue(node)) {
            switch (getScalarValue(tuple.getKeyNode())) {
                case "min":
                    min = Integer.parseInt(getScalarValue(tuple.getValueNode()));
                    break;
                case "max":
                    max = Integer.parseInt(getScalarValue(tuple.getValueNode()));
                    break;
                case "text":
                    text = getScalarValue(tuple.getValueNode());
                    break;
            }
        }
        return new ResultRange(min, max, text);
    }
}

Notice that every constructor works entirely with YAML node trees — not strings or maps. This is low-level, but it gives you full control.

See the full code here


Why This Pattern Works

This approach gives you:

  • ✅ Full support for Java records, without DTOs or mapping libraries
  • ✅ Controlled injection of default values, like Holder<Answer>
  • ✅ Separation of concerns — YAML parsing logic is encapsulated, and your domain model stays clean
  • ✅ Future-proofing for features like field validation, filtering, or YAML schema constraints


Things to Watch Out For

  • Custom constructors are verbose — but centralized and cleanly separated.
  • This technique assumes full control over the YAML structure. If you're consuming external YAML, use it cautiously.
  • Avoid abuse — this pattern is great for structured config, but don’t build an ORM with it. ;)


Who Should Use This?

If you're:

  • Modeling structured forms, questionnaires, or tests
  • Loading configurations with nested structure
  • Wanting to keep domain logic expressive and compact
  • Already using or considering Java records

Then this pattern might be worth exploring in your next project.


Final Thought

Clean modeling and clean configuration don’t have to live in separate worlds.

With a bit of glue code, you can have expressive, immutable Java records and flexible YAML configuration — a pairing that turns out to be pretty powerful.

Let me know if you've implemented something similar — or taken a different approach to config-model binding. I'd love to compare notes.


No comments:

Post a Comment

The Gladiator’s Leap: Did Predatory Combat Ignite the Spark of Avian Flight?

  Forget the "trees-down" vs. "ground-up" debate. The true origins of the avian power stroke may lie in the brutal arena...