Backend Development 24 min read

Refactoring a Monolithic System: Database Splitting, Application Decomposition, and Data‑Access Control

This article describes a step‑by‑step migration of a large monolithic Java application into vertically‑split micro‑services, covering database sharding into nine business databases, multi‑datasource and custom transaction implementations, application splitting, routing, RPC generation, and the measures taken to ensure data safety and low‑risk rollout.

Dada Group Technology
Dada Group Technology
Dada Group Technology
Refactoring a Monolithic System: Database Splitting, Application Decomposition, and Data‑Access Control

Background

The department maintained an old monolithic system with over 300 interfaces and more than 200 tables in a single database, causing single‑point failures, performance bottlenecks, and high complexity. Rapid business growth highlighted these issues, prompting a three‑stage micro‑service transformation: database splitting, application splitting, and data‑access permission consolidation.

Database Splitting

The monolithic database suffered from lack of business isolation, slow SQL affecting the whole system, and heavy read/write pressure.

Database Refactoring

We plan to split the database into nine business‑specific databases. Table‑to‑new‑database mappings are prepared and sent to operations, which use binlog filtering to sync the relevant tables to the new databases, ensuring each new database contains only its own business tables.

Code Refactoring Plan

After splitting, interfaces that previously accessed multiple tables in one database may now need to access multiple databases. The main problems are data‑source selection (multiple mappers belonging to different databases) and transaction handling (single‑transaction annotations cannot span multiple databases).

Refactoring points:

6 interfaces write to multiple databases within one transaction – need data‑source and distributed‑transaction handling.

50+ interfaces write to multiple databases but not within the same transaction – need data‑source handling only.

200+ interfaces read from multiple databases or read from one and write to another – need data‑source handling only.

8 interfaces perform joint queries across multiple databases – require logical code changes.

We use an aspect tool to capture entry points and table‑call relationships, identifying interfaces that operate on tables belonging to different business databases.

Solution 1

Extract mappers that operate on different databases into separate services, adding data‑source and transaction annotations to each. This approach involves many code changes and high risk.

Solution 2

Move the data‑source annotation to the mapper level and use a custom transaction implementation to handle transactions. This reduces code‑logic changes but introduces two new issues:

When the transaction is started at the service layer, the data‑source connection is not yet obtained because the actual data‑source is defined on the mapper, causing errors that can be solved by the multi‑datasource component’s default data‑source feature.

MyBatis’s default transaction caches the connection; after the first use, subsequent mapper‑level data‑source switches do not obtain a new connection, leading to missing‑table errors. A custom transaction class was developed to address this.

Below are brief explanations of the two components introduced in the solution.

Multi‑DataSource Component

The component enables a single application to connect to multiple data sources. It initializes all connections at startup via configuration, and an aspect switches the current data source based on annotations. When a call involves several data sources, a stack‑based mechanism resolves nested switches.

/**
 * Aspect method
 */
public Object switchDataSourceAroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
    // Get the data source name
    String dsName = getDataSourceName(pjp);
    boolean dataSourceSwitched = false;
    if (StringUtils.isNotEmpty(dsName) && !StringUtils.equals(dsName, StackRoutingDataSource.getCurrentTargetKey())) {
        // see next code segment
        StackRoutingDataSource.setTargetDs(dsName);
        dataSourceSwitched = true;
    }
    try {
        // execute the advised method
        return pjp.proceed();
    } catch (Throwable e) {
        throw e;
    } finally {
        if (dataSourceSwitched) {
            StackRoutingDataSource.clear();
        }
    }
}
public static void setTargetDs(String dbName) {
    if (dbName == null) {
        throw new NullPointerException();
    }
    if (contextHolder.get() == null) {
        contextHolder.set(new Stack
());
    }
    contextHolder.get().push(dbName);
    log.debug("set current datasource is " + dbName);
}

StackRoutingDataSource extends Spring’s AbstractRoutingDataSource, which provides the core routing logic.

Custom Transaction Implementation

MyBatis’s default SpringManagedTransaction caches a single connection, preventing cross‑database operations. We created MultiDataSourceManagedTransaction, which maintains a map of connections per data source and ensures each database gets its own connection when needed.

public class MultiDataSourceManagedTransaction extends SpringManagedTransaction {
    private DataSource dataSource;
    public ConcurrentHashMap
CON_MAP = new ConcurrentHashMap<>();
    public MultiDataSourceManagedTransaction(DataSource dataSource) {
        super(dataSource);
        this.dataSource = dataSource;
    }
    @Override
    public Connection getConnection() throws SQLException {
        Method getCurrentTargetKey;
        String dataSourceKey;
        try {
            getCurrentTargetKey = dataSource.getClass().getDeclaredMethod("getCurrentTargetKey");
            getCurrentTargetKey.setAccessible(true);
            dataSourceKey = (String) getCurrentTargetKey.invoke(dataSource);
        } catch (Exception e) {
            log.error("MultiDataSourceManagedTransaction invoke getCurrentTargetKey exception", e);
            return null;
        }
        if (CON_MAP.get(dataSourceKey) == null) {
            Connection connection = dataSource.getConnection();
            if (!TransactionSynchronizationManager.isActualTransactionActive()) {
                connection.setAutoCommit(true);
            } else {
                connection.setAutoCommit(false);
            }
            CON_MAP.put(dataSourceKey, connection);
            return connection;
        }
        return CON_MAP.get(dataSourceKey);
    }
    @Override
    public void commit() throws SQLException {
        if (CON_MAP == null || CON_MAP.size() == 0) {
            return;
        }
        for (Map.Entry
entry : CON_MAP.entrySet()) {
            Connection value = entry.getValue();
            if (!value.isClosed() && !value.getAutoCommit()) {
                value.commit();
            }
        }
    }
    @Override
    public void rollback() throws SQLException {
        if (CON_MAP == null || CON_MAP.size() == 0) {
            return;
        }
        for (Map.Entry
entry : CON_MAP.entrySet()) {
            Connection value = entry.getValue();
            if (value == null) continue;
            if (!value.isClosed() && !value.getAutoCommit()) {
                entry.getValue().rollback();
            }
        }
    }
    @Override
    public void close() throws SQLException {
        if (CON_MAP == null || CON_MAP.size() == 0) {
            return;
        }
        for (Map.Entry
entry : CON_MAP.entrySet()) {
            DataSourceUtils.releaseConnection(entry.getValue(), this.dataSource);
        }
        CON_MAP.clear();
    }
}

