How to Eliminate Hundreds of CRUD Endpoints with a Single Generic Controller

The article presents a technique that uses a generic AggregateController, dynamic model‑name routing, and a lightweight mapping container to share CRUD logic across dozens of tables, reducing hundreds of API methods to just two controllers while keeping the codebase clean and extensible.

Architect's Journey
Architect's Journey
Architect's Journey
How to Eliminate Hundreds of CRUD Endpoints with a Single Generic Controller

Problem

The rental platform contains 36 database tables. Using a conventional controller for each table would require 36 × 4 = 144 CRUD API endpoints per side (client and admin), i.e., 288 endpoints in total.

Requirements for a shared CRUD controller

Different tables must be isolated by model name.

The model name must be resolvable to the corresponding model class.

From the model class the appropriate repository must be located.

For query operations, request parameters must be convertible into a query object.

For write operations, request parameters must be convertible into a model instance.

AggregateController interface

A generic controller interface provides default CRUD methods that operate on a model identified by the path variable {modelName}. Each method obtains the model class via getModelClass(modelName), converts the incoming Map to a Query or Model using BeanKit.ofMap, and delegates to the repository obtained with BaseRepository.of(modelClass).

public interface AggregateController {
    @PostMapping("/{modelName}/page")
    default Page<Model> postPage(@PathVariable("modelName") String modelName,
                                 @RequestBody Map<String, Object> query) {
        return convertQuery(getModelClass(modelName), query).page();
    }

    @GetMapping("/{modelName}/page")
    default Page<Model> getPage(@PathVariable("modelName") String modelName,
                                Map<String, Object> query) {
        return convertQuery(getModelClass(modelName), query).page();
    }

    @PostMapping("/{modelName}/list")
    default List<Model> postList(@PathVariable("modelName") String modelName,
                                 @RequestBody Map<String, Object> query) {
        return convertQuery(getModelClass(modelName), query).list();
    }

    @GetMapping("/{modelName}/list")
    default List<Model> getList(@PathVariable("modelName") String modelName,
                                Map<String, Object> query) {
        return convertQuery(getModelClass(modelName), query).list();
    }

    @GetMapping("/{modelName}/detail")
    default Model detail(@PathVariable("modelName") String modelName,
                        Map<String, Object> query) {
        return convertQuery(getModelClass(modelName), query).first();
    }

    @GetMapping("/{modelName}/detail/{id}")
    default Model detail(@PathVariable("modelName") String modelName,
                        @PathVariable("id") String id) {
        return BaseRepository.of(getModelClass(modelName)).get(id);
    }

    @PostMapping({"/{modelName}/save", "/{modelName}/create"})
    default Model save(@PathVariable("modelName") String modelName,
                      @RequestBody Map<String, Object> body) {
        Model model = convertModel(getModelClass(modelName), body);
        model.save();
        return model;
    }

    @PostMapping("/{modelName}/saveBatch")
    default void saveBatch(@PathVariable("modelName") String modelName,
                           @RequestBody List<Map<String, Object>> params) {
        Class<Model> modelClass = getModelClass(modelName);
        BaseRepository.of(modelClass).save(convertModels(modelClass, params));
    }

    @PostMapping({"/{modelName}/update", "/{modelName}/modify"})
    default void update(@PathVariable("modelName") String modelName,
                        @RequestBody Map<String, Object> body) {
        convertModel(getModelClass(modelName), body).update();
    }

    @PostMapping({"/{modelName}/delete/{id}", "/{modelName}/remove/{id}"})
    default void delete(@PathVariable("modelName") String modelName,
                        @PathVariable("id") String id) {
        BaseRepository.of(getModelClass(modelName)).delete(id);
    }

    static Class<Model> getModelClass(String modelName) {
        Class<Model> cls = MappingKit.get("MODEL_NAME", modelName);
        BizAssert.notNull(cls, "Model: {} not found", modelName);
        return cls;
    }

