jakartaee / persistence

https://jakartaee.github.io/persistence/
Other
204 stars 59 forks source link

DDD, Hexagonal architecture and JPA mismatch problem #674

Closed urbandroid closed 1 week ago

urbandroid commented 1 week ago

Right now Domain Driven Design and Hexagonal architecture getting some traction they deserve and using JPA with these approaches is simply ugly here is the example to show it.

To comply with the architectural constraints in model module or domain module we shouldn't depend on infrastructure code that means bye, bye @Entity on model classes.

We have 3 choices to proceed.

1. Create duplicate entity classes in infra module like this:

For User class in model module:

// Model Module: Domain Entity (without JPA annotations)
public class User {
    private Long id;
    private String name;
    private String email;

    // getters and setters
}

Create UserEntity class in infra module

// Infrastructure Module: JPA Entity (with JPA annotations)
@Entity
@Table(name = "users")
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    // getters and setters
}

and then user repository looks like this

// Infrastructure Module: JPA Repository Implementation

public class JpaUserRepository implements UserRepository {

    @Inject
    private UserJpaRepository userJpaRepository;

    @Override
    public Optional<User> findById(Long id) {
        return userJpaRepository.findById(id)
            .map(this::toDomain);
    }

    @Override
    public void save(User user) {
        userJpaRepository.save(toEntity(user));
    }

    private User toDomain(UserEntity entity) {
        User user = new User();
        user.setId(entity.getId());
        user.setName(entity.getName());
        user.setEmail(entity.getEmail());
        return user;
    }

    private UserEntity toEntity(User user) {
        UserEntity entity = new UserEntity();
        entity.setId(user.getId());
        entity.setName(user.getName());
        entity.setEmail(user.getEmail());
        return entity;
    }
}

Look at those ugly toDomain and toEntity methods.

2. Orm.xml files in infrastructure module

For the same user model class we have

<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm"
                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                 xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm 
                 http://java.sun.com/xml/ns/persistence/orm_2_0.xsd"
                 version="2.0">

    <entity class="com.example.model.User" access="FIELD">
        <table name="users"/>
        <attributes>
            <id name="id">
                <generated-value strategy="IDENTITY"/>
            </id>
            <basic name="name"/>
            <basic name="email"/>
        </attributes>
    </entity>
</entity-mappings>

An then add those 1990 style xml files to another xml file persistence.xml file like this:

<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
             http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd"
             version="2.1">

    <persistence-unit name="userPU">
        <mapping-file>META-INF/orm.xml</mapping-file>
        <class>com.example.model.User</class>
        imagine lots of entries here

        <!-- Other properties like DataSource, JTA settings -->
    </persistence-unit>
</persistence>

An then for this option repository class looks cool and with it.

3. Custom annotation

First we create custom annotations in our model module for JPA to pick up later.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface EntityModel {
    String tableName();
}

// Custom Annotation for Fields
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface FieldModel {
    String columnName();
    boolean isId() default false;
}

Then we annotate our model classes with these annotations.

@EntityModel(tableName = "users")
public class User {

    @FieldModel(columnName = "id", isId = true)
    private Long id;

    @FieldModel(columnName = "name")
    private String name;

    @FieldModel(columnName = "email")
    private String email;

    // Getters and Setters
}

And then we use JPA metamodel to pick up our annotated classes:


import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import jakarta.persistence.metamodel.Metamodel;
import jakarta.persistence.metamodel.Type;
import java.lang.reflect.Field;

@ApplicationScoped
public class MetamodelProcessor {

    @Produces
    public EntityManagerFactory entityManagerFactory() {
        // Initialize EntityManagerFactory (you can customize the persistence unit name)
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("userPU");

        // Process Metamodel to dynamically handle custom annotations
        processMetamodel(emf);

        return emf;
    }

    @Inject
    private EntityManagerFactory emf;

