Umgang mit generierten Klassen am Beispiel von AVRO

AVRO ist derzeit ein sehr populäres Serialisierungs-Framework. Dank Schemaregistry für Enterprise-Anforderungen bestens gerüstet. Es macht Sinn, die generierten Klassen im Code zu verwenden - allerdings ist der Code dann oft mit Zusatzcode zugepflastert, der mit der generierten Klasse interagiert, obwohl die Funktionalität eigentlich besser in die integrierte Klasse passen würde.

Ein kurzer Exkurs: Große Schemas verwalten

Ein Finding war, dass komplexe Schemas in einer *.avsc File wenig Sinn machen. Die Herausforderung ist, dass beim ersten Vorkommnis ein record typ definiert wird und bei weiterer Verwendung nur mehr referenziert wird – das ist echt mühsam.

address.avsc:

{
  "type": "record",
  "name": "Address",
  "namespace": "com.example",
  "fields": [
    {
      "name": "street",
      "type": "string"
    },
    {
      "name": "city",
      "type": "string"
    },
    {
      "name": "state",
      "type": "string"
    },
    {
      "name": "zipCode",
      "type": "string"
    }
  ]
}

person.avsc:

{
  "type": "record",
  "name": "Person",
  "namespace": "com.example",
  "fields": [
    {
      "name": "firstName",
      "type": "string"
    },
    {
      "name": "lastName",
      "type": "string"
    },
    {
      "name": "mainResidence",
      "type": "com.example.Address"
    },
    {
      "name": "secondaryResidence",
      "type": ["null", "com.example.Address"],
      "default": null
    }
  ]
}

Mit dem avro maven tool kann anschließend alles zusammengefügt werden (und ggf. mit <includes> ergänzt werden – je nachdem, wo die Files liegen).

Verhalten mit dem Dekorator erweitern

Man hat jetzt eine schöne Klasse – die aber relativ „dumm“ ist und wohl am ehesten einem POJO / POCO entspricht. Natürlich hätte man jetzt die Möglichkeit, alles nochmal selbst zu schreiben und zu mappen – aber wenn man ab und zu pragmatisch sein will – welche Wege gibt es noch?

In object-oriented programming, the decorator pattern is a design pattern that allows behavior to be added to an individual object, dynamically, without affecting the behavior of other instances of the same class … The decorator pattern provides a flexible alternative to subclassing for extending functionality (Wikipedia)

Z.B. könnte man eine Funktion „getPostalCodes“ implementieren. Dazu macht man einfach:

public class PersonDecorator {
    private Person person;

    public PersonDecorator(Person person) {
        this.person = person;
    }

    public List<PostalCode> getPostalCodes() {

Das Single-responsibility principle bleibt dadurch erhalten, ohne dass man unnötige Code-Duplikation hat.

SRP wahren mit dem Proxy Pattern

The access to an object should be controlled. implements additional functionality to control the access to this subject.

Das Problem ist, dass oft Klassen, deren Aufgabe e.g. irgendwas mit Berechnung zu tun haben, komplizierte Stream Operationen auf Listen machen. Meine Meinung dazu ist klar: im Domain Objekt hat sowas nichts verloren. Jede Stream Operation macht etwas – also kann man sie auch benennen. Mit dem Proxy Pattern kann ich sie sogar elegant in einem Container verstecken:

import java.util.List;
import java.util.Iterator;
import java.util.stream.Collectors;

public class ListContainer<T> implements Iterable<T> {
    private final List<T> list;

    public ListContainer(List<T> list) {
        this.list = list;
    }

    @Override
    public Iterator<T> iterator() {
        return list.iterator();
    }

    public List<T> filterLowerCaseNames() {
        return list.stream()
                   .filter(name -> name.toString().length() > 0 && Character.isLowerCase(name.toString().charAt(0)))
                   .collect(Collectors.toList());
    }

    public static void main(String[] args) {
        List<String> originalList = List.of("John", "Jane", "Alice", "seppi", "karl");

        ListContainer<String> proxyList = new ListContainer<>(originalList);

        System.out.println("Names starting with lowercase letters:");

        for (String name : proxyList.filterLowerCaseNames()) {
            System.out.println(name);
        }
    }
}

The power of .NET

In .NET funktionieren die ganzen Patterns natürlich auch. Was macht in .NET aber noch nutzen kann: die Roslyn Compiler Plattform. Da der C# Compiler in C# geschrieben ist, kann man sich in allen Stufen in den Prozess einklinken.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System;
using System.Linq;

class Program
{
    static void Main(string[] args)
    {
        string code = @"
        public class Person
        {
            public string Name { get; set; }
            public int Age { get; set; }
        }";

        // Step 1: Parse the code into a syntax tree
        var tree = CSharpSyntaxTree.ParseText(code);
        var root = tree.GetRoot();

        // Step 2: Find all property declarations
        var properties = root.DescendantNodes()
                             .OfType<PropertyDeclarationSyntax>()
                             .Where(p => p.AccessorList != null);

        // Step 3: Remove setters from properties and keep only getters
        var updatedProperties = properties.Select(p =>
        {
            var getters = p.AccessorList.Accessors.Where(a => a.Kind() == SyntaxKind.GetAccessorDeclaration).ToList();

            // Rebuild the property declaration with only the getter
            return p.WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.List(getters)));
        });

        // Step 4: Replace the original properties with the updated ones
        var updatedRoot = root.ReplaceNodes(properties, (original, _) => updatedProperties.First());

        // Step 5: Generate the modified code
        string modifiedCode = updatedRoot.NormalizeWhitespace().ToFullString();

        Console.WriteLine("Original Code:");
        Console.WriteLine(code);

        Console.WriteLine("\nModified Code:");
        Console.WriteLine(modifiedCode);
    }
}