I recently created an API for a client that was nothing more than simple CRUD, with a few extra endpoints thrown in where some additional behavior was necessary. The API was a pretty simple mechanism for their inventory management. It exposes CRUD methods for its domain; Item, Vendor, Location, etc.
As I was working through creating the Controller/Service/Repository layer for each model, I started to notice that each Controller, each Service, and each Repository were all exactly the same. I realized pretty quickly that I didn’t want to repeat this boilerplate logic, so here’s my solution to handle this generically.
Before I get into that, though, I want to talk a bit about the elephant in the room…why not just use Spring Data Rest? Spring Data Rest will expose your repository interfaces directly as REST endpoints. It’s beautiful, but it also requires that you use HATEOAS, and since I’m not using this on my client, I don’t want to include it in the API.
So, let’s start with some requirements. The only requirement I have is that for any model in my domain, I want to define a single Controller
, Service
, and Repository
that will expose CRUD methods without having to reimplement them for each entity. The behavior I want are
- Create a new entity
- Update the entity
- Get a List of the entity
- Get the entity by its ID
- Delete the entity
Spring already has done a third of this work for me by creating the generic Repository
interface, it’s parameterized with the type of the entity class and the type of the ID for that entity. So, let’s work this solution from the bottom up and see what our model and repository look like. For the sake of this tutorial, we’ll follow a single entity, Cage
We’ll define our Entity object, which will extend a base class that will include the fields shared across the entities.
@Document("Cage")
@Getter
@Setter
@ToString(of = "value")
public class Cage extends BaseMongoDocument {
private String value;
}
And, just to give you a look at the base class, it’s
@Getter
@Setter
public abstract class BaseMongoDocument {
@Id
@Field("_id")
private ObjectId id;
}
So, now I’ve got an entity class that I can perform operations on. Let’s create the repository that will implement those CRUD operations.
public interface CageRepository extends MongoRepository<Cage, ObjectId> {
}
In this application, I’m using Mongo so I’m going to extend MongoRepository from spring-boot-starter-data-mongodb
. So now I have an entity and the repository to operate on it, let’s create a generic service class that will expose the CRUD methods. We’re going to add a couple of type parameters to it, T and ID. T will be the entity type, and ID will be the type of the Id
column of that entity. The beauty of this is that Spring can now inject our Repository that’s of the same type and id. Here’s the implementation for the BaseService, that contains methods to find, find all as a Page, create, update (implemented with JsonPatch), and delete.
public abstract class BaseService<T, ID> {
private final MongoRepository<T, ID> repository;
private final ObjectMapper objectMapper;
public BaseService(MongoRepository<T, ID> repository, ObjectMapper objectMapper) {
this.repository = repository;
this.objectMapper = objectMapper;
}
public void delete(ID id) {
repository.deleteById(id);
}
public T findById(ID id) {
return repository.findById(id)
.orElseThrow(() ->
new EntityNotFoundException("Unable to find record with ID of " + id)
);
}
public Page<T> find(Pageable pageable) {
return repository.findAll(pageable);
}
public T create(T entity) {
return repository.save(entity);
}
public T update(ID id, JsonPatch jsonPatch)
throws JsonPatchException, JsonProcessingException {
T entityToPatch = findById(id);
JsonNode patched = jsonPatch.apply(objectMapper.convertValue(entityToPatch, JsonNode.class));
T patchedEntity = objectMapper.treeToValue(patched, (Class<T>) entityToPatch.getClass());
return repository.save(patchedEntity);
}
}
Now, to go from the abstract to the concrete, using our Cage
class, let’s create our CageService. Here we define our CageService, which extends BaseService with the parameters of Cage
and ObjectId
. We simply create a constructor that contains a Repository that conforms to <Cage, ObjectId>
, and an ObjectMapper
which comes from our SpringContext.
@Service
public class CageService extends BaseService<Cage, ObjectId> {
public CageService(CageRepository cageRepository, ObjectMapper objectMapper) {
super(cageRepository, objectMapper);
}
}
Now we have a generic Service layer and can work to create a similar generic Controller layer. We’ll start by defining a simple interface that prototypes our CRUD methods. This also defines the generic parameters for the entity type and Id type, and adds a third generic type for a DTO of the entity. The DTO is a functional interface that provides a single method, toEntity()
. It provides a mechanism for passing the DTO to the create method, and in the future could also be used to return from our find methods if we choose.
public interface CrudControllerMethods<T, E extends DTO<T>, ID> {
AngularPage<T> delete(ID id, HttpSession session);
AngularPage<T> find(Pageable pageable, HttpSession session);
T findById(ID id);
AngularPage<T> create(E dto, HttpSession session);
T patch(ID id, JsonPatch jsonPatch) throws JsonPatchException, JsonProcessingException;
}
The DTO interface looks like this
public interface DTO<T> {
T toEntity();
}
and it’s implemented on the entities like this
@Getter
@Setter
public class CageDTO implements DTO<Cage> {
private String value;
@Override
public Cage toEntity() {
Cage cage = new Cage();
cage.setValue(value);
return cage;
}
}
Now we have an interface that defines our CRUD methods, a DTO that can be used to convey the data, and an entity for persisting. Let’s create a BaseController
that implements the CrudControllerMethods
. The BaseController will be abstract and carry the same type parameters as the CrudControllerMethods. The BaseService that satisfies <T, ID>
will be injected by Spring. This contains all of the required logic to perform all CRUD operations for type T
, identified by ID
, and transferred via E
public abstract class BaseController<T, E extends DTO<T>, ID> implements CrudControllerMethods<T, E, ID> {
private final BaseService<T, ID> baseService;
public BaseController(BaseService<T, ID> service) {
this.baseService = service;
}
@DeleteMapping("/{id}")
public AngularPage<T> delete(@PathVariable ID id, HttpSession session) {
baseService.delete(id);
int pageSize = getPageSizeFromSession(session);
return find(Pageable.ofSize(pageSize), session);
}
@GetMapping("/{id}")
public T findById(@PathVariable ID id) {
return baseService.findById(id);
}
@GetMapping("/find")
public AngularPage<T> find(Pageable pageable, HttpSession session) {
session.setAttribute("pageSize", pageable.getPageSize());
return new AngularPage<>(baseService.find(pageable));
}
@PostMapping
public AngularPage<T> create(@RequestBody E dto, HttpSession session) {
baseService.create(dto.toEntity());
int pageSize = getPageSizeFromSession(session);
return find(PageRequest.of(0, pageSize), session);
}
@PatchMapping("/{id}")
public T patch(@PathVariable ID id, @RequestBody JsonPatch jsonPatch) throws JsonPatchException, JsonProcessingException {
return baseService.update(id, jsonPatch);
}
private int getPageSizeFromSession(HttpSession session) {
return (int) Optional.ofNullable(session.getAttribute("pageSize")).orElse(10);
}
}
To put this all together, we’re going to define a Controller
for the Cage
entity that we’ve been working with.
@RestController
@RequestMapping("/cage")
public class CageController extends BaseController<Cage, CageDTO, ObjectId> {
public CageController(CageService cageService) {
super(cageService);
}
}
Running this now I’ll have mappings for
- GET /cage/{id}
- DELETE /cage/{id}
- GET /cage/find
- POST /cage which takes the CageDTO as the RequestBody
- PATCH /cage/{id}
Implementing a new type is now a trivial process and I don’t need to duplicate all of the boilerplate logic of the service and controller. Here’s the steps to add an entity.
- Define the Entity
@Document("Vendor")
@Getter
@Setter
@ToString(of = "value")
public class Vendor extends BaseMongoDocument {
private String value;
}
2. Create the DTO
@Getter
@Setter
public class VendorDTO implements DTO<Vendor> {
private String value;
@Override
public Vendor toEntity() {
Vendor vendor = new Vendor();
vendor.setValue(value);
return vendor;
}
}
3. Create the Repository
public interface VendorRepository extends MongoRepository<Vendor, ObjectId> {
}
4. Create the Service layer
@Service
public class VendorService extends BaseService<Vendor, ObjectId> {
public VendorService(VendorRepository vendorRepository, ObjectMapper objectMapper) {
super(vendorRepository, objectMapper);
}
}
5. Create the Controller layer
@RestController
@RequestMapping("/vendor")
public class VendorController extends BaseController<Vendor, VendorDTO, ObjectId> {
public VendorController(VendorService vendorService) {
super(vendorService);
}
}
And just like that, I have all the CRUD operations now for the Vendor entity.