Send your request Join Sii

Not long ago, an article on the Internet caught my attention. It described an intriguing approach to combining Generics and Spring capabilities. It reminded me of the method that I tested in my private programming experience. I want to present it here and show how we can use the Java Generics mechanism for building an abstract architecture.

As a result, we can obtain a system where adding a new entity requires just initializing one bean in each part of the repository-service-controller trio. I assembled the project using Spring Initializr, adding JPA, Web, and H2 frameworks with Gradle and Spring Boot 3.1.3.

To begin with, let’s examine the classic scenario of transferring data between the controller and the repository without any additional logic. If you are eager to dive into the core of this approach, feel free to scroll down to the abstract version. However, I still recommend reading the entire article.

Classic Version

We will not be distracted by essential matters like validation, DTO mapping, and other details. Let’s concentrate solely on the differences between classic and abstract approaches to constructing all layers of the application.

Domain

This section defines the User class, which represents a domain entity. It is annotated with @Entity, indicating that it is a JPA entity.

@Entity
public class User implements Serializable {

    private Long id;
    private String name;
    private String phone;

   //getters, setters, equals, hashcode, toString
}

Repository

Here, the UserRepository interface is defined, extending the CrudRepository interface for the User entity.

@Repository
public interface UserRepository extends CrudRepository<User, Long> {
}

Service Interface

The UserService interface is introduced, defining a contract for working with user-related operations.

public interface UserService {

    Optional<User> save(User user);
}

Service

The UserServiceImpl class implements the UserService interface. It is annotated with @Service, indicating that it is a Spring service component.

@Service
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    @Autowired
    public UserServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public Optional<User> save(User user) {
        return Optional.of(userRepository.save(user));
    }
}

Controller

In this section, the UserController class is defined as a Spring REST controller. It is responsible for handling HTTP requests related to users. The class has a constructor that takes a UserService as a parameter, and it uses dependency injection to initialize it.

@RestController
@RequestMapping("/user")
public class UserController {

    private final UserService service;

    @Autowired
    public UserController(UserService service) {
        this.service = service;
    }

    @PostMapping
    public ResponseEntity<User> save(@RequestBody User user) {
        return service.save(user).map(u -> new ResponseEntity<>(u, HttpStatus.OK)).orElseThrow(() -> new UserException(
String.format(ErrorType.USER_NOT_SAVED.getDescription(), user.toString())));
    }
}

Adding another entity

At the end, we have a set of dependent classes that will assist us in operating the User entity at the CRUD level. Let’s say we need to add another entity, for example, Car. We will not map them to each other at the entity level (if you wish, you can create mappings). To begin, let’s create the entity.

@Entity
public class Car implements Serializable {

    private Long id;
    private String brand;
    private String model;

    @Id
    @GeneratedValue
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }
//getters, setters, equals, hashcode, toString
}

Then, we need to create the repository.

public interface CarRepository extends CrudRepository<Car, Long> {}

Next, the service.

public interface CarService {

    Optional<Car> save(Car car);

    List<Car> saveAll(List<Car> cars);

    Optional<Car> update(Car car);

    Optional<Car> get(Long id);

    List<Car> getAll();

    Boolean deleteById(Long id);

    Boolean deleteAll();
}

Then, the service implementation, Controller, etc.Yes, you can simply copy and paste the same methods (since they are universal) from the User class, then replace “User” with “Car”, and repeat the same process for the implementation and the controller. Then, another entity is in the queue, and more and more keep appearing.

Usually, one can become exhausted even after the second entity. Creating a utility architecture for a couple dozen entities (copy-pasting, replacing entity names, making mistakes here and there) leads to struggles caused by any monotonous work. Try to write twenty entities on your free time, and you’ll understand what I mean.

Abstractions based on type parameters

At one point, while delving into generics and type parameters, it dawned on me that this process could be approached differently, providing us with the opportunity to write a smaller amount of code.

Abstractions based on type parameters — the essence of this strategy is to extract all the logic into an abstraction, bind that abstraction to type parameters of an interface, and inject other beans into it. That’s it. No logic in the beans themselves — only the injection of other beans. This approach involves writing the architecture and logic once, eliminating duplication when adding new entities.

Let’s begin with the cornerstone of our abstraction: the abstract entity. From this entity, the chain of abstract dependencies will commence. All entities share at least one common field, usually more: the ID. We will extract this field into a separate abstract entity and inherit it for both User and Car.

AbstractEntity

@MappedSuperclass
public abstract class AbstractEntity implements Serializable {

    private Long id;

    @Id
    @GeneratedValue
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }
}

Do not forget to annotate the abstraction with @MappedSuperclass. Hibernate should also recognize it as an abstraction.

User

@Entity
public class User extends AbstractEntity {

    private String name;
    private String phone;
   
    //...
}

With Car, the same approach applies accordingly.

In each layer, apart from the beans, we will have one interface with type parameters and one abstract class with logic, except for the repository – thanks to the specificity of Spring Data JPA, things will be much simpler here.The first thing we will need in the repository is a common repository.

CommonRepository

@NoRepositoryBean
public interface CommonRepository<E extends AbstractEntity> extends CrudRepository<E, Long> {}