This is not a distributed transaction; it works within the same JVM. For true distributed transactions, solutions such as Atomikos could be considered.

Data Safety

We categorize safety measures into three cases:

Cross‑database transactions (6 cases) – handled by code‑level consistency guarantees and thorough pre‑release testing.

Single‑database transactions – rely on the custom transaction implementation, which is fully tested.

Simple single‑table operations – mainly involve adding data‑source annotations on mappers; hundreds of changes are automated, with runtime monitoring to capture missing‑table errors for quick remediation.

Application Splitting

The monolith poses systemic risks, high complexity, and test‑environment conflicts.

Splitting方案

Both approaches split the system into nine business‑aligned services, mirroring the database split.

Solution 1: Build empty new services and manually migrate code – low speed, high risk.

Solution 2: Clone the old system into nine new services, route traffic to them, then gradually prune redundant code – faster, lower initial risk, and flexible for later cleanup.

We chose Solution 2.

Splitting Practice

Build new services by copying the old code and changing the system name.

Implement a traffic router (filter) that forwards requests to the appropriate new service based on a mapping table and a test‑traffic header.

Generate a route‑map by scanning controllers and using a custom @TargetSystem annotation to indicate the target service.

Identify test traffic via a special request header (e.g., "systemRoute=1") and route only those requests.

Merge ongoing development code from the old system to the new ones using multiple Git remotes, ensuring history is preserved.

Mitigate launch risks such as duplicate JOB execution and premature MQ consumption by adding dynamic switches that can enable/disable jobs and MQ listeners per system.

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    HttpServletRequest servletRequest = (HttpServletRequest) request;
    HttpServletResponse servletResponse = (HttpServletResponse) response;
    // routing switch (0‑no routing, 1‑header based, 2‑full routing)
    final int systemRouteSwitch = configUtils.getInteger("system_route_switch", 1);
    if (systemRouteSwitch == 0) {
        filterChain.doFilter(request, response);
        return;
    }
    if (systemRouteSwitch == 1) {
        String systemRoute = servletRequest.getHeader("systemRoute");
        if (systemRoute == null || !systemRoute.equals("1")) {
            filterChain.doFilter(request, response);
            return;
        }
    }
    String systemRouteMapJsonStr = configUtils.getString("route.map", "");
    Map
map = JSONObject.parseObject(systemRouteMapJsonStr, Map.class);
    String rootUrl = map.get(servletRequest.getRequestURI());
    if (StringUtils.isEmpty(rootUrl)) {
        log.error("Routing failed, fallback to local handling. URI: {}", servletRequest.getRequestURI());
        filterChain.doFilter(request, response);
        return;
    }
    String targetURL = rootUrl + servletRequest.getRequestURI();
    if (servletRequest.getQueryString() != null) {
        targetURL = targetURL + "?" + servletRequest.getQueryString();
    }
    // ... (rest of routing logic) ...
}

We also generate the route‑map programmatically:

public Map
generateRouteMap() {
    Map
handlerMethods = requestMappingHandlerMapping.getHandlerMethods();
    Map
map = new HashMap<>();
    for (Map.Entry
entry : handlerMethods.entrySet()) {
        RequestMappingInfo key = entry.getKey();
        HandlerMethod value = entry.getValue();
        Class declaringClass = value.getMethod().getDeclaringClass();
        TargetSystem targetSystem = (TargetSystem) declaringClass.getAnnotation(TargetSystem.class);
        String targetUrl = targetSystem.value();
        String s1 = key.getPatternsCondition().toString();
        String url = s1.substring(1, s1.length() - 1);
        map.put(url, targetUrl);
    }
    return map;
}

Data‑Access Permission Consolidation

Problem Introduction

Before consolidation, business databases were accessed directly by other applications, bypassing the owning service’s RPC layer, leading to tight coupling and maintenance difficulties.

Refactoring Process

Count RPC interfaces needed by comparing entry points with the databases they access; 260+ cross‑business DAO calls were identified.

Generate RPC interfaces automatically using a code‑generation tool that parses DAO files, extracts class context (method signatures, imports, packages), applies Freemarker templates, and creates corresponding API and RPC classes.

Implement a gray‑release strategy where the new RPC layer initially forwards calls to both the original DAO and the new RPC implementation, compares results, and gradually switches traffic to the RPC layer once verified.

Benefits

Business data is decoupled; each vertical system owns its data access.

Future enhancements such as caching or degradation can be added at the API layer.

Conclusion

The three‑stage migration—database splitting, application splitting, and data‑access permission consolidation—smoothly transformed a monolithic system into a micro‑service architecture. It eliminated single‑point database failures, improved performance, simplified business logic, enabled independent scaling, and provided a solid foundation for future iterative development.

BackendMicroservicescustom transactiondatabase splittingmulti-datasource
Dada Group Technology
Written by

Dada Group Technology

Sharing insights and experiences from Dada Group's R&D department on product refinement and technology advancement, connecting with fellow geeks to exchange ideas and grow together.

0 followers
Reader feedback

How this landed with the community

login 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.