DEV Community

Genevieve Breton
Genevieve Breton

Posted on

I tried to obfuscate my Java code before sending it to AI — here's what broke

When a client added a "no AI assistants on our codebase" clause to my contract last year, I did what any developer would do: I assumed I could solve it with regex.

The plan was simple. Before sending Java source to Claude, I'd rename every identifier — InvoiceService becomes Cls_a1b2c3d4, customerName becomes fld_e5f6a7b8. The AI works on the obfuscated version, I rename everything back, the AI never sees my actual domain. Five hundred lines of code, two days max. I was sure of it.

It took two weeks before I had something that even compiled, and another month before the tests passed. This article is a tour of the things that broke and why each one is a different shape of the same lesson: Java code obfuscation for AI is a framework problem, not a string-replace problem.


The naive approach

If you've never tried this, the obvious approach feels obvious for a reason. You parse the Java file, collect the identifiers (classes, methods, fields, packages), generate a deterministic hash for each one, replace every occurrence. Roughly:

// Before
public class InvoiceService {
    public Invoice calculateDiscount(Invoice invoice) {
        return invoice.applyDiscount(0.10);
    }
}

// After
public class Cls_a1b2c3d4 {
    public Cls_e5f6a7b8 mtd_9c8d7e6f(Cls_e5f6a7b8 fld_3a4b5c6d) {
        return fld_3a4b5c6d.mtd_2c1b0a9f(0.10);
    }
}
Enter fullscreen mode Exit fullscreen mode

Fine, right? The AI sees something structurally identical — same control flow, same types, same method calls — but with no business meaning to leak. JavaParser handles the AST, you walk it once, you're done.

You're not done. This works on a hello-world program. The first time you run it on a real Spring Boot project, your application context fails to start. And the failure has nothing to do with the AI yet — it's the framework loudly telling you that the names you renamed meant something to it.


Failure #1: Spring Data

This was my first proper "oh." moment. Consider:

public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
    List<Invoice> findByActiveTrue();
    long countByPaidFalse();
}
Enter fullscreen mode Exit fullscreen mode

After my naive pass, findByActiveTrue became mtd_a3f9b2c1. The class compiled, the application seemed fine — and then at the first request, Spring threw:

org.springframework.data.mapping.PropertyReferenceException:
No property mtd_a3f9b2c1 found for type Invoice!
Enter fullscreen mode Exit fullscreen mode

I read that error five times before I understood. Spring Data doesn't generate the SQL for findByActiveTrue from any implementation — there is no implementation. It generates the SQL from the method name itself at bean-creation time. findBy + Active + True becomes WHERE active = true. Rename the method to mtd_a3f9b2c1 and Spring tries to find a property called mtd_a3f9b2c1 on the Invoice entity. There isn't one. Crash.

The lesson: in Spring Data, the method name is the query. It is not a label that can be renamed independently of its meaning. Obfuscate it and you destroy its semantic content by definition.

The fix was to scan the project for interfaces extending Repository, CrudRepository, JpaRepository, etc., and for each one, mark every method matching the Spring Data prefix patterns as preserved:

  • findBy*, readBy*, getBy*, queryBy*, searchBy*, streamBy*
  • existsBy*, countBy*
  • deleteBy*, removeBy*
  • All the variants: And, Or, OrderBy, IgnoreCase, Distinct, First, Top10...

That set goes into an exclusion list applied as the obfuscation pass runs. Classes get renamed, fields get renamed, but Spring Data's contract methods are preserved exactly.

The first time the obfuscated project compiled and started cleanly, I cried a little.


Failure #2: JPA @Query annotations

I thought I was through the worst. Then I tried it on a project with custom JPQL:

@Query("SELECT i FROM Invoice i WHERE i.customer.id = :customerId")
List<Invoice> findUnpaidByCustomer(@Param("customerId") Long customerId);
Enter fullscreen mode Exit fullscreen mode

After obfuscation, the Invoice class became Cls_e5f6a7b8. Hibernate happily compiled. At runtime, the query failed:

org.hibernate.query.SemanticException: Could not resolve entity 'Invoice'
Enter fullscreen mode Exit fullscreen mode

JPQL is not SQL. It references entity class names, not table names. The string "SELECT i FROM Invoice i ..." says "select from the entity class called Invoice." If the class is now called Cls_e5f6a7b8, the string has to say "SELECT i FROM Cls_e5f6a7b8 i ...".

Two follow-up traps:

  1. Field accessors inside the JPQL. i.customer.id is also a path through entity field names. If customer was renamed to fld_2c3d4e5f, the string has to become i.fld_2c3d4e5f.id.

  2. Native queries are different. @Query(value = "SELECT * FROM invoices WHERE total > ?1", nativeQuery = true) references the database table and column names — those are part of the schema, not the Java identifier space. They must NOT be renamed.

So I added a post-processing pass: scan every @Query value, identify whether it's JPQL or native (the annotation argument tells you), and for JPQL, replace entity-name and field-name tokens in sync with the rest of the obfuscation. The native ones get left alone.


Failure #3: Lombok

This one almost made me give up. Lombok generates code at compile time. Consider:

@Data
public class InvoiceDto {
    private Long id;
    private String customerName;
    private boolean paid;
}
Enter fullscreen mode Exit fullscreen mode

Lombok generates getId(), setId(Long), getCustomerName(), setCustomerName(String), isPaid(), setPaid(boolean). The accessor names are mechanically derived from the field names at compile time.