    public void processMetamodel(EntityManagerFactory emf) {
        EntityManager em = emf.createEntityManager();
        Metamodel metamodel = em.getMetamodel();

        // Iterate over entity types in the metamodel
        for (Type<?> entityType : metamodel.getEntities()) {
            Class<?> entityClass = entityType.getJavaType();

            // Check if the class is annotated with custom @EntityModel
            if (entityClass.isAnnotationPresent(EntityModel.class)) {
                EntityModel entityAnnotation = entityClass.getAnnotation(EntityModel.class);
                String tableName = entityAnnotation.tableName();

                System.out.println("Processing Entity: " + entityClass.getSimpleName() + " -> Table: " + tableName);

                // Map fields based on @FieldModel annotations
                for (Field field : entityClass.getDeclaredFields()) {
                    if (field.isAnnotationPresent(FieldModel.class)) {
                        FieldModel fieldAnnotation = field.getAnnotation(FieldModel.class);
                        String columnName = fieldAnnotation.columnName();
                        boolean isId = fieldAnnotation.isId();

                        System.out.println("Field: " + field.getName() + " -> Column: " + columnName + " (ID: " + isId + ")");

                        // Adjust JPA mappings dynamically based on custom annotations
                        adjustMappings(entityClass, field, columnName, isId);
                    }
                }
            }
        }

        em.close();
    }

    private void adjustMappings(Class<?> entityClass, Field field, String columnName, boolean isId) {
        // Here you can modify JPA mappings based on the custom annotations
        // For example, you can create criteria, query mappings, or field adjustments.

        System.out.println("Adjusting mapping for field " + field.getName() + " in entity " + entityClass.getSimpleName());

        // Example: Adjusting the persistence context based on field types
        if (isId) {
            System.out.println("This field is the primary key: " + columnName);
            // Here you would adjust primary key strategies or configurations for JPA
        } else {
            // Example: Modify the mapping (for demo purposes, this is a placeholder)
            System.out.println("Mapping field to database column: " + columnName);
            // In real scenarios, you might dynamically create queries or update field definitions
        }

        // Other dynamic adjustments can include creating JPQL criteria based on field values
    }
}

Look at this code eye bleeding.

In my opinion DDD and hexagonal architecture, onion architecture all have something in common no anemic domain models, keep your domain classes POJOs. This is the future and JPA needs to be with it.

So JPA needs to make a way to pickup simple POJO classes to stay cool.

My suggestion would be make a elegant way to pick up custom annotated classes a.k.a. option 3 improved. If it is possible I would prefer a JPA which works when i tell it which annotations to work with it in java code.

Wouldn't be great if we annotated our model classes with custom @Model annotation then tell JPA work with this annotation in single line?

gavinking commented 1 week ago

Domain Driven Design and Hexagonal architecture

These are not real things; they have no basis in math, nor in empirical science.

When hand-wavy "patterns" or "best practices" come into conflict with writing clear, DRY, redundancy-free, readable code, it's the patterns and best practices which must yield. Because code is actually real stuff which can be evaluated via relatively objective metrics.

Wouldn't be great if we annotated our model classes with custom @Model annotation then tell JPA work with this annotation in single line?

No, it would not. We now have literally decades of practical experience telling us that the right place for object/relational mappings is on the entity classes.

When JPA 1.0 was first proposed, there was indeed an active debate in the community around the appropriateness of the use of annotations on the entity class to indicate persistence semantics and object/relational mappings. Full disclosure: I was strongly on the side of "annotations on the entity class". This debate is long, long over. The success of the annotation-based model is now entirely clear.

There's no way we should be messing with such a successful aspect of the most successful persistence API which has ever existed.

Sorry.

urbandroid commented 1 week ago

Hello @gavinking , how about giving people another choice so that they can make their own decisions. Right now working with said architectures and using JPA is a pain point. Is it something hard to implement or we are categorically against it?

beikov commented 6 days ago
  1. Create duplicate entity classes in infra module like this:

Ideally, your domain would be different from your entity, so there is no "duplicate" class, but surely a sort of class-per-entity pattern which may or may not be an issue for you. I personally think it's totally fine to have a bunch of DTOs per entity.

Look at those ugly toDomain and toEntity methods.

