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

> Source: <https://dev.to/genevieve_breton_cb795f52/i-tried-to-obfuscate-my-java-code-before-sending-it-to-ai-heres-what-broke-5615>
> Published: 2026-07-08 17:22:49+00:00

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

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);
    }
}
```

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

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

```
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
    List<Invoice> findByActiveTrue();
    long countByPaidFalse();
}
```

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!
```

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*`

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

`@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);
```

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'
```

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:

**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`

.

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

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;
}
```

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
```

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:

`customerName`

→ `fld_8c9d0e1f`

`getCustomerName()`

callsites → `getFld_8c9d0e1f()`

`setCustomerName(...)`

callsites → `setFld_8c9d0e1f(...)`

`isPaid()`

→ `isFld_0c1d2e3f()`

(boolean variant uses `is`

, not `get`

)`.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.

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

`@JsonProperty`

and DTO classes`dto/`

, `model/`

, or `request/response/`

package gets its fields preserved.`@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.`Class.forName("com.acme.InvoiceService")`

is a string that has to be updated in lockstep with the class rename. Same for `getMethod("calculateTotal")`

.`@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.

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

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.

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:

`mvn test-compile`

(or `mvn test`

if you trust your tests).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.

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](https://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.
