Most of the applications we developers are working on are have to do with data. We are getting data from “outside world”, save it and pass further. And it does not matter how complex the domain is we always ask the next questions:

  • How do I know for sure that my data is valid?
  • How do I avoid errors in my system and do not inject it into other system?

The answer is - better data modeling. Clear, cohesive domain model that make it very hard to input erroneous states.

One of the methods to achieve that is data oriented programming.

The main characteristics of Data Oriented Programming are:

  1. Modeling clear and cohesive data structures
  2. Using immutable data
  3. Making illegal state impossible to enter the system
  4. Validation at the boundary

Let’s take a look at each of these characteristics on relatively easy Java example.

We will build simple REST application that process orders.

Modeling data with sealed interfaces and records

To get cohesive data model we will use sealed interface for Order and records for alternatives of the Order. The benefit of this approach is better safety and maintainability by making illegal states unrepresentable.

Annotations above sealed class are necessarily to link interface and matching concrete implementation.

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonSubTypes({
        @JsonSubTypes.Type(value = ClientOrder.class, name = "ClientOrder"),
        @JsonSubTypes.Type(value = GuestOrder.class, name = "GuestOrder")
})
public sealed interface Order permits ClientOrder, GuestOrder {
}


public record GuestOrder(String orderDetails, String deliveryDetails) implements Order {
}

public record ClientOrder(String orderDetails, String deliveryDetails, Long clientId) implements Order {
    public ClientOrder {
        var clientIdStr = String.valueOf(clientId);
        if (clientIdStr.length() > 10 || clientIdStr.length() < 3) {
            throw new IllegalArgumentException("clientId must be no more than 10 and no less than 3 digits long");
        }
    }
}

Using records also brings immutability benefits. Fields of a record do not have setter methods and are implicitly final. As shown in ClientOrder, record constructor can be a good place for validation data before injecting it into a system.

Processing the options with a pattern matching switch

To process and map Order options we use pattern matching switch. Because Order interface is sealed, compiler will check if all the options are covered.

public OrderEntity mapToEntity(Order order) {
        return switch (order) {
            case ClientOrder clientOrder -> mapToEntity(clientOrder);
            case GuestOrder guestOrder -> mapToEntity(guestOrder);
        };
    }

    public Order mapToDto(OrderEntity entity) {
        return switch (entity) {
            case ClientOrderEntity clientOrderEntity -> map(clientOrderEntity);
            case GuestOrderEntity guestOrderEntity -> map(guestOrderEntity);
            case null, default -> throw new OrderNotFoundException();
        };
    }

This implementation is quite elegant in comparison to Visitor pattern for example.

Controller takes Order sealed type and the system can confidently deal with alternative cases.

 @PostMapping
    public ResponseEntity<String> processOrder(@RequestBody Order order) {
        orderProcessor.processOrder(order);
        return ResponseEntity.ok("Order processed successfully! " + order);
    }

    @GetMapping
    public ResponseEntity<List<Order>> getOrders() {
        return ResponseEntity.ok(orderProcessor.getAllOrders());
    }

So, using data oriented approach can help with creating more cohesive data model, which can increase readability, maintainability and testability. In my opinion it can be good fit for both simple domain and more complex one.

Complete implementation can be found on GitHub.

Sources:

  1. Project Amber.