It's your interpretation of the architectural constraints that force you to write these methods. If you're willing to model your domain as entities, you don't need this. You also have to ask yourself, what is the purpose of these constraints if all you're doing is to essentially duplicate code and map things 1:1. One of the main ideas of having a separate domain model that abstracts over the entity model is to gain freedom in both representations i.e. you can change one without necessarily having to change the other (unless necessary for new use cases ofc). But if you're not taking advantage of this freedom, maybe because you're building a simple CRUD app, then the architectural constraints will be a burden.

There are libraries out there which can help you to build a domain abstraction on top of an entity model, but that is IMO out of scope for JPA. I actually created a library which can be used for domain model abstractions that is called Blaze-Persistence Entity-Views. There are talks in the Jakarta Data community about adding "projections" which is the read aspect of the abstraction story. Blaze-Persistence Entity-Views goes one step further and also allows "writable projections". Maybe if there is enough interest in the community, this could emerge as separate specification. There is definitely interest in specifying the read aspect and potentially support this also on top of other non-JPA technologies.

gavinking commented 6 days ago

I mean, we already said we're going to investigate the question of projections/DTOs/Entity Views in the context of the Jakarta Data spec. But I wouldn't say that this is a thing that JPA should address. JPA is already very big!

urbandroid commented 6 days ago

@beikov

Ideally, your domain would be different from your entity, so there is no "duplicate" class, but surely a sort of class-per-entity pattern which may or may not be an issue for you. I personally think it's totally fine to have a bunch of DTOs per entity.

you are right i shouldn't be bothered by this. And for simple CRUD apps xml does the job but having ability to tell JPA to pickup @Model like annotations would still be great for this kind of apps.

gavinking commented 6 days ago

We have no idea what a "@Model-like" annotation is, nor what it does, because you haven't defined it.

urbandroid commented 6 days ago

It is a custom annotation provided by the client in model/domain module and in some way we tell JPA to scan this annotation and JPA treats these annotated classes as entities and if client wants extra configuration either implements 1-1 @EntityConfiguration annotated configuration classes or orm.xml files for the parts that derails from conventions.

Convention is simple and things already part of JPA like every field is column, named with field name, and conventions that doesn't exist like every Collection field is relationship if there is counterpart relationship in another class then its a Many to Many relationship, first field is always ID so on.

This way if client wants extra configuration that derails from convention can provide mapped @EntityConfiguration class like @EntityConfiguration(model=MappedModel.class) or something like that.

For example :

public class User {
    private Long id;
    private String name;
    private String email;

    // getters and setters
}

And then configuration like this

@EntityConfiguration(model=User.class)
public class UserEntityConfiguration {

    @Column(unique = true)
     private String email;

     @Version
     private Long version;

    // getters and setters
} 

And if there is no configuration class then JPA treats @Model annotated class as if like this class exists:

@Entity 
 public class User {

    @Id
    private Long id;

    private String name;

    private String email;

    // getters and setters
}

It is just a thought. There may be better ways to do it. This is what i can think of, i bet you guys do something lot better than this.

gavinking commented 6 days ago

So:

  1. if everything about the persistence semantics of an entity is defaultable, @Model is exactly like @Entity, but
  2. otherwise, in the overwhelmingly common case where not everything us defaultable, it's much worse, and I have to write a whole new class, a partial dupe of my entity class, on which to place my mapping annotations, instead of just sticking them on the entity class where they belong.

I don't see how this improves my program. It violates the very most basic and most fundamental principle of software engineering, which is DRY. It makes the code harder to navigate, harder to understand, and harder to refactor.

Now, OK, sure, you're arguing that you can make case 2 somewhat less common via additional:

conventions that doesn't exist like every Collection field is relationship if there is counterpart relationship in another class then its a Many to Many relationship, first field is always ID so on.

The problem with this is that if we thought that defaulting such stuff was a good idea, then we would have already built such defaulting into JPA. But we don't think that. Instead, we decided, after much reflection, that some things should be explicit, and that defaulting them would cause problems.