In this repository, we establish common rules for the entire chain: all entities participating in it will inherit from the abstract one. We use the @NoRepositoryBean annotation here to indicate that we do not want Spring to create an instance of this repository. Therefore, a Bean of this repo will not be created during the context creation.

Next, for each entity, we need to write its own repository interface where we specify the exact entity with which this repository-service-controller chain will work.

UserRepository

@Repository
public interface UserRepository extends CommonRepository<User> {}

With this, thanks to the features of Spring Data JPA, the repository configuration is now complete, and everything will work as expected. Next comes the service. We need to create a common interface, abstraction, and bean.

CommonService

public interface CommonService<E extends AbstractEntity> {
    Optional<E> save(E entity);
//... other methods
}

AbstractService

public abstract class AbstractService<E extends AbstractEntity, R extends CommonRepository<E>> implements CommonService<E> {
    protected final R repository;

    @Autowired
    public AbstractService(R repository) {
        this.repository = repository;  }
//… other methods overridden from interface }

In this step, we override all methods and also create a parameterized constructor for the future repository, which we will override in the bean. In this way, we are already using a repository that we have not defined yet. We do not know which entity will be processed in this abstraction and which repository we will need.

UserService

@Service
public class UserService extends AbstractService<User, UserRepository> {

    public UserService(UserRepository repository) {
        super(repository);
    }
}

In the bean, we perform the final step — explicitly defining the necessary repository that will be called in the constructor of the abstraction. And that’s it.

Using the interface and abstraction, we have created a mainline through which all entities will pass. In the bean, we create the branching point from which we will connect the desired entity to the mainline.The controller follows a similar principle: we create an interface, abstraction, and bean for it.

CommonController

public interface CommonController<E extends AbstractEntity> {

    @PostMapping
    ResponseEntity<E> save(@RequestBody E entity);

// other methods }

AbstractController

public abstract class AbstractController<E extends AbstractEntity, S extends CommonService<E>>
        implements CommonController<E> {

    private final S service;

    @Autowired
    protected AbstractController(S service) {
        this.service = service;
    }

    @Override
    public ResponseEntity<E> save(@RequestBody E entity) {
        return service.save(entity).map(ResponseEntity::ok)
                .orElseThrow(() -> new SampleException(
                        String.format(ErrorType.ENTITY_NOT_SAVED.getDescription(), entity.toString())
                ));
    }

// others methods
}

UserController

@RestController
@RequestMapping("/user")
public class UserController extends AbstractController<User, UserService> {

    public UserController(UserService service) {
        super(service);
    }
}

And that’s it: this is the entire structure. What’s next?

Now, let’s imagine that we have a new entity. We have already inherited it from AbstractEntity, and we need to establish the same chain for it. This will take us a minute. No copy-pasting or corrections required. Let’s consider the Car entity, which is already inherited from AbstractEntity.

CarRepository

@Repository
public interface CarRepository extends CommonRepository<Car> {}

CarService

@Service
public class CarService extends AbstractService<Car, CarRepository> {
    public CarService(CarRepository repository) {
        super(repository); }}

CarController

@RestController
@RequestMapping("/car")
public class CarController extends AbstractController<Car, CarService> {

    public CarController(CarService service) {
        super(service);
    }
}

As we can see, duplicating the same logic involves simply adding a bean. There’s no need to rewrite the logic for each bean with parameter changes and signatures. These aspects are written once and work in every subsequent case.

Conclusion

Of course, the example described is a kind of situation in vacuum where CRUD for each entity share the same logic. Such situations rarely occur — there will still be a need to override some methods in the bean or add new ones based on specific entity processing requirements. It’s good if around 50% of the total CRUD methods remain in the abstraction. This would be beneficial because the more unnecessary code we manually generate, the more time we spend on monotonous work, and the higher the risk of errors or typos.

However, it’s important to acknowledge that this strategy can lead to code that may appear cumbersome and unreadable in enterprise-level projects. Therefore, I can recommend this strategy only for developing some smaller projects. It allows you to experience its pros and cons of this strategy and consider how it might be enhanced and made more suitable for larger, more complex projects.

I hope you found this article helpful and interesting. Thank you for your attention. You can find the examples from this article on the public repository on GitHub.

***

If you’re curious about spring, also take a peek at other articles by our experts.

5/5 ( votes: 4)
Rating:
5/5 ( votes: 4)
Author
Avatar
Oleksandr Turchanovskyi

He is a software developer with, in summary, 3.5 years of professional experience. He’s working in Sii on the position Software Engineer. Oleksandr’s main course is improving himself in Java + Spring tech stack. He’s interested in building systems from scratch and resolving non-trivial tasks

Leave a comment

Your email address will not be published. Required fields are marked *

You might also like

More articles

Don't miss out

Subscribe to our blog and receive information about the latest posts.

Get an offer

If you have any questions or would like to learn more about our offer, feel free to contact us.

Send your request Send your request

Natalia Competency Center Director

Get an offer

Join Sii

Find the job that's right for you. Check out open positions and apply.

Apply Apply

Paweł Process Owner

Join Sii

SUBMIT

Ta treść jest dostępna tylko w jednej wersji językowej.
Nastąpi przekierowanie do strony głównej.

Czy chcesz opuścić tę stronę?