    static Query convertQuery(Class<Model> modelClass, Map<String, Object> map) {
        Class<Query> qCls = MappingKit.get("MODEL_QUERY", modelClass);
        BizAssert.notNull(qCls, "Query not found");
        return BeanKit.ofMap(map, qCls);
    }

    static Model convertModel(Class<Model> modelClass, Map<String, Object> map) {
        return BeanKit.ofMap(map, modelClass);
    }
}

Mapping infrastructure

The infrastructure consists of a BaseRepository interface that holds a static ConcurrentHashMap mapping model or query classes to repository implementations, and an abstract BaseRepositoryImpl that registers these mappings during construction.

public interface BaseRepository<M extends Model, Q extends Query> {
    Map<Class<?>, Class<?>> REPOSITORY_MAPPINGS = new ConcurrentHashMap<>();

    static <R extends BaseRepository> void inject(Class<?> mappingClass,
                                                Class<R> repositoryClass) {
        REPOSITORY_MAPPINGS.put(mappingClass, repositoryClass);
    }
    // CRUD methods are omitted for brevity
}

The implementation class discovers generic type arguments via ReflectionKit, then registers the following relationships:

Model class → repository implementation

Query class → repository implementation

Model class ↔ PO class

Model class ↔ Query class

Model name (camel‑cased) → Model class

public abstract class BaseRepositoryImpl<MP extends BaseMapper<P>, M extends Model,
                                   P, Q extends Query>
        implements BaseRepository<M, Q>, Serializable {

    public BaseRepositoryImpl() {
        Class<M> modelClass = (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), 1);
        Class<P> poClass = (Class<P>) ReflectionKit.getSuperClassGenericType(this.getClass(), 2);
        Class<Q> queryClass = (Class<Q>) ReflectionKit.getSuperClassGenericType(this.getClass(), 3);

        BaseRepository.inject(modelClass, this.getClass());
        BaseRepository.inject(queryClass, this.getClass());

        MappingKit.map("MODEL_PO", modelClass, poClass);
        MappingKit.map("MODEL_PO", poClass, modelClass);
        MappingKit.map("MODEL_QUERY", modelClass, queryClass);
        MappingKit.map("MODEL_QUERY", queryClass, modelClass);

        String modelName = modelClass.getSimpleName();
        modelName = modelName.substring(0, 1).toLowerCase() + modelName.substring(1);
        MappingKit.map("MODEL_NAME", modelName, modelClass);
    }
    // CRUD methods are omitted for brevity
}

MappingKit utility

A lightweight bean container stores mappings per business domain (e.g., "MODEL_NAME", "MODEL_QUERY").

@UtilityClass
public final class MappingKit {
    private final Map<String, Map<Object, Object>> BEAN_MAPPINGS = new ConcurrentHashMap<>();

    public <K, V> void map(String biz, K key, V value) {
        BEAN_MAPPINGS.computeIfAbsent(biz, k -> new ConcurrentHashMap<>())
                     .put(key, value);
    }

    public <K, V> V get(String biz, K key) {
        Map<Object, Object> map = BEAN_MAPPINGS.get(biz);
        if (map == null) return null;
        return (V) map.get(key);
    }
}

Concrete controllers

Two endpoint groups—client and admin—are realized by simple classes that implement AggregateController and are annotated with Spring MVC annotations. No additional CRUD methods are required.

@RestController
@RequestMapping("/client")
public class ClientController implements AggregateController {
}

@RestController
@RequestMapping("/admin")
public class AdminController implements AggregateController {
}

Result

The generic controller and mapping infrastructure collapse the original 288 CRUD endpoints into two concrete controllers, eliminating repetitive code while preserving full CRUD functionality for all 36 tables.

Reference implementation

The complete source code is available in the open‑source D3Boot framework at the following URL (plain text): https://gitee.com/jensvn/d3boot

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaCRUDMybatisPlusSpringMVCGenericController
Architect's Journey
Written by

Architect's Journey

E‑commerce, SaaS, AI architect; DDD enthusiast; SKILL enthusiast

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.