If I rename customerName to fld_8c9d0e1f, Lombok will run on my obfuscated source and generate getFld_8c9d0e1f(). Meanwhile, every callsite in the rest of the codebase still calls dto.getCustomerName(). The build fails:

cannot find symbol
  symbol:   method getCustomerName()
  location: variable dto of type Cls_e5f6a7b8
Enter fullscreen mode Exit fullscreen mode

The fix is two-step. First, detect Lombok-instrumented classes (@Data, @Getter, @Setter, @Value, @Builder, @RequiredArgsConstructor, etc.). Second, when renaming a field on such a class, also rename the implicit accessors at every callsite in the project:

  • customerNamefld_8c9d0e1f
  • getCustomerName() callsites → getFld_8c9d0e1f()
  • setCustomerName(...) callsites → setFld_8c9d0e1f(...)
  • isPaid()isFld_0c1d2e3f() (boolean variant uses is, not get)
  • Builder method .customerName(...).fld_8c9d0e1f(...)

It's a coordinated rename, not a per-identifier rename. Once you see this, you start seeing it everywhere — Jackson does the same kind of generation, Bean Validation references field names from constraints, OpenAPI generates schemas from field names.


Failure #4: the rest of the iceberg

I'll spare you the long version. In quick succession I found:

  • Jackson @JsonProperty and DTO classes — field names become JSON keys. Rename the field, your API contract changes silently. Detector side: any DTO in a dto/, model/, or request/response/ package gets its fields preserved.
  • Bean Validation@NotBlank private String email; — the constraint message templates and {validatedValue} interpolation reference the field name.
  • @ConfigurationProperties@ConfigurationProperties(prefix = "app") binds YAML keys to field names. Rename the field, the application.yml no longer maps.
  • ReflectionClass.forName("com.acme.InvoiceService") is a string that has to be updated in lockstep with the class rename. Same for getMethod("calculateTotal").
  • OpenAPI / Swagger@Schema annotations document field names that end up in your API documentation. Rename and the contract documentation drifts.

Each of these gets its own detector. Eight detectors total in the version that finally worked.


What actually works: framework detection before identifier collection

The mental model that finally clicked, after the third or fourth rewrite:

Pass 0:  Scan the project for framework annotations and conventions.
         For each detected framework, generate exclusion rules.

Pass 1:  Collect identifiers (classes, methods, fields, packages, enums, records)
         using a JavaParser AST. Filter against the exclusion list.

Pass 2:  Replace identifiers in source files. Word-boundary regex,
         longest-match-first ordering.

Pass 3:  Post-process strings in @Query, @ComponentScan, Class.forName(),
         getMethod() — apply identifier replacement inside specific contexts.

Pass 4:  Compile. If it fails, parse compiler errors, reverse-map the broken
         identifiers, add them to the exclusion list, re-run from Pass 1.
         Repeat until green or max iterations.
Enter fullscreen mode Exit fullscreen mode

The detector-per-framework architecture means you can't think of it as one big "obfuscate Java" function. You think of it as a coordination problem between renaming and the conventions each framework uses. Once you have the detector for Spring Data, adding the detector for Spring Config or OpenAPI is mechanical.


The auto-fix loop as safety net

No matter how careful the detectors are, real Java projects have edge cases. Some annotation processor you've never heard of generates code based on a field name. Some library uses reflection in a way the detectors don't catch. Some method name collides with a JDK method after obfuscation.

For everything detection misses, the auto-fix loop is the safety net:

  1. Run obfuscation.
  2. Run mvn test-compile (or mvn test if you trust your tests).
  3. If compilation fails, parse the compiler errors. They reference the obfuscated identifier; reverse-map it.
  4. Add the original identifier to the exclusion list (persist it, so it applies on future runs).
  5. Re-obfuscate.
  6. Goto 2.

In practice the loop converges in 2–4 iterations on a fresh project. The exclusion list grows to a few hundred entries on a 50-class project, then stabilizes. Subsequent runs just use the saved exclusions and don't need iteration.


Why this is a framework problem, not a string-replace problem

Looking back, my original mistake was thinking of the AI privacy problem as primarily a text problem (replace strings before sending to API). It is not. It is a framework problem (preserve the conventions that the frameworks rely on while renaming everything else).

The frameworks I depend on every day — Spring, JPA, Lombok, Jackson, Bean Validation, OpenAPI — encode meaning in identifier names intentionally. They were designed to reduce boilerplate by deriving behavior from naming conventions. That same design choice makes them incompatible with naive obfuscation. To obfuscate Java code safely for AI, you have to know each framework's contract and respect it.

There is no shortcut. The two-day project became a six-month project and more. But once the detector-per-framework architecture is in place, adding new framework support is incremental, the auto-fix loop handles the long tail, and the result is a Java codebase that the AI sees in obfuscated form, works on, and produces changes that re-apply cleanly to the real source — with the build still green.


If you want to see the detectors in action on real Java, the framework-by-framework before/after examples are in gitlab.com/gbreton7/promptcape-docs. I built this as PromptCape — a Java-first obfuscation proxy for Claude Code, Cursor, and other AI coding assistants — but the design lessons here apply to any code obfuscation pipeline that has to coexist with framework conventions. MRs welcome on the docs repo if I'm missing your favorite framework.

Top comments (0)