From MyBatis Interceptor to Annotation-Driven AOP: Refactoring Multi-Environment Data Isolation in Java

A senior Java engineer recounts how a team evolved from a MyBatis interceptor for environment-based data isolation to a clean annotation-plus-AOP solution after a junior developer's ThreadLocal-based quick fix caused nested-call bugs and scattered boilerplate across the codebase.

Architect's Guide
Architect's Guide
Architect's Guide
From MyBatis Interceptor to Annotation-Driven AOP: Refactoring Multi-Environment Data Isolation in Java

1. Historical Background: Data Isolation Challenge

The team ran pre-release, gray, and production environments against a single shared database. Every table carried an env column whose value differed per environment (e.g., pre, gray, online). Initially only one core table had this column; after a pre-release operation accidentally mutated production data, the remaining 20+ tables were retrofitted with env. Historical rows were initialized to 'all' to remain visible in every environment.

2. First Isolation Implementation: MyBatis Interceptor + JSqlParser

To avoid touching every DO, Mapper, and XML file, the author built a custom MyBatis interceptor that rewrites SQL on the fly:

On INSERT: automatically fill the env column with the current environment value (read from application.properties).

On SELECT / UPDATE / DELETE: append env IN (${currentEnv}, 'all') to the WHERE clause so historical 'all' rows stay accessible.

The interceptor uses

@Intercepts(@Signature(type=Executor.class, method="update", args={MappedStatement.class, Object.class}))

and leverages JSqlParser to parse and rewrite the bound SQL. This centralized approach meant zero business-code changes and safe migration of existing data.

3. Evolving Requirements & the Junior Developer's Attempt

New needs emerged:

Upstream/downstream RPC partners used different environment naming, requiring selective skipping of the env filter.

Some environments (e.g., pre + gray) should share data.

Developers wanted to correct production data from the pre-release environment.

A junior engineer (≈2 years experience) implemented a quick fix: in each service method that needed to bypass the filter, he hard-coded three lines that swapped the ThreadLocal -held filter value:

String oriFilterEnv = UserHolder.getUser().getFilterEnv();
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());
// ... business logic ...
UserHolder.getUser().setFilterEnv(oriFilterEnv);

This pattern proliferated across dozens of methods. The bug surfaced when method A called method B; B cleared the ThreadLocal on exit, so A subsequently read null.

4. Design Flaws Identified

Violates Open/Closed Principle — every new skip scenario demands more copy-paste.

High risk of missing a spot ("did we forget one?").

Mixes business logic with cross-cutting infrastructure concerns.

Abuses the user-context ThreadLocal for an unrelated purpose.

Magic strings and 500-character lines scattered everywhere.

5. Refactored Solution: Annotation + AOP + Dedicated ThreadLocal

The author redesigned the mechanism with three pillars:

Dedicated ThreadLocal — a separate EnvRuleContext holder, isolated from user data.

Custom annotation @InvokeChainSkipEnvRule placed on entry-point methods (controllers, job handlers):

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {
    boolean isSkip() default true;
    String[] skipEnvList() default {};
    String[] skipTableList() default {};
}

AOP Aspect — reads the annotation, builds a rule object, pushes it into EnvRuleContext before proceeding, and cleans up in a finally block.

MyBatis Interceptor — now consults EnvRuleContext; if a rule matches the current table and environment, it omits the env predicate entirely (or expands it to all environments).

Usage example:

@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
@GetMapping("/importSignedUserData")
public void importSignedUserData(HttpServletRequest req, HttpServletResponse resp) { ... }

6. Known Limitations

Granularity is at the whole-call-chain level for the specified tables — cannot skip for a single query inside the chain.

Annotation must be placed on the entry point; shared library methods should avoid it to prevent unintended widening.

7. Retrospective Lessons

Isolation pattern : Interceptor for transparent filtering; annotation+AOP for controlled opt-out.

Coding discipline : "Write it twice, refactor once." Prefer centralized cross-cutting logic (annotations, interceptors) over scattered boilerplate.

Design upfront : Separate databases per environment would have avoided the whole problem; evaluate whether a requirement truly justifies the complexity.

Annotation+AOP catalog : The same pattern applies to distributed locks, compliance validation, data permissions, routing strategies, etc.

In an environment that only rewards business outcomes, not technical craft, one wonders: how much does this refactoring really matter? The body is giving out; the age of obsessing over code purity may be passing.
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.

Design PatternsJavaAOPMyBatisData IsolationInterceptorrefactoringThreadLocal
Architect's Guide
Written by

Architect's Guide

Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.

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.