Search code examples
javaspringspring-bootjpaspring-data

Spring Boot - combine nested resources for single API calls


Suppose you have two resources, User and Account. They are stored in separate tables but have a one-to-one relationship, and all API calls should work with them both together. For example a POST request to create a User with an Account would send this data:

{ "name" : "Joe Bloggs", "account" : { "title" : "My Account" }}

to /users rather than have multiple controllers with separate routes like users/1/account. This is because I need the User object to be just one, regardless of how it is stored internally.

Let's say I create these Entity classes

@Table(name = "user")
public class User {

  @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
  @NotNull
  Account account;

  @Column(name = "name")
  String name;
}

@Table(name = "account")
public class Account {

  @OneToOne(cascade = CascadeType.ALL, optional = false, fetch = FetchType.LAZY)
  @JoinColumn(name = "user_id", nullable = false)
  @NotNull
  User user;

  @Column(name = "title")
  String title;
}

The problem is when I make that POST request above, it throws an error because user_id is missing, since that's required for the join, but I cannot send the user_id because the User has not yet been created.

Is there a way to create both entities in a single API call?


Solution

  • Since it is a bi-directional relation, and one-to-one is a mandatory in this case, you should persist a user entity and only then persist an account. And one more thing isn't clear here is db schema. What are the pk's of entities? I coukd offer to use user.id as a single identity for both of tables. If so, entities would be as: User(id, name), Account(user_id, title) and its entities are:

    @Table(name = "account")
    @Entity
    public class Account {
      @Id
      @Column(name = "user_id", insertable = false, updatable = false)
      private Long id;
    
      @OneToOne(mappedBy = "account", fetch = FetchType.LAZY, optional = false)
      @MapsId
      private User user;
    
      @Column(name = "title")
      private String title;
    }
    
    @Table(name = "user")
    @Entity
    public class User {
    
      @Id
      @GeneratedValue(strategy = GenerationType.AUTO)
      private Long id;;
    
      @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
      @JoinColumn(name = "id", referencedColumnName = "user_id")
      private Account account;
    
      @Column(name = "name")
      private String name;
    }
    

    at the service layer you must save them consistently:

    @Transactional
    public void save(User userModel) {
      Account account = user.getAccount();
      user.setAccount(null);
      userRepository.save(user);
      account.setUser(user);
      accountRepository.save(account);
    }
    

    it will be done within a single transaction. But you must save the user first, coz the user_id is a PK of the account table. @MapsId shows that user's id is used as an account's identity

    Another case is when account's id is stored in the user table: User(id, name, account_id), Account(id, title) and entities are:

    @Table(name = "account")
    @Entity
    public class Account {
      @Id
      @Column(name = "id")
      @GeneratedValue(strategy = GenerationType.AUTO)
      private Long id;
    
      @OneToOne(cascade = CascadeType.ALL, mappedBy = "account")
      private User user;
    
      @Column(name = "title")
      private String title;
    }
    
    @Table(name = "user")
    @Entity
    public class User {
    
      @Id
      @GeneratedValue(strategy = GenerationType.AUTO)
      private Long id;
    
      @Column(name = "account_id", insertable = false, updatable = false)
      private Long accountId;
    
      @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
      @JoinColumn(name = "account_id", referencedColumnName = "id", unique = true)
      private Account account;
    
      @Column(name = "name")
      private String name;
    }
    

    in this case an Account entity will be implisitly persisted while User entity saving:

    @Transactional
    public void save(User userModel) {
      userRepository.save(user);
    }
    

    will cause an insertion into the both of tables. Since cascade and orphane are declared, for deletion would be enough to set null for the account reference:

    user.setAccount(null);
    userRepository.